Commit 936650cc authored by Robert Knight's avatar Robert Knight

Reset expanded threads when filter query changes.

As noted in https://github.com/hypothesis/h/pull/3284#issuecomment-219031557
threads which have been explicitly collapsed need to have their
expansion state reset when the filter query changes.

This commit resolves this by moving the search query and sort UI state
into the annotationUI Redux store and handles the state update
in a SET_FILTER_QUERY action.

This means that the rootThread module no longer maintains any primary
state of its own, just state derived from the current annotationUI
state.
parent 6462e0e0
...@@ -49,6 +49,10 @@ function initialState(settings) { ...@@ -49,6 +49,10 @@ function initialState(settings) {
// Set of IDs of annotations that have been explicitly shown // Set of IDs of annotations that have been explicitly shown
// by the user even if they do not match the current search filter // by the user even if they do not match the current search filter
forceVisible: {}, forceVisible: {},
filterQuery: null,
sortMode: 'Location',
}); });
} }
...@@ -61,6 +65,8 @@ var types = { ...@@ -61,6 +65,8 @@ var types = {
ADD_ANNOTATIONS: 'ADD_ANNOTATIONS', ADD_ANNOTATIONS: 'ADD_ANNOTATIONS',
REMOVE_ANNOTATIONS: 'REMOVE_ANNOTATIONS', REMOVE_ANNOTATIONS: 'REMOVE_ANNOTATIONS',
CLEAR_ANNOTATIONS: 'CLEAR_ANNOTATIONS', CLEAR_ANNOTATIONS: 'CLEAR_ANNOTATIONS',
SET_FILTER_QUERY: 'SET_FILTER_QUERY',
SORT_BY: 'SORT_BY',
}; };
function excludeAnnotations(current, annotations) { function excludeAnnotations(current, annotations) {
...@@ -104,6 +110,14 @@ function reducer(state, action) { ...@@ -104,6 +110,14 @@ function reducer(state, action) {
return Object.assign({}, state, {forceVisible: action.forceVisible}); return Object.assign({}, state, {forceVisible: action.forceVisible});
case types.SET_EXPANDED: case types.SET_EXPANDED:
return Object.assign({}, state, {expanded: action.expanded}); return Object.assign({}, state, {expanded: action.expanded});
case types.SET_FILTER_QUERY:
return Object.assign({}, state, {
filterQuery: action.query,
forceVisible: {},
expanded: {},
});
case types.SORT_BY:
return Object.assign({}, state, {sortMode: action.mode});
default: default:
return state; return state;
} }
...@@ -204,17 +218,6 @@ module.exports = function (settings) { ...@@ -204,17 +218,6 @@ module.exports = function (settings) {
}); });
}, },
/**
* Clear the set of annotations which have been explicitly shown by
* setForceVisible()
*/
clearForceVisible: function () {
store.dispatch({
type: types.SET_FORCE_VISIBLE,
forceVisible: {},
});
},
/** /**
* Returns true if the annotation with the given `id` is selected. * Returns true if the annotation with the given `id` is selected.
*/ */
...@@ -289,5 +292,21 @@ module.exports = function (settings) { ...@@ -289,5 +292,21 @@ module.exports = function (settings) {
clearAnnotations: function () { clearAnnotations: function () {
store.dispatch({type: types.CLEAR_ANNOTATIONS}); store.dispatch({type: types.CLEAR_ANNOTATIONS});
}, },
/** Set the query used to filter displayed annotations. */
setFilterQuery: function (query) {
store.dispatch({
type: types.SET_FILTER_QUERY,
query: query,
});
},
/** Sets the sort mode for the annotation list. */
sortBy: function (mode) {
store.dispatch({
type: types.SORT_BY,
mode: mode,
});
},
}; };
}; };
...@@ -299,8 +299,7 @@ function buildThread(annotations, opts) { ...@@ -299,8 +299,7 @@ function buildThread(annotations, opts) {
// Expand any threads which: // Expand any threads which:
// 1) Have been explicitly expanded OR // 1) Have been explicitly expanded OR
// 2) Have children matching the filter OR // 2) Have children matching the filter
// 3) Contain children which have been selected
thread = mapThread(thread, function (thread) { thread = mapThread(thread, function (thread) {
var id = thread.id; var id = thread.id;
......
...@@ -27,11 +27,12 @@ var sortFns = { ...@@ -27,11 +27,12 @@ var sortFns = {
/** /**
* Root conversation thread for the sidebar and stream. * Root conversation thread for the sidebar and stream.
* *
* Listens for annotations being loaded, created and unloaded and * This performs two functions:
* builds a conversation thread.
* *
* The thread is sorted and filtered according to * 1. It listens for annotations being loaded, created and unloaded and
* current sort and filter settings. * dispatches annotationUI.{addAnnotations|removeAnnotations} actions.
* 2. Listens for changes in the UI state and rebuilds the root conversation
* thread.
* *
* The root thread is then displayed by viewer.html * The root thread is then displayed by viewer.html
*/ */
...@@ -39,24 +40,23 @@ var sortFns = { ...@@ -39,24 +40,23 @@ var sortFns = {
module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
var thread; var thread;
var sortFn = sortFns.Location;
var searchQuery;
/** /**
* Rebuild the root conversation thread. This should be called * Rebuild the root conversation thread. This should be called
* whenever the set of annotations to render or the sort/search/filter * whenever the set of annotations to render or the sort/search/filter
* settings change. * settings change.
*/ */
function rebuildRootThread() { function rebuildRootThread() {
var sortFn = sortFns[annotationUI.getState().sortMode];
var filters; var filters;
if (searchQuery) { var filterQuery = annotationUI.getState().filterQuery;
// TODO - Only regenerate the filter function when the search
// query changes if (filterQuery) {
filters = searchFilter.generateFacetedFilter(searchQuery); filters = searchFilter.generateFacetedFilter(filterQuery);
} }
var filterFn; var filterFn;
if (searchQuery) { if (filterQuery) {
filterFn = function (annot) { filterFn = function (annot) {
return viewFilter.filter([annot], filters).length > 0; return viewFilter.filter([annot], filters).length > 0;
}; };
...@@ -77,7 +77,10 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { ...@@ -77,7 +77,10 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
annotationUI.subscribe(rebuildRootThread); annotationUI.subscribe(rebuildRootThread);
// 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.
//
// Note: These events could all be converted into actions that are handled by
// the Redux store in annotationUI.
var loadEvents = [events.BEFORE_ANNOTATION_CREATED, var loadEvents = [events.BEFORE_ANNOTATION_CREATED,
events.ANNOTATION_CREATED, events.ANNOTATION_CREATED,
events.ANNOTATIONS_LOADED]; events.ANNOTATIONS_LOADED];
...@@ -123,27 +126,5 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { ...@@ -123,27 +126,5 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
thread: function () { thread: function () {
return thread; return thread;
}, },
/**
* Set the sort order for annotations.
* @param {'Location'|'Newest'|'Oldest'} mode
*/
sortBy: function (mode) {
if (!sortFns[mode]) {
throw new Error('Unknown sort mode: ' + mode);
}
sortFn = sortFns[mode];
rebuildRootThread();
},
/**
* Set the query to use when filtering annotations.
* @param {string} query - The filter query
*/
setSearchQuery: function (query) {
searchQuery = query;
annotationUI.clearForceVisible();
rebuildRootThread();
},
}; };
}; };
...@@ -47,7 +47,7 @@ module.exports = class StreamController ...@@ -47,7 +47,7 @@ module.exports = class StreamController
fetch(20) fetch(20)
$scope.$watch('sort.name', (name) -> $scope.$watch('sort.name', (name) ->
rootThread.sortBy(name) annotationUI.sortBy(name)
) )
$scope.setCollapsed = (id, collapsed) -> $scope.setCollapsed = (id, collapsed) ->
......
...@@ -78,14 +78,6 @@ describe('annotationUI', function () { ...@@ -78,14 +78,6 @@ describe('annotationUI', function () {
}); });
}); });
describe('#clearForceVisible()', function () {
it('clears the forceVisible set', function () {
annotationUI.setForceVisible('id1', true);
annotationUI.clearForceVisible();
assert.deepEqual(annotationUI.getState().forceVisible, {});
});
});
describe('#setCollapsed()', function () { describe('#setCollapsed()', function () {
it('sets the expanded state of the annotation', function () { it('sets the expanded state of the annotation', function () {
annotationUI.setCollapsed('parent_id', false); annotationUI.setCollapsed('parent_id', false);
...@@ -211,4 +203,19 @@ describe('annotationUI', function () { ...@@ -211,4 +203,19 @@ describe('annotationUI', function () {
assert.isNull(annotationUI.getState().selectedAnnotationMap); assert.isNull(annotationUI.getState().selectedAnnotationMap);
}); });
}); });
describe('#setFilterQuery()', function () {
it('sets the filter query', function () {
annotationUI.setFilterQuery('a-query');
assert.equal(annotationUI.getState().filterQuery, 'a-query');
});
it('resets the force-visible and expanded sets', function () {
annotationUI.setForceVisible('123', true);
annotationUI.setCollapsed('456', false);
annotationUI.setFilterQuery('some-query');
assert.deepEqual(annotationUI.getState().forceVisible, {});
assert.deepEqual(annotationUI.getState().expanded, {});
});
});
}); });
...@@ -63,14 +63,14 @@ describe('annotation threading', function () { ...@@ -63,14 +63,14 @@ describe('annotation threading', function () {
it('should filter annotations when a search is set', function () { it('should filter annotations when a search is set', function () {
annotationUI.addAnnotations(fixtures.annotations); annotationUI.addAnnotations(fixtures.annotations);
rootThread.setSearchQuery('second'); annotationUI.setFilterQuery('second');
assert.equal(rootThread.thread().children.length, 1); assert.equal(rootThread.thread().children.length, 1);
assert.equal(rootThread.thread().children[0].id, '2'); assert.equal(rootThread.thread().children[0].id, '2');
}); });
unroll('should sort annotations by #mode', function (testCase) { unroll('should sort annotations by #mode', function (testCase) {
annotationUI.addAnnotations(fixtures.annotations); annotationUI.addAnnotations(fixtures.annotations);
rootThread.sortBy(testCase.mode); annotationUI.sortBy(testCase.mode);
var actualOrder = rootThread.thread().children.map(function (thread) { var actualOrder = rootThread.thread().children.map(function (thread) {
return thread.annotation.id; return thread.annotation.id;
}); });
......
...@@ -36,6 +36,8 @@ describe('rootThread', function () { ...@@ -36,6 +36,8 @@ describe('rootThread', function () {
selectedAnnotationMap: null, selectedAnnotationMap: null,
expanded: {}, expanded: {},
forceVisible: {}, forceVisible: {},
filterQuery: null,
sortMode: 'Location',
}, },
getState: function () { getState: function () {
...@@ -46,7 +48,6 @@ describe('rootThread', function () { ...@@ -46,7 +48,6 @@ describe('rootThread', function () {
removeSelectedAnnotation: sinon.stub(), removeSelectedAnnotation: sinon.stub(),
addAnnotations: sinon.stub(), addAnnotations: sinon.stub(),
setCollapsed: sinon.stub(), setCollapsed: sinon.stub(),
clearForceVisible: sinon.stub(),
}; };
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread); fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
...@@ -146,13 +147,7 @@ describe('rootThread', function () { ...@@ -146,13 +147,7 @@ describe('rootThread', function () {
}); });
}); });
describe('#sortBy', function () { describe('when the sort order changes', function () {
it('rebuilds the thread when the sort order changes', function () {
assertRebuildsThread(function () {
rootThread.sortBy('Newest');
});
});
function sortBy(annotations, sortCompareFn) { function sortBy(annotations, sortCompareFn) {
return annotations.slice().sort(function (a,b) { return annotations.slice().sort(function (a,b) {
return sortCompareFn(a,b) ? -1 : sortCompareFn(b,a) ? 1 : 0; return sortCompareFn(a,b) ? -1 : sortCompareFn(b,a) ? 1 : 0;
...@@ -181,7 +176,10 @@ describe('rootThread', function () { ...@@ -181,7 +176,10 @@ describe('rootThread', function () {
}]; }];
fakeBuildThread.reset(); fakeBuildThread.reset();
rootThread.sortBy(testCase.order); fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state, {
sortMode: testCase.order,
});
rootThread.rebuild();
var sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn; var sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn;
var actualOrder = sortBy(annotations, sortCompareFn).map(function (annot) { var actualOrder = sortBy(annotations, sortCompareFn).map(function (annot) {
return annotations.indexOf(annot); return annotations.indexOf(annot);
...@@ -194,19 +192,15 @@ describe('rootThread', function () { ...@@ -194,19 +192,15 @@ describe('rootThread', function () {
]); ]);
}); });
describe('#setSearchQuery', function () { describe('when the filter query changes', function () {
it('rebuilds the thread when the search query changes', function () { it('generates a thread filter function from the query', function () {
assertRebuildsThread(function () {
rootThread.setSearchQuery('new query');
});
});
it('generates a thread filter function from the search query', function () {
fakeBuildThread.reset(); fakeBuildThread.reset();
var filters = [{any: {terms: ['queryterm']}}]; var filters = [{any: {terms: ['queryterm']}}];
var annotation = annotationFixtures.defaultAnnotation(); var annotation = annotationFixtures.defaultAnnotation();
fakeSearchFilter.generateFacetedFilter.returns(filters); fakeSearchFilter.generateFacetedFilter.returns(filters);
rootThread.setSearchQuery('queryterm'); fakeAnnotationUI.state = Object.assign({}, fakeAnnotationUI.state,
{filterQuery: 'queryterm'});
rootThread.rebuild();
var filterFn = fakeBuildThread.args[0][1].filterFn; var filterFn = fakeBuildThread.args[0][1].filterFn;
fakeViewFilter.filter.returns([annotation]); fakeViewFilter.filter.returns([annotation]);
...@@ -214,11 +208,6 @@ describe('rootThread', function () { ...@@ -214,11 +208,6 @@ describe('rootThread', function () {
assert.calledWith(fakeViewFilter.filter, sinon.match([annotation]), assert.calledWith(fakeViewFilter.filter, sinon.match([annotation]),
filters); filters);
}); });
it('clears the set of explicitly shown conversations', function () {
rootThread.setSearchQuery('new query');
assert.called(fakeAnnotationUI.clearForceVisible);
});
}); });
context('when annotation events occur', function () { context('when annotation events occur', function () {
......
...@@ -207,10 +207,10 @@ module.exports = function WidgetController( ...@@ -207,10 +207,10 @@ module.exports = function WidgetController(
// Watch the inputs that determine which annotations are currently // Watch the inputs that determine which annotations are currently
// visible and how they are sorted and rebuild the thread when they change // visible and how they are sorted and rebuild the thread when they change
$scope.$watch('sort.name', function (mode) { $scope.$watch('sort.name', function (mode) {
rootThread.sortBy(mode); annotationUI.sortBy(mode);
}); });
$scope.$watch('search.query', function (query) { $scope.$watch('search.query', function (query) {
rootThread.setSearchQuery(query); annotationUI.setFilterQuery(query);
}); });
$scope.rootThread = function () { $scope.rootThread = 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