Unverified Commit 4248f4bf authored by Kyle Keating's avatar Kyle Keating Committed by GitHub

Merge pull request #1255 from hypothesis/refactor-annotation-events

Refactor annotation events
parents ad2d877f 69c87b9f
...@@ -5,7 +5,6 @@ const events = require('../events'); ...@@ -5,7 +5,6 @@ const events = require('../events');
const memoize = require('../util/memoize'); const memoize = require('../util/memoize');
const metadata = require('../util/annotation-metadata'); const metadata = require('../util/annotation-metadata');
const tabs = require('../util/tabs'); const tabs = require('../util/tabs');
const uiConstants = require('../ui-constants');
function truthyKeys(map) { function truthyKeys(map) {
return Object.keys(map).filter(function(k) { return Object.keys(map).filter(function(k) {
...@@ -82,18 +81,6 @@ function RootThread($rootScope, store, searchFilter, viewFilter) { ...@@ -82,18 +81,6 @@ function RootThread($rootScope, store, searchFilter, viewFilter) {
}); });
} }
function deleteNewAndEmptyAnnotations() {
store
.getState()
.annotations.filter(function(ann) {
return metadata.isNew(ann) && !store.getDraftIfNotEmpty(ann);
})
.forEach(function(ann) {
store.removeDraft(ann);
$rootScope.$broadcast(events.ANNOTATION_DELETED, ann);
});
}
// Listen for annotations being created or loaded // Listen for annotations being created or loaded
// and show them in the UI. // and show them in the UI.
// //
...@@ -111,33 +98,14 @@ function RootThread($rootScope, store, searchFilter, viewFilter) { ...@@ -111,33 +98,14 @@ function RootThread($rootScope, store, searchFilter, viewFilter) {
}); });
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, function(event, ann) { $rootScope.$on(events.BEFORE_ANNOTATION_CREATED, function(event, ann) {
// When a new annotation is created, remove any existing annotations store.createAnnotation(ann);
// that are empty.
deleteNewAndEmptyAnnotations();
store.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)) {
store.selectTab(uiConstants.TAB_NOTES);
} else if (metadata.isAnnotation(ann)) {
store.selectTab(uiConstants.TAB_ANNOTATIONS);
}
(ann.references || []).forEach(function(parent) {
store.setCollapsed(parent, false);
});
}); });
// Remove any annotations that are deleted or unloaded // Remove any annotations that are deleted or unloaded
$rootScope.$on(events.ANNOTATION_DELETED, function(event, annotation) { $rootScope.$on(events.ANNOTATION_DELETED, function(event, annotation) {
store.removeAnnotations([annotation]); store.removeAnnotations([annotation]);
if (annotation.id) {
store.removeSelectedAnnotation(annotation.id);
}
}); });
$rootScope.$on(events.ANNOTATIONS_UNLOADED, function(event, annotations) { $rootScope.$on(events.ANNOTATIONS_UNLOADED, function(event, annotations) {
store.removeAnnotations(annotations); store.removeAnnotations(annotations);
}); });
......
...@@ -39,6 +39,7 @@ describe('rootThread', function() { ...@@ -39,6 +39,7 @@ describe('rootThread', function() {
state: { state: {
annotations: [], annotations: [],
expanded: {}, expanded: {},
drafts: [],
filterQuery: null, filterQuery: null,
focusedAnnotationMap: null, focusedAnnotationMap: null,
forceVisible: {}, forceVisible: {},
...@@ -63,6 +64,7 @@ describe('rootThread', function() { ...@@ -63,6 +64,7 @@ describe('rootThread', function() {
selectTab: sinon.stub(), selectTab: sinon.stub(),
getDraftIfNotEmpty: sinon.stub().returns(null), getDraftIfNotEmpty: sinon.stub().returns(null),
removeDraft: sinon.stub(), removeDraft: sinon.stub(),
createAnnotation: sinon.stub(),
}; };
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread); fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
...@@ -341,6 +343,12 @@ describe('rootThread', function() { ...@@ -341,6 +343,12 @@ describe('rootThread', function() {
context('when annotation events occur', function() { context('when annotation events occur', function() {
const annot = annotationFixtures.defaultAnnotation(); const annot = annotationFixtures.defaultAnnotation();
it('creates a new annotation in the store 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));
});
unroll( unroll(
'adds or updates annotations when #event event occurs', 'adds or updates annotations when #event event occurs',
function(testCase) { function(testCase) {
...@@ -350,38 +358,20 @@ describe('rootThread', function() { ...@@ -350,38 +358,20 @@ describe('rootThread', function() {
assert.calledWith(fakeStore.addAnnotations, sinon.match(annotations)); assert.calledWith(fakeStore.addAnnotations, sinon.match(annotations));
}, },
[ [
{ event: events.BEFORE_ANNOTATION_CREATED, annotations: annot },
{ event: events.ANNOTATION_CREATED, annotations: annot }, { event: events.ANNOTATION_CREATED, annotations: annot },
{ event: events.ANNOTATION_UPDATED, annotations: annot }, { event: events.ANNOTATION_UPDATED, annotations: annot },
{ event: events.ANNOTATIONS_LOADED, annotations: [annot] }, { event: events.ANNOTATIONS_LOADED, annotations: [annot] },
] ]
); );
it('expands the parents of new annotations', function() { it('removes annotations when ANNOTATION_DELETED event occurs', function() {
const reply = annotationFixtures.oldReply(); $rootScope.$broadcast(events.ANNOTATION_DELETED, annot);
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, reply); assert.calledWith(fakeStore.removeAnnotations, sinon.match([annot]));
assert.calledWith(fakeStore.setCollapsed, reply.references[0], false);
}); });
unroll( it('removes annotations when ANNOTATIONS_UNLOADED event occurs', function() {
'removes annotations when #event event occurs', $rootScope.$broadcast(events.ANNOTATIONS_UNLOADED, annot);
function(testCase) { assert.calledWith(fakeStore.removeAnnotations, sinon.match(annot));
$rootScope.$broadcast(testCase.event, testCase.annotations);
const annotations = [].concat(testCase.annotations);
assert.calledWith(
fakeStore.removeAnnotations,
sinon.match(annotations)
);
},
[
{ event: events.ANNOTATION_DELETED, annotations: annot },
{ event: events.ANNOTATIONS_UNLOADED, annotations: [annot] },
]
);
it('deselects deleted annotations', function() {
$rootScope.$broadcast(events.ANNOTATION_DELETED, annot);
assert.calledWith(fakeStore.removeSelectedAnnotation, annot.id);
}); });
describe('when a new annotation is created', function() { describe('when a new annotation is created', function() {
...@@ -395,24 +385,6 @@ describe('rootThread', function() { ...@@ -395,24 +385,6 @@ describe('rootThread', function() {
fakeStore.state.annotations.push(existingNewAnnot); fakeStore.state.annotations.push(existingNewAnnot);
}); });
it('removes drafts for new and empty annotations', function() {
fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
assert.calledWith(fakeStore.removeDraft, existingNewAnnot);
});
it('deletes new and empty annotations', function() {
fakeStore.getDraftIfNotEmpty.returns(null);
const annotation = annotationFixtures.newEmptyAnnotation();
$rootScope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annotation);
assert.calledWithMatch(onDelete, sinon.match.any, existingNewAnnot);
});
it('does not remove annotations that have non-empty drafts', function() { it('does not remove annotations that have non-empty drafts', function() {
fakeStore.getDraftIfNotEmpty.returns(fixtures.nonEmptyDraft); fakeStore.getDraftIfNotEmpty.returns(fixtures.nonEmptyDraft);
......
...@@ -12,6 +12,7 @@ const metadata = require('../../util/annotation-metadata'); ...@@ -12,6 +12,7 @@ const metadata = require('../../util/annotation-metadata');
const uiConstants = require('../../ui-constants'); const uiConstants = require('../../ui-constants');
const selection = require('./selection'); const selection = require('./selection');
const drafts = require('./drafts');
const util = require('../util'); const util = require('../util');
/** /**
...@@ -333,6 +334,36 @@ function hideAnnotation(id) { ...@@ -333,6 +334,36 @@ function hideAnnotation(id) {
}; };
} }
/**
* Create a new annotation
*
* The method does 4 tasks:
* 1. Removes any existing empty drafts.
* 2. Creates a new annotation.
* 3. Changes the focused tab to match that of the newly created annotation.
* 4. Expands the collapsed state of all new annotation's parents.
*/
function createAnnotation(ann) {
return dispatch => {
// 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. * Update the local hidden state of an annotation.
* *
...@@ -426,13 +457,14 @@ module.exports = { ...@@ -426,13 +457,14 @@ module.exports = {
init: init, init: init,
update: update, update: update,
actions: { actions: {
addAnnotations: addAnnotations, addAnnotations,
clearAnnotations: clearAnnotations, clearAnnotations,
removeAnnotations: removeAnnotations, createAnnotation,
updateAnchorStatus: updateAnchorStatus, hideAnnotation,
updateFlagStatus: updateFlagStatus, removeAnnotations,
hideAnnotation: hideAnnotation, updateAnchorStatus,
unhideAnnotation: unhideAnnotation, updateFlagStatus,
unhideAnnotation,
}, },
selectors: { selectors: {
......
'use strict'; 'use strict';
const metadata = require('../../util/annotation-metadata');
const util = require('../util'); const util = require('../util');
/** /**
...@@ -96,14 +97,41 @@ function createDraft(annotation, changes) { ...@@ -96,14 +97,41 @@ function createDraft(annotation, changes) {
}; };
} }
/** Remove all drafts. */ /**
* Remove any drafts that are empty.
*
* An empty draft has no text and no reference tags.
*/
function deleteNewAndEmptyDrafts() {
const annotations = require('./annotations');
return (dispatch, getState) => {
const newDrafts = getState().drafts.filter(draft => {
return (
metadata.isNew(draft.annotation) &&
!getDraftIfNotEmpty(getState(), draft.annotation)
);
});
const removedAnnotations = newDrafts.map(draft => {
dispatch(removeDraft(draft.annotation));
return draft.annotation;
});
dispatch(annotations.actions.removeAnnotations(removedAnnotations));
};
}
/**
* Remove all drafts.
* */
function discardAllDrafts() { function discardAllDrafts() {
return { return {
type: actions.DISCARD_ALL_DRAFTS, type: actions.DISCARD_ALL_DRAFTS,
}; };
} }
/** Remove the draft version of an annotation. */ /**
* Remove the draft version of an annotation.
*/
function removeDraft(annotation) { function removeDraft(annotation) {
return { return {
type: actions.REMOVE_DRAFT, type: actions.REMOVE_DRAFT,
...@@ -169,6 +197,7 @@ module.exports = { ...@@ -169,6 +197,7 @@ module.exports = {
update, update,
actions: { actions: {
createDraft, createDraft,
deleteNewAndEmptyDrafts,
discardAllDrafts, discardAllDrafts,
removeDraft, removeDraft,
}, },
......
...@@ -176,6 +176,18 @@ const update = { ...@@ -176,6 +176,18 @@ const update = {
return {}; return {};
}, },
REMOVE_ANNOTATIONS: function(state, action) {
const selection = Object.assign({}, state.selectedAnnotationMap);
action.annotations.forEach(annotation => {
if (annotation.id) {
delete selection[annotation.id];
}
});
return {
selectedAnnotationMap: freeze(selection),
};
},
SET_FILTER_QUERY: function(state, action) { SET_FILTER_QUERY: function(state, action) {
return { return {
filterQuery: action.query, filterQuery: action.query,
...@@ -317,20 +329,6 @@ function hasSelectedAnnotations(state) { ...@@ -317,20 +329,6 @@ function hasSelectedAnnotations(state) {
return !!state.selectedAnnotationMap; return !!state.selectedAnnotationMap;
} }
/** De-select an annotation. */
function removeSelectedAnnotation(id) {
// FIXME: This should be converted to a plain action and accessing the state
// should happen in the update() function
return function(dispatch, getState) {
const selection = Object.assign({}, getState().selectedAnnotationMap);
if (!selection || !id) {
return;
}
delete selection[id];
dispatch(select(selection));
};
}
/** De-select all annotations. */ /** De-select all annotations. */
function clearSelectedAnnotations() { function clearSelectedAnnotations() {
return { type: actions.CLEAR_SELECTED_ANNOTATIONS }; return { type: actions.CLEAR_SELECTED_ANNOTATIONS };
...@@ -365,7 +363,6 @@ module.exports = { ...@@ -365,7 +363,6 @@ module.exports = {
clearSelection: clearSelection, clearSelection: clearSelection,
focusAnnotations: focusAnnotations, focusAnnotations: focusAnnotations,
highlightAnnotations: highlightAnnotations, highlightAnnotations: highlightAnnotations,
removeSelectedAnnotation: removeSelectedAnnotation,
selectAnnotations: selectAnnotations, selectAnnotations: selectAnnotations,
selectTab: selectTab, selectTab: selectTab,
setCollapsed: setCollapsed, setCollapsed: setCollapsed,
......
'use strict'; 'use strict';
const redux = require('redux');
// `.default` is needed because 'redux-thunk' is built as an ES2015 module
const thunk = require('redux-thunk').default;
const annotations = require('../annotations'); const annotations = require('../annotations');
const createStoreFromModules = require('../../create-store');
const drafts = require('../drafts');
const fixtures = require('../../../test/annotation-fixtures'); const fixtures = require('../../../test/annotation-fixtures');
const util = require('../../util'); const selection = require('../selection');
const uiConstants = require('../../../ui-constants');
const unroll = require('../../../../shared/test/util').unroll; const unroll = require('../../../../shared/test/util').unroll;
const { actions, selectors } = annotations; const { actions, selectors } = annotations;
/** /**
* Create a Redux store which only handles annotation actions. * Create a Redux store which handles annotation, selection and draft actions.
*/ */
function createStore() { function createStore() {
// Thunk middleware is needed for the ADD_ANNOTATIONS action. return createStoreFromModules([annotations, selection, drafts], [{}, {}, {}]);
const enhancer = redux.applyMiddleware(thunk);
const reducer = util.createReducer(annotations.update);
return redux.createStore(reducer, annotations.init(), enhancer);
} }
// Tests for most of the functionality in reducers/annotations.js are currently // Tests for most of the functionality in reducers/annotations.js are currently
...@@ -144,6 +140,16 @@ describe('annotations reducer', function() { ...@@ -144,6 +140,16 @@ describe('annotations reducer', function() {
}); });
}); });
describe('#removeAnnotations', function() {
it('removes the annotation', function() {
const store = createStore();
const ann = fixtures.defaultAnnotation();
store.dispatch(actions.addAnnotations([ann]));
store.dispatch(actions.removeAnnotations([ann]));
assert.equal(store.getState().annotations.length, 0);
});
});
describe('#updateFlagStatus', function() { describe('#updateFlagStatus', function() {
unroll( unroll(
'updates the flagged status of an annotation', 'updates the flagged status of an annotation',
...@@ -206,4 +212,57 @@ describe('annotations reducer', function() { ...@@ -206,4 +212,57 @@ describe('annotations reducer', function() {
] ]
); );
}); });
describe('#createAnnotation', function() {
it('should create an annotation', function() {
const store = createStore();
const ann = fixtures.oldAnnotation();
store.dispatch(actions.createAnnotation(ann));
assert.equal(
selectors.findAnnotationByID(store.getState(), ann.id).id,
ann.id
);
});
it('should change tab focus to TAB_ANNOTATIONS when a new annotation is created', function() {
const store = createStore();
store.dispatch(actions.createAnnotation(fixtures.oldAnnotation()));
assert.equal(store.getState().selectedTab, uiConstants.TAB_ANNOTATIONS);
});
it('should change tab focus to TAB_NOTES when a new note annotation is created', function() {
const store = createStore();
store.dispatch(actions.createAnnotation(fixtures.oldPageNote()));
assert.equal(store.getState().selectedTab, uiConstants.TAB_NOTES);
});
it('should expand parent of created annotation', function() {
const store = createStore();
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().expanded.annotation_id);
});
});
}); });
...@@ -28,7 +28,7 @@ const fixtures = immutable({ ...@@ -28,7 +28,7 @@ const fixtures = immutable({
}, },
}); });
describe('Drafts Store', () => { describe('store/modules/drafts', () => {
let store; let store;
beforeEach(() => { beforeEach(() => {
...@@ -225,4 +225,46 @@ describe('Drafts Store', () => { ...@@ -225,4 +225,46 @@ describe('Drafts Store', () => {
assert.deepEqual(store.unsavedAnnotations(), []); assert.deepEqual(store.unsavedAnnotations(), []);
}); });
}); });
describe('#deleteNewAndEmptyDrafts', () => {
[
{
key: 'should remove new and empty drafts',
annotation: {
id: undefined,
$tag: 'my_annotation_tag',
},
draft: fixtures.emptyDraft,
shouldRemove: true,
},
{
key: 'should not remove drafts with an id',
annotation: {
id: 'my_id',
$tag: 'my_annotation_tag',
},
draft: fixtures.emptyDraft,
shouldRemove: false,
},
{
key: 'should not remove drafts with text',
annotation: {
id: undefined,
$tag: 'my_annotation_tag',
},
draft: fixtures.draftWithText,
shouldRemove: false,
},
].forEach(test => {
it(test.key, () => {
store.createDraft(test.annotation, test.draft);
store.deleteNewAndEmptyDrafts([test.annotation]);
if (test.shouldRemove) {
assert.isNotOk(store.getDraft(test.annotation));
} else {
assert.isOk(store.getDraft(test.annotation));
}
});
});
});
}); });
'use strict'; 'use strict';
const annotations = require('../annotations');
const createStore = require('../../create-store'); const createStore = require('../../create-store');
const selection = require('../selection'); const selection = require('../selection');
const uiConstants = require('../../../ui-constants'); const uiConstants = require('../../../ui-constants');
describe('selection', () => { describe('store/modules/selection', () => {
let store; let store;
let fakeSettings = {}; let fakeSettings = [{}, {}];
beforeEach(() => { beforeEach(() => {
store = createStore([selection], [fakeSettings]); store = createStore([annotations, selection], fakeSettings);
}); });
describe('getFirstSelectedAnnotationId', function() { describe('getFirstSelectedAnnotationId', function() {
...@@ -145,21 +146,15 @@ describe('selection', () => { ...@@ -145,21 +146,15 @@ describe('selection', () => {
}); });
}); });
describe('removeSelectedAnnotation()', function() { describe('#REMOVE_ANNOTATIONS', function() {
it('removes an annotation from the selectedAnnotationMap', function() { it('removing an annotation should also remove it from selectedAnnotationMap', function() {
store.selectAnnotations([1, 2, 3]); store.selectAnnotations([1, 2, 3]);
store.removeSelectedAnnotation(2); store.removeAnnotations([{ id: 2 }]);
assert.deepEqual(store.getState().selectedAnnotationMap, { assert.deepEqual(store.getState().selectedAnnotationMap, {
1: true, 1: true,
3: true, 3: true,
}); });
}); });
it('nulls the map if no annotations are selected', function() {
store.selectAnnotations([1]);
store.removeSelectedAnnotation(1);
assert.isNull(store.getState().selectedAnnotationMap);
});
}); });
describe('clearSelectedAnnotations()', function() { describe('clearSelectedAnnotations()', function() {
......
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