Unverified Commit 8142037e authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1869 from hypothesis/create-annotation-service

Move `createAnnotation` to a service
parents ec5d1b01 5ee1e30d
import classnames from 'classnames';
import { createElement } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { useState } from 'preact/hooks';
import propTypes from 'prop-types';
import useStore from '../store/use-store';
import {
isHighlight,
isNew,
isReply,
quote,
} from '../util/annotation-metadata';
import { isNew, isReply, quote } from '../util/annotation-metadata';
import { isShared } from '../util/permissions';
import { withServices } from '../util/service-context';
......@@ -56,18 +51,6 @@ function AnnotationOmega({
const toggleAction = threadIsCollapsed ? 'Show replies' : 'Hide replies';
const toggleText = `${toggleAction} (${replyCount})`;
useEffect(() => {
// TEMPORARY. Create a new draft for new (non-highlight) annotations
// to put the component in "edit mode."
if (!isSaving && !draft && isNew(annotation) && !isHighlight(annotation)) {
createDraft(annotation, {
tags: annotation.tags,
text: annotation.text,
isPrivate: !isShared(annotation.permissions),
});
}
}, [annotation, draft, createDraft, isSaving]);
const shouldShowActions = !isSaving && !isEditing && !isNew(annotation);
const shouldShowLicense = isEditing && !isPrivate && group.type !== 'private';
const shouldShowReplyToggle = replyCount > 0 && !isReply(annotation);
......@@ -80,14 +63,7 @@ function AnnotationOmega({
createDraft(annotation, { ...draft, text });
};
const onReply = () => {
// TODO: Re-evaluate error handling here
if (!userid) {
flash.error('Please log in to reply to annotations');
} else {
annotationsService.reply(annotation, userid);
}
};
const onReply = () => annotationsService.reply(annotation, userid);
const onSave = async () => {
setIsSaving(true);
......
......@@ -82,15 +82,6 @@ function AnnotationController(
* new client-side).
*/
newlyCreatedByHighlightButton = self.annotation.$highlight || false;
// If this annotation is not a highlight and if it's new (has just been
// created by the annotate button) or it has edits not yet saved to the
// server - then open the editor on AnnotationController instantiation.
if (!newlyCreatedByHighlightButton) {
if (isNew(self.annotation) || store.getDraft(self.annotation)) {
self.edit();
}
}
};
/**
......
......@@ -8,11 +8,10 @@ import { applyTheme } from '../util/theme';
import Button from './button';
function NewNoteButton({ settings }) {
function NewNoteButton({ annotationsService, settings }) {
const topLevelFrame = useStore(store => store.mainFrame());
const isLoggedIn = useStore(store => store.isLoggedIn());
const createAnnotation = useStore(store => store.createAnnotation);
const openSidebarPanel = useStore(store => store.openSidebarPanel);
const onNewNoteBtnClick = function() {
......@@ -24,7 +23,7 @@ function NewNoteButton({ settings }) {
target: [],
uri: topLevelFrame.uri,
};
createAnnotation(annot);
annotationsService.create(annot);
};
return (
......@@ -42,9 +41,10 @@ function NewNoteButton({ settings }) {
}
NewNoteButton.propTypes = {
// Injected services.
annotationsService: propTypes.object.isRequired,
settings: propTypes.object.isRequired,
};
NewNoteButton.injectedProps = ['settings'];
NewNoteButton.injectedProps = ['annotationsService', 'settings'];
export default withServices(NewNoteButton);
......@@ -445,23 +445,6 @@ describe('AnnotationOmega', () => {
describe('annotation actions', () => {
describe('replying to an annotation', () => {
// nb: There's no reason this logic needs to stay within `AnnotationOmega`
// once we've migrated to it; it could happily move to `AnnotationActionBar`
it('should show a flash alert if user not logged in', () => {
// No logged-in user...
fakeStore.profile.returns({});
const wrapper = createComponent();
wrapper
.find('AnnotationActionBar')
.props()
.onReply();
assert.calledOnce(fakeFlash.error);
assert.notCalled(fakeAnnotationsService.reply);
});
it('should create a reply', () => {
const theAnnot = fixtures.defaultAnnotation();
const wrapper = createComponent({ annotation: theAnnot });
......
......@@ -235,28 +235,6 @@ describe('annotation', function() {
sandbox.restore();
});
describe('initialization', function() {
it('creates drafts for new annotations on initialization', function() {
const annotation = fixtures.newAnnotation();
createDirective(annotation);
assert.calledWith(fakeStore.createDraft, annotation, {
isPrivate: false,
tags: annotation.tags,
text: annotation.text,
});
});
it('edits annotations with drafts on initialization', function() {
const annotation = fixtures.oldAnnotation();
// The drafts store has some draft changes for this annotation.
fakeStore.getDraft.returns({ text: 'foo', tags: [] });
const controller = createDirective(annotation).controller;
assert.isTrue(controller.editing());
});
});
describe('#editing()', function() {
it('returns false if the annotation does not have a draft', function() {
const controller = createDirective().controller;
......
......@@ -11,13 +11,22 @@ import mockImportedComponents from '../../../test-util/mock-imported-components'
describe('NewNoteButton', function() {
let fakeStore;
let fakeAnnotationsService;
let fakeSettings;
function createComponent() {
return mount(<NewNoteButton settings={fakeSettings} />);
return mount(
<NewNoteButton
annotationsService={fakeAnnotationsService}
settings={fakeSettings}
/>
);
}
beforeEach(function() {
fakeAnnotationsService = {
create: sinon.stub(),
};
fakeSettings = {
branding: {
ctaBackgroundColor: '#00f',
......@@ -77,7 +86,7 @@ describe('NewNoteButton', function() {
.onClick();
});
assert.calledWith(fakeStore.createAnnotation, {
assert.calledWith(fakeAnnotationsService.create, {
target: [],
uri: 'thisFrame',
});
......
import SearchClient from '../search-client';
import { isNew, isPublic } from '../util/annotation-metadata';
import { privatePermissions, sharedPermissions } from '../util/permissions';
import * as metadata from '../util/annotation-metadata';
import {
defaultPermissions,
privatePermissions,
sharedPermissions,
} from '../util/permissions';
import { generateHexString } from '../util/random';
import uiConstants from '../ui-constants';
// @ngInject
export default function annotationsService(
......@@ -12,6 +18,88 @@ export default function annotationsService(
) {
let searchClient = null;
/**
* Extend new annotation objects with defaults and permissions.
*/
function initialize(annotationData, now = new Date()) {
const defaultPrivacy = store.getDefault('annotationPrivacy');
const groupid = store.focusedGroupId();
const profile = store.profile();
const userid = profile.userid;
const userInfo = profile.user_info;
// We need a unique local/app identifier for this new annotation such
// that we might look it up later in the store. It won't have an ID yet,
// as it has not been persisted to the service.
const $tag = generateHexString(8);
let permissions = defaultPermissions(userid, groupid, defaultPrivacy);
// Highlights are peculiar in that they always have private permissions
if (metadata.isHighlight(annotationData)) {
permissions = privatePermissions(userid);
}
return Object.assign(
{
created: now.toISOString(),
group: groupid,
permissions,
tags: [],
text: '',
updated: now.toISOString(),
user: userid,
user_info: userInfo,
$tag: $tag,
},
annotationData
);
}
/**
* Populate a new annotation object from `annotation` and add it to the store.
* Create a draft for it unless it's a highlight and clear other empty
* drafts out of the way.
*
* @param {Object} annotationData
* @param {Date} now
*/
function create(annotationData, now = new Date()) {
const annotation = initialize(annotationData, now);
store.addAnnotations([annotation]);
// Remove other drafts that are in the way, and their annotations (if new)
store.deleteNewAndEmptyDrafts();
// Create a draft unless it's a highlight
if (!metadata.isHighlight(annotation)) {
store.createDraft(annotation, {
tags: annotation.tags,
text: annotation.text,
isPrivate: !metadata.isPublic(annotation),
});
}
// NB: It may make sense to move the following code at some point to
// the UI layer
// Select the correct tab
// If the annotation is of type note or annotation, make sure
// the appropriate tab is selected. If it is of type reply, user
// stays in the selected tab.
if (metadata.isPageNote(annotation)) {
store.selectTab(uiConstants.TAB_NOTES);
} else if (metadata.isAnnotation(annotation)) {
store.selectTab(uiConstants.TAB_ANNOTATIONS);
}
(annotation.references || []).forEach(parent => {
// Expand any parents of this annotation.
store.setCollapsed(parent, false);
});
}
/**
* Load annotations for all URIs and groupId.
*
......@@ -90,14 +178,14 @@ export default function annotationsService(
function reply(annotation, userid) {
const replyAnnotation = {
group: annotation.group,
permissions: isPublic(annotation)
permissions: metadata.isPublic(annotation)
? sharedPermissions(userid, annotation.group)
: privatePermissions(userid),
references: (annotation.references || []).concat(annotation.id),
target: [{ source: annotation.target[0].source }],
uri: annotation.uri,
};
store.createAnnotation(replyAnnotation);
create(replyAnnotation);
}
/**
......@@ -110,7 +198,7 @@ export default function annotationsService(
const annotationWithChanges = applyDraftChanges(annotation);
if (isNew(annotation)) {
if (metadata.isNew(annotation)) {
saved = api.annotation.create({}, annotationWithChanges);
} else {
saved = api.annotation.update(
......@@ -136,6 +224,7 @@ export default function annotationsService(
}
return {
create,
load,
reply,
save,
......
......@@ -39,6 +39,7 @@ const sortFns = {
// @ngInject
export default function RootThread(
$rootScope,
annotationsService,
store,
searchFilter,
viewFilter
......@@ -114,7 +115,7 @@ export default function RootThread(
});
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, function(event, ann) {
store.createAnnotation(ann);
annotationsService.create(ann);
});
// Remove any annotations that are deleted or unloaded
......
import EventEmitter from 'tiny-emitter';
import * as fixtures from '../../test/annotation-fixtures';
import uiConstants from '../../ui-constants';
import annotationsService from '../annotations';
import { $imports } from '../annotations';
......@@ -38,11 +39,11 @@ describe('annotationService', () => {
let fakeStreamer;
let fakeStreamFilter;
let fakeMetadata;
let fakeUris;
let fakeGroupId;
let fakeIsNew;
let fakeIsPublic;
let fakeDefaultPermissions;
let fakePrivatePermissions;
let fakeSharedPermissions;
......@@ -64,8 +65,7 @@ describe('annotationService', () => {
search: sinon.stub(),
};
fakeIsNew = sinon.stub().returns(true);
fakeIsPublic = sinon.stub().returns(true);
fakeDefaultPermissions = sinon.stub();
fakePrivatePermissions = sinon.stub().returns({
read: ['acct:foo@bar.com'],
......@@ -75,20 +75,33 @@ describe('annotationService', () => {
fakeSharedPermissions = sinon.stub().returns({
read: ['group:__world__'],
});
fakeMetadata = {
isAnnotation: sinon.stub(),
isHighlight: sinon.stub(),
isNew: sinon.stub(),
isPageNote: sinon.stub(),
isPublic: sinon.stub(),
};
fakeStore = {
addAnnotations: sinon.stub(),
annotationFetchFinished: sinon.stub(),
annotationFetchStarted: sinon.stub(),
createAnnotation: sinon.stub(),
createDraft: sinon.stub(),
deleteNewAndEmptyDrafts: sinon.stub(),
focusedGroupId: sinon.stub(),
frames: sinon.stub(),
getDefault: sinon.stub(),
getDraft: sinon.stub().returns(null),
getState: sinon.stub(),
hasSelectedAnnotations: sinon.stub(),
profile: sinon.stub().returns({}),
removeDraft: sinon.stub(),
searchUris: sinon.stub(),
savedAnnotations: sinon.stub(),
selectTab: sinon.stub(),
setCollapsed: sinon.stub(),
updateFrameAnnotationFetchStatus: sinon.stub(),
};
fakeStreamer = {
setConfig: sinon.stub(),
connect: sinon.stub(),
......@@ -105,11 +118,9 @@ describe('annotationService', () => {
$imports.$mock({
'../search-client': FakeSearchClient,
'../util/annotation-metadata': {
isNew: fakeIsNew,
isPublic: fakeIsPublic,
},
'../util/annotation-metadata': fakeMetadata,
'../util/permissions': {
defaultPermissions: fakeDefaultPermissions,
privatePermissions: fakePrivatePermissions,
sharedPermissions: fakeSharedPermissions,
},
......@@ -136,6 +147,166 @@ describe('annotationService', () => {
);
}
describe('create', () => {
let now;
let svc;
const getLastAddedAnnotation = () => {
if (fakeStore.addAnnotations.callCount <= 0) {
return null;
}
const callCount = fakeStore.addAnnotations.callCount;
return fakeStore.addAnnotations.getCall(callCount - 1).args[0][0];
};
beforeEach(() => {
now = new Date();
svc = service();
fakeStore.focusedGroupId.returns('mygroup');
fakeStore.profile.returns({
userid: 'acct:foo@bar.com',
user_info: {},
});
});
it('extends the provided annotation object with defaults', () => {
fakeStore.focusedGroupId.returns('mygroup');
svc.create({}, now);
const annotation = getLastAddedAnnotation();
assert.equal(annotation.created, now.toISOString());
assert.equal(annotation.group, 'mygroup');
assert.isArray(annotation.tags);
assert.isEmpty(annotation.tags);
assert.isString(annotation.text);
assert.isEmpty(annotation.text);
assert.equal(annotation.updated, now.toISOString());
assert.equal(annotation.user, 'acct:foo@bar.com');
assert.isOk(annotation.$tag);
assert.isString(annotation.$tag);
});
describe('annotation permissions', () => {
it('sets private permissions if default privacy level is "private"', () => {
fakeStore.getDefault.returns('private');
fakeDefaultPermissions.returns('private-permissions');
svc.create({}, now);
const annotation = getLastAddedAnnotation();
assert.calledOnce(fakeDefaultPermissions);
assert.calledWith(
fakeDefaultPermissions,
'acct:foo@bar.com',
'mygroup',
'private'
);
assert.equal(annotation.permissions, 'private-permissions');
});
it('sets shared permissions if default privacy level is "shared"', () => {
fakeStore.getDefault.returns('shared');
fakeDefaultPermissions.returns('default permissions');
svc.create({}, now);
const annotation = getLastAddedAnnotation();
assert.calledOnce(fakeDefaultPermissions);
assert.calledWith(
fakeDefaultPermissions,
'acct:foo@bar.com',
'mygroup',
'shared'
);
assert.equal(annotation.permissions, 'default permissions');
});
it('sets private permissions if annotation is a highlight', () => {
fakeMetadata.isHighlight.returns(true);
fakePrivatePermissions.returns('private permissions');
fakeDefaultPermissions.returns('default permissions');
svc.create({}, now);
const annotation = getLastAddedAnnotation();
assert.calledOnce(fakePrivatePermissions);
assert.equal(annotation.permissions, 'private permissions');
});
});
it('creates a draft for the new annotation', () => {
fakeMetadata.isHighlight.returns(false);
svc.create(fixtures.newAnnotation(), now);
assert.calledOnce(fakeStore.createDraft);
});
it('adds the annotation to the store', () => {
svc.create(fixtures.newAnnotation(), now);
assert.calledOnce(fakeStore.addAnnotations);
});
it('deletes other empty drafts for new annotations', () => {
svc.create(fixtures.newAnnotation(), now);
assert.calledOnce(fakeStore.deleteNewAndEmptyDrafts);
});
it('does not create a draft if the annotation is a highlight', () => {
fakeMetadata.isHighlight.returns(true);
svc.create(fixtures.newAnnotation(), now);
assert.notCalled(fakeStore.createDraft);
});
describe('automatic tab selection', () => {
it('sets the active tab to "Page Notes" if the annotation is a Page Note', () => {
fakeMetadata.isPageNote.returns(true);
svc.create(fixtures.newAnnotation(), now);
assert.calledOnce(fakeStore.selectTab);
assert.calledWith(fakeStore.selectTab, uiConstants.TAB_NOTES);
});
it('sets the active tab to "Annotations" if the annotation is an annotation', () => {
fakeMetadata.isAnnotation.returns(true);
svc.create(fixtures.newAnnotation(), now);
assert.calledOnce(fakeStore.selectTab);
assert.calledWith(fakeStore.selectTab, uiConstants.TAB_ANNOTATIONS);
});
it('does nothing if the annotation is neither an annotation nor a page note (e.g. reply)', () => {
fakeMetadata.isAnnotation.returns(false);
fakeMetadata.isPageNote.returns(false);
svc.create(fixtures.newAnnotation(), now);
assert.notCalled(fakeStore.selectTab);
});
});
it("expands all of the new annotation's parents", () => {
const annot = fixtures.newAnnotation();
annot.references = ['aparent', 'anotherparent', 'yetanotherancestor'];
svc.create(annot, now);
assert.equal(fakeStore.setCollapsed.callCount, 3);
assert.calledWith(fakeStore.setCollapsed, 'aparent', false);
assert.calledWith(fakeStore.setCollapsed, 'anotherparent', false);
assert.calledWith(fakeStore.setCollapsed, 'yetanotherancestor', false);
});
});
describe('load', () => {
it('unloads any existing annotations', () => {
// When new clients connect, all existing annotations should be unloaded
......@@ -323,7 +494,7 @@ describe('annotationService', () => {
svc.reply(annotation, 'acct:foo@bar.com');
assert.calledOnce(fakeStore.createAnnotation);
assert.calledOnce(fakeStore.addAnnotations);
});
it('associates the reply with the annotation', () => {
......@@ -331,7 +502,7 @@ describe('annotationService', () => {
svc.reply(annotation, 'acct:foo@bar.com');
const reply = fakeStore.createAnnotation.getCall(0).args[0];
const reply = fakeStore.addAnnotations.getCall(0).args[0][0];
assert.equal(
reply.references[reply.references.length - 1],
......@@ -343,26 +514,26 @@ describe('annotationService', () => {
});
it('uses public permissions if annotation is public', () => {
fakeIsPublic.returns(true);
fakeMetadata.isPublic.returns(true);
fakeSharedPermissions.returns('public');
const annotation = filledAnnotation();
svc.reply(annotation, 'acct:foo@bar.com');
const reply = fakeStore.createAnnotation.getCall(0).args[0];
const reply = fakeStore.addAnnotations.getCall(0).args[0][0];
assert.equal(reply.permissions, 'public');
});
it('uses private permissions if annotation is private', () => {
fakeIsPublic.returns(false);
fakeMetadata.isPublic.returns(false);
fakePrivatePermissions.returns('private');
const annotation = filledAnnotation();
svc.reply(annotation, 'acct:foo@bar.com');
const reply = fakeStore.createAnnotation.getCall(0).args[0];
const reply = fakeStore.addAnnotations.getCall(0).args[0][0];
assert.equal(reply.permissions, 'private');
});
});
......@@ -375,7 +546,7 @@ describe('annotationService', () => {
});
it('calls the `create` API service for new annotations', () => {
fakeIsNew.returns(true);
fakeMetadata.isNew.returns(true);
// Using the new-annotation fixture has no bearing on which API method
// will get called because `isNew` is mocked, but it has representative
// properties
......@@ -387,7 +558,7 @@ describe('annotationService', () => {
});
it('calls the `update` API service for pre-existing annotations', () => {
fakeIsNew.returns(false);
fakeMetadata.isNew.returns(false);
const annotation = fixtures.defaultAnnotation();
return svc.save(annotation).then(() => {
......@@ -397,6 +568,7 @@ describe('annotationService', () => {
});
it('calls the relevant API service with an object that has any draft changes integrated', () => {
fakeMetadata.isNew.returns(true);
fakePrivatePermissions.returns({ read: ['foo'] });
const annotation = fixtures.defaultAnnotation();
annotation.text = 'not this';
......@@ -424,7 +596,7 @@ describe('annotationService', () => {
context('successful save', () => {
it('copies over internal app-specific keys to the annotation object', () => {
fakeIsNew.returns(false);
fakeMetadata.isNew.returns(false);
const annotation = fixtures.defaultAnnotation();
annotation.$tag = 'mytag';
annotation.$foo = 'bar';
......@@ -450,7 +622,7 @@ describe('annotationService', () => {
it('adds the updated annotation to the store', () => {
const annotation = fixtures.defaultAnnotation();
fakeIsNew.returns(false);
fakeMetadata.isNew.returns(false);
fakeApi.annotation.update.resolves(annotation);
return svc.save(annotation).then(() => {
......@@ -462,7 +634,7 @@ describe('annotationService', () => {
context('error on save', () => {
it('does not remove the annotation draft', () => {
fakeApi.annotation.update.rejects();
fakeIsNew.returns(false);
fakeMetadata.isNew.returns(false);
return svc.save(fixtures.defaultAnnotation()).catch(() => {
assert.notCalled(fakeStore.removeDraft);
......@@ -471,7 +643,7 @@ describe('annotationService', () => {
it('does not add the annotation to the store', () => {
fakeApi.annotation.update.rejects();
fakeIsNew.returns(false);
fakeMetadata.isNew.returns(false);
return svc.save(fixtures.defaultAnnotation()).catch(() => {
assert.notCalled(fakeStore.addAnnotations);
......
......@@ -20,6 +20,7 @@ const fixtures = immutable({
});
describe('rootThread', function() {
let fakeAnnotationsService;
let fakeStore;
let fakeBuildThread;
let fakeSearchFilter;
......@@ -31,6 +32,9 @@ describe('rootThread', function() {
let rootThread;
beforeEach(function() {
fakeAnnotationsService = {
create: sinon.stub(),
};
fakeStore = {
state: {
annotations: {
......@@ -83,6 +87,7 @@ describe('rootThread', function() {
angular
.module('app', [])
.value('annotationsService', fakeAnnotationsService)
.value('store', fakeStore)
.value('searchFilter', fakeSearchFilter)
.value('settings', fakeSettings)
......@@ -351,10 +356,10 @@ describe('rootThread', function() {
context('when annotation events occur', function() {
const annot = annotationFixtures.defaultAnnotation();
it('creates a new annotation in the store when BEFORE_ANNOTATION_CREATED event occurs', function() {
it('creates a new annotation when BEFORE_ANNOTATION_CREATED event occurs', function() {
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annot);
assert.notCalled(fakeStore.removeAnnotations);
assert.calledWith(fakeStore.createAnnotation, sinon.match(annot));
assert.calledWith(fakeAnnotationsService.create, sinon.match(annot));
});
[
......
......@@ -5,15 +5,10 @@
import { createSelector } from 'reselect';
import uiConstants from '../../ui-constants';
import * as metadata from '../../util/annotation-metadata';
import * as arrayUtil from '../../util/array';
import { defaultPermissions, privatePermissions } from '../../util/permissions';
import * as util from '../util';
import drafts from './drafts';
import selection from './selection';
/**
* Return a copy of `current` with all matching annotations in `annotations`
* removed.
......@@ -241,6 +236,8 @@ function addAnnotations(annotations) {
});
// If we're not in the sidebar, we're done here.
// FIXME Split the annotation-adding from the anchoring code; possibly
// move into service
if (!getState().viewer.isSidebar) {
return;
}
......@@ -320,71 +317,6 @@ function hideAnnotation(id) {
};
}
/**
* Create a new annotation (as-yet unpersisted)
*
* This method has several responsibilities:
* 1. Set some default data attributes on the annotation
* 2. Remove any existing, empty drafts
* 3. Add the annotation to the current collection of annotations
* 4. Change focused tab to the applicable one for the new annotation's meta-type
* 5. Expand all of the new annotation's parents
*
*/
function createAnnotation(ann, now = new Date()) {
return (dispatch, getState) => {
/**
* Extend the new, unsaved annotation object with defaults for some
* required data fields and default permissions.
*
* Note: the `created` and `updated` values will be ignored and superseded
* by the service when the annotation is persisted, but they are used
* app-side for annotation card sorting until then.
*/
const groupid = getState().groups.focusedGroupId;
const userid = getState().session.userid;
ann = Object.assign(
{
created: now.toISOString(),
group: groupid,
permissions: defaultPermissions(
userid,
groupid,
getState().defaults.annotationPrivacy
),
tags: [],
text: '',
updated: now.toISOString(),
user: userid,
user_info: getState().session.user_info,
},
ann
);
// Highlights are peculiar in that they always have private permissions
if (metadata.isHighlight(ann)) {
ann.permissions = privatePermissions(userid);
}
// When a new annotation is created, remove any existing annotations
// that are empty.
dispatch(drafts.actions.deleteNewAndEmptyDrafts([ann]));
dispatch(addAnnotations([ann]));
// If the annotation is of type note or annotation, make sure
// the appropriate tab is selected. If it is of type reply, user
// stays in the selected tab.
if (metadata.isPageNote(ann)) {
dispatch(selection.actions.selectTab(uiConstants.TAB_NOTES));
} else if (metadata.isAnnotation(ann)) {
dispatch(selection.actions.selectTab(uiConstants.TAB_ANNOTATIONS));
}
(ann.references || []).forEach(parent => {
// Expand any parents of this annotation.
dispatch(selection.actions.setCollapsed(parent, false));
});
};
}
/**
* Update the local hidden state of an annotation.
*
......@@ -491,7 +423,6 @@ export default {
actions: {
addAnnotations,
clearAnnotations,
createAnnotation,
hideAnnotation,
removeAnnotations,
updateAnchorStatus,
......
import * as fixtures from '../../../test/annotation-fixtures';
import uiConstants from '../../../ui-constants';
import * as metadata from '../../../util/annotation-metadata';
import createStore from '../../create-store';
import annotations from '../annotations';
import { $imports } from '../annotations';
import defaults from '../defaults';
import drafts from '../drafts';
import groups from '../groups';
import selection from '../selection';
import session from '../session';
import viewer from '../viewer';
const { actions, selectors } = annotations;
function createTestStore() {
return createStore(
[annotations, selection, defaults, drafts, groups, session, viewer],
[{}]
);
return createStore([annotations, viewer], [{}]);
}
// Tests for most of the functionality in reducers/annotations.js are currently
// in the tests for the whole Redux store
describe('sidebar/store/modules/annotations', function() {
let fakeDefaultPermissions;
let fakePrivatePermissions;
beforeEach(() => {
fakeDefaultPermissions = sinon.stub();
fakePrivatePermissions = sinon.stub();
$imports.$mock({
'../../util/permissions': {
defaultPermissions: fakeDefaultPermissions,
privatePermissions: fakePrivatePermissions,
},
});
});
afterEach(() => {
$imports.$restore();
});
describe('#addAnnotations()', function() {
const ANCHOR_TIME_LIMIT = 1000;
let clock;
......@@ -72,36 +44,6 @@ describe('sidebar/store/modules/annotations', function() {
]);
});
it('does not change `selectedTab` state if annotations are already loaded', function() {
const annot = fixtures.defaultAnnotation();
store.addAnnotations([annot]);
const page = fixtures.oldPageNote();
store.addAnnotations([page]);
assert.equal(
store.getState().selection.selectedTab,
uiConstants.TAB_ANNOTATIONS
);
});
it('sets `selectedTab` to "note" if only page notes are present', function() {
const page = fixtures.oldPageNote();
store.addAnnotations([page]);
assert.equal(
store.getState().selection.selectedTab,
uiConstants.TAB_NOTES
);
});
it('leaves `selectedTab` as "annotation" if annotations and/or page notes are present', function() {
const page = fixtures.oldPageNote();
const annot = fixtures.defaultAnnotation();
store.addAnnotations([annot, page]);
assert.equal(
store.getState().selection.selectedTab,
uiConstants.TAB_ANNOTATIONS
);
});
it('assigns a local tag to annotations', function() {
const annotA = Object.assign(fixtures.defaultAnnotation(), { id: 'a1' });
const annotB = Object.assign(fixtures.defaultAnnotation(), { id: 'a2' });
......@@ -444,187 +386,4 @@ describe('sidebar/store/modules/annotations', function() {
});
});
});
describe('#createAnnotation', function() {
let clock;
let now;
let store;
beforeEach(() => {
// Stop the clock to keep the current date from advancing
clock = sinon.useFakeTimers();
now = new Date();
store = createTestStore();
});
afterEach(() => {
clock.restore();
});
it('should create an annotation', function() {
const ann = fixtures.oldAnnotation();
store.dispatch(actions.createAnnotation(ann));
assert.equal(
selectors.findAnnotationByID(store.getState(), ann.id).id,
ann.id
);
});
it('should set basic default properties on a new/empty annotation', () => {
store.dispatch(actions.createAnnotation({ id: 'myID' }, now));
const createdAnnotation = selectors.findAnnotationByID(
store.getState(),
'myID'
);
assert.include(createdAnnotation, {
created: now.toISOString(),
updated: now.toISOString(),
text: '',
});
assert.isArray(createdAnnotation.tags);
});
it('should set user properties on a new/empty annotation', () => {
store.dispatch(actions.createAnnotation({ id: 'myID' }, now));
const createdAnnotation = selectors.findAnnotationByID(
store.getState(),
'myID'
);
assert.equal(createdAnnotation.user, store.getState().session.userid);
assert.equal(
createdAnnotation.user_info,
store.getState().session.user_info
);
});
it('should set default permissions on a new annotation', () => {
fakeDefaultPermissions.returns('somePermissions');
store.dispatch(actions.createAnnotation({ id: 'myID' }, now));
const createdAnnotation = selectors.findAnnotationByID(
store.getState(),
'myID'
);
assert.equal(createdAnnotation.permissions, 'somePermissions');
});
it('should always assign private permissions to highlights', () => {
fakePrivatePermissions.returns('private');
store.dispatch(
actions.createAnnotation({ id: 'myID', $highlight: true }, now)
);
const createdAnnotation = selectors.findAnnotationByID(
store.getState(),
'myID'
);
assert.equal(createdAnnotation.permissions, 'private');
});
it('should set group to currently-focused group if not set on annotation', () => {
store.dispatch(actions.createAnnotation({ id: 'myID' }, now));
const createdAnnotation = selectors.findAnnotationByID(
store.getState(),
'myID'
);
assert.equal(
createdAnnotation.group,
store.getState().groups.focusedGroupId
);
});
it('should set not overwrite properties if present', () => {
store.dispatch(
actions.createAnnotation(
{
id: 'myID',
created: 'when',
updated: 'then',
text: 'my annotation',
tags: ['foo', 'bar'],
group: 'fzzy',
permissions: ['whatever'],
user: 'acct:foo@bar.com',
user_info: {
display_name: 'Herbivore Fandango',
},
},
now
)
);
const createdAnnotation = selectors.findAnnotationByID(
store.getState(),
'myID'
);
assert.include(createdAnnotation, {
created: 'when',
updated: 'then',
text: 'my annotation',
group: 'fzzy',
user: 'acct:foo@bar.com',
});
assert.include(createdAnnotation.tags, 'foo', 'bar');
assert.include(createdAnnotation.permissions, 'whatever');
assert.equal(
createdAnnotation.user_info.display_name,
'Herbivore Fandango'
);
});
it('should change tab focus to TAB_ANNOTATIONS when a new annotation is created', function() {
store.dispatch(actions.createAnnotation(fixtures.oldAnnotation()));
assert.equal(
store.getState().selection.selectedTab,
uiConstants.TAB_ANNOTATIONS
);
});
it('should change tab focus to TAB_NOTES when a new note annotation is created', function() {
store.dispatch(actions.createAnnotation(fixtures.oldPageNote()));
assert.equal(
store.getState().selection.selectedTab,
uiConstants.TAB_NOTES
);
});
it('should expand parent of created annotation', function() {
const store = createTestStore();
store.dispatch(
actions.addAnnotations([
{
id: 'annotation_id',
$highlight: undefined,
target: [{ source: 'source', selector: [] }],
references: [],
text: 'This is my annotation',
tags: ['tag_1', 'tag_2'],
},
])
);
// Collapse the parent.
store.dispatch(selection.actions.setCollapsed('annotation_id', true));
// Creating a new child annotation should expand its parent.
store.dispatch(
actions.createAnnotation({
highlight: undefined,
target: [{ source: 'http://example.org' }],
references: ['annotation_id'],
text: '',
tags: [],
})
);
assert.isTrue(store.getState().selection.expanded.annotation_id);
});
});
});
......@@ -57,6 +57,7 @@ describe('annotation threading', function() {
.service('store', storeFactory)
.service('rootThread', rootThreadFactory)
.service('searchFilter', searchFilterFactory)
.service('annotationsService', () => {})
.service('viewFilter', viewFilterFactory)
.value('features', fakeFeatures)
.value('settings', {})
......
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