Unverified Commit b0dfce31 authored by Hannah Stepanek's avatar Hannah Stepanek Committed by GitHub

Merge pull request #1254 from hypothesis/convert-search-status-bar

Convert search status bar to preact
parents 30d4004e cdd54488
'use strict'; 'use strict';
const memoize = require('../util/memoize'); const { Fragment, createElement } = require('preact');
const propTypes = require('prop-types');
const { useMemo } = require('preact/hooks');
const { withServices } = require('../util/service-context');
const uiConstants = require('../ui-constants'); const uiConstants = require('../ui-constants');
const useStore = require('../store/use-store');
// @ngInject const countVisibleAnns = annThread => {
function SearchStatusBarController(store, rootThread) { return annThread.children.reduce(
this.TAB_ANNOTATIONS = uiConstants.TAB_ANNOTATIONS; function(count, child) {
this.TAB_NOTES = uiConstants.TAB_NOTES; return count + countVisibleAnns(child);
this.TAB_ORPHANS = uiConstants.TAB_ORPHANS; },
annThread.visible ? 1 : 0
);
};
const thread = () => { /**
return rootThread.thread(store.getState()); * A bar where the user can clear a selection or search and see whether
}; * any search results were found.
* */
function SearchStatusBar({
selectedTab,
totalAnnotations,
totalNotes,
rootThread,
}) {
const storeState = useStore(store => store.getState());
const clearSelection = useStore(store => store.clearSelection);
const filterQuery = storeState.filterQuery;
const filterActive = !!storeState.filterQuery;
const thread = rootThread.thread(storeState);
const visibleCount = memoize(thread => { const visibleCount = useMemo(() => {
return thread.children.reduce( return countVisibleAnns(thread);
function(count, child) { }, [thread]);
return count + visibleCount(child);
}, const filterResults = () => {
thread.visible ? 1 : 0 const resultsCount = visibleCount;
); switch (resultsCount) {
}); case 0:
return 'No results for "' + filterQuery + '"';
this.filterMatchCount = function() { case 1:
return visibleCount(thread()); return '1 search result';
default:
return resultsCount + ' search results';
}
}; };
this.areAllAnnotationsVisible = function() { const areNotAllAnnotationsVisible = () => {
if (store.getState().directLinkedGroupFetchFailed) { if (storeState.directLinkedGroupFetchFailed) {
return true; return true;
} }
const selection = store.getState().selectedAnnotationMap; const selection = storeState.selectedAnnotationMap;
if (!selection) { if (!selection) {
return false; return false;
} }
return Object.keys(selection).length > 0; return Object.keys(selection).length > 0;
}; };
this.filterQuery = function() { return (
return store.getState().filterQuery; <div>
}; {filterActive && (
<div className="search-status-bar">
this.filterActive = function() { <button
return !!store.getState().filterQuery; className="primary-action-btn primary-action-btn--short"
}; onClick={clearSelection}
title="Clear the search filter and show all annotations"
this.onClearSelection = function() { >
store.clearSelection(); <i className="primary-action-btn__icon h-icon-close" />
}; Clear search
</button>
<span>{filterResults()}</span>
</div>
)}
{!filterActive && areNotAllAnnotationsVisible() && (
<div className="search-status-bar">
<button
className="primary-action-btn primary-action-btn--short"
onClick={clearSelection}
title="Clear the selection and show all annotations"
>
{selectedTab === uiConstants.TAB_ORPHANS && (
<Fragment>Show all annotations and notes</Fragment>
)}
{selectedTab === uiConstants.TAB_ANNOTATIONS && (
<Fragment>
Show all annotations
{totalAnnotations > 1 && <span> ({totalAnnotations})</span>}
</Fragment>
)}
{selectedTab === uiConstants.TAB_NOTES && (
<Fragment>
Show all notes
{totalNotes > 1 && <span> ({totalNotes})</span>}
</Fragment>
)}
</button>
</div>
)}
</div>
);
} }
module.exports = { SearchStatusBar.propTypes = {
controller: SearchStatusBarController, selectedTab: propTypes.oneOf([
controllerAs: 'vm', uiConstants.TAB_ANNOTATIONS,
bindings: { uiConstants.TAB_ORPHANS,
selectedTab: '<', uiConstants.TAB_NOTES,
totalAnnotations: '<', ]).isRequired,
totalNotes: '<', totalAnnotations: propTypes.number.isRequired,
}, totalNotes: propTypes.number.isRequired,
template: require('../templates/search-status-bar.html'), rootThread: propTypes.object.isRequired,
}; };
SearchStatusBar.injectedProps = ['rootThread'];
module.exports = withServices(SearchStatusBar);
'use strict'; 'use strict';
const angular = require('angular'); const { shallow } = require('enzyme');
const { createElement } = require('preact');
const util = require('../../directive/test/util'); const SearchStatusBar = require('../search-status-bar');
describe('searchStatusBar', () => {
before(() => {
angular
.module('app', [])
.component('searchStatusBar', require('../search-status-bar'));
});
describe('SearchStatusBar', () => {
let fakeRootThread; let fakeRootThread;
let fakeStore; let fakeStore;
function createComponent(props) {
return shallow(
<SearchStatusBar rootThread={fakeRootThread} {...props} />
).dive(); // dive() needed because this component uses `withServices`
}
beforeEach(() => { beforeEach(() => {
fakeRootThread = { fakeRootThread = {
thread: sinon.stub(), thread: sinon.stub().returns({ children: [] }),
}; };
fakeStore = { fakeStore = {
getState: sinon.stub(), getState: sinon.stub(),
...@@ -26,174 +27,192 @@ describe('searchStatusBar', () => { ...@@ -26,174 +27,192 @@ describe('searchStatusBar', () => {
clearDirectLinkedIds: sinon.stub(), clearDirectLinkedIds: sinon.stub(),
clearSelection: sinon.stub(), clearSelection: sinon.stub(),
}; };
angular.mock.module('app', {
store: fakeStore,
rootThread: fakeRootThread,
});
});
describe('filterQuery', () => {
['tag:foo', null].forEach(filterQuery => {
it('returns the `filterQuery`', () => {
fakeStore.getState.returns({ filterQuery });
const elem = util.createDirective(document, 'searchStatusBar', {});
const ctrl = elem.ctrl;
assert.equal(ctrl.filterQuery(), filterQuery); SearchStatusBar.$imports.$mock({
}); '../store/use-store': callback => callback(fakeStore),
}); });
}); });
describe('filterActive', () => { afterEach(() => {
it('returns true if there is a `filterQuery`', () => { SearchStatusBar.$imports.$restore();
fakeStore.getState.returns({ filterQuery: 'tag:foo' });
const elem = util.createDirective(document, 'searchStatusBar', {});
const ctrl = elem.ctrl;
assert.isTrue(ctrl.filterActive());
});
it('returns false if `filterQuery` is null', () => {
fakeStore.getState.returns({ filterQuery: null });
const elem = util.createDirective(document, 'searchStatusBar', {});
const ctrl = elem.ctrl;
assert.isFalse(ctrl.filterActive());
});
}); });
describe('filterMatchCount', () => { [
it('returns the total number of visible annotations or replies', () => { {
description:
'shows correct text if 2 annotations match the search filter',
children: [
{
id: '1',
visible: true,
children: [{ id: '3', visible: true, children: [] }],
},
{
id: '2',
visible: false,
children: [],
},
],
expectedText: '2 search results',
},
{
description:
'shows correct text if 1 annotation matches the search filter',
children: [
{
id: '1',
visible: true,
children: [{ id: '3', visible: false, children: [] }],
},
{
id: '2',
visible: false,
children: [],
},
],
expectedText: '1 search result',
},
{
description:
'shows correct text if no annotation matches the search filter',
children: [
{
id: '1',
visible: false,
children: [{ id: '3', visible: false, children: [] }],
},
{
id: '2',
visible: false,
children: [],
},
],
expectedText: 'No results for "tag:foo"',
},
].forEach(test => {
it(test.description, () => {
fakeRootThread.thread.returns({ fakeRootThread.thread.returns({
children: [ children: test.children,
{
id: '1',
visible: true,
children: [{ id: '3', visible: true, children: [] }],
},
{
id: '2',
visible: false,
children: [],
},
],
}); });
fakeStore.getState.returns({ fakeStore.getState.returns({
filterQuery: 'tag:foo', filterQuery: 'tag:foo',
}); });
const elem = util.createDirective(document, 'searchStatusBar', {}); const wrapper = createComponent({
const ctrl = elem.ctrl; selectedTab: 'annotation',
totalAnnotations: 3,
totalNotes: 0,
});
const buttonText = wrapper.find('button').text();
assert.equal(buttonText, 'Clear search');
assert.equal(ctrl.filterMatchCount(), 2); const searchResultsText = wrapper.find('span').text();
assert.equal(searchResultsText, test.expectedText);
}); });
}); });
describe('areAllAnnotationsVisible', () => { it('displays "Show all annotations" button when a direct-linked group fetch fails', () => {
it('returns true if the direct-linked group fetch failed', () => { fakeStore.getState.returns({
fakeStore.getState.returns({ directLinkedGroupFetchFailed: true }); filterQuery: null,
directLinkedGroupFetchFailed: true,
const elem = util.createDirective(document, 'searchStatusBar', {}); selectedAnnotationMap: { annId: true },
const ctrl = elem.ctrl;
assert.isTrue(ctrl.areAllAnnotationsVisible());
}); });
it('returns true if there are annotations selected', () => { const wrapper = createComponent({
fakeStore.getState.returns({ selectedTab: 'annotation',
directLinkedGroupFetchFailed: false, totalAnnotations: 1,
selectedAnnotationMap: { ann: true }, totalNotes: 0,
});
const elem = util.createDirective(document, 'searchStatusBar', {});
const ctrl = elem.ctrl;
assert.isTrue(ctrl.areAllAnnotationsVisible());
}); });
it('returns false if there are no annotations selected', () => { const buttonText = wrapper.find('button').text();
fakeStore.getState.returns({ assert.equal(buttonText, 'Show all annotations');
directLinkedGroupFetchFailed: false, });
selectedAnnotationMap: {},
});
const elem = util.createDirective(document, 'searchStatusBar', {});
const ctrl = elem.ctrl;
assert.isFalse(ctrl.areAllAnnotationsVisible()); it('displays "Show all annotations" button when there are selected annotations', () => {
fakeStore.getState.returns({
filterQuery: null,
directLinkedGroupFetchFailed: false,
selectedAnnotationMap: { annId: true },
}); });
it('returns false if the `selectedAnnotationMap` is null', () => { const wrapper = createComponent({
fakeStore.getState.returns({ selectedTab: 'annotation',
directLinkedGroupFetchFailed: false, totalAnnotations: 1,
selectedAnnotationMap: null, totalNotes: 0,
});
const elem = util.createDirective(document, 'searchStatusBar', {});
const ctrl = elem.ctrl;
assert.isFalse(ctrl.areAllAnnotationsVisible());
}); });
});
context('when there is a filter', () => { const buttonText = wrapper.find('button').text();
it('should display the filter count', () => { assert.equal(buttonText, 'Show all annotations');
fakeRootThread.thread.returns({
children: [
{
id: '1',
visible: true,
children: [{ id: '3', visible: true, children: [] }],
},
{
id: '2',
visible: false,
children: [],
},
],
});
fakeStore.getState.returns({
filterQuery: 'tag:foo',
});
const elem = util.createDirective(document, 'searchStatusBar', {});
assert.include(elem[0].textContent, '2 search results');
});
}); });
context('when there is a selection', () => { [null, {}].forEach(selectedAnnotationMap => {
it('should display the "Show all annotations (2)" message when there are 2 annotations', () => { it('does not display "Show all annotations" button when there are no selected annotations', () => {
const msg = 'Show all annotations';
const msgCount = '(2)';
fakeStore.getState.returns({ fakeStore.getState.returns({
selectedAnnotationMap: { ann1: true }, filterQuery: null,
directLinkedGroupFetchFailed: false,
selectedAnnotationMap: selectedAnnotationMap,
}); });
const elem = util.createDirective(document, 'searchStatusBar', { const wrapper = createComponent({
totalAnnotations: 2,
selectedTab: 'annotation', selectedTab: 'annotation',
totalAnnotations: 1,
totalNotes: 0,
}); });
const clearBtn = elem[0].querySelector('button');
assert.include(clearBtn.textContent, msg); const buttons = wrapper.find('button');
assert.include(clearBtn.textContent, msgCount); assert.equal(buttons.length, 0);
}); });
});
it('should display the "Show all notes (3)" message when there are 3 notes', () => { [
const msg = 'Show all notes'; {
const msgCount = '(3)'; description:
'displays "Show all annotations and notes" button when the orphans tab is selected',
selectedTab: 'orphan',
totalAnnotations: 1,
totalNotes: 1,
expectedText: 'Show all annotations and notes',
},
{
description:
'displays "Show all notes" button when the notes tab is selected',
selectedTab: 'note',
totalAnnotations: 1,
totalNotes: 1,
expectedText: 'Show all notes',
},
{
description:
'displays "Show all notes (2)" button when the notes tab is selected and there are two notes',
selectedTab: 'note',
totalAnnotations: 2,
totalNotes: 2,
expectedText: 'Show all notes (2)',
},
{
description:
'displays "Show all annotations (2)" button when the notes tab is selected and there are two annotations',
selectedTab: 'annotation',
totalAnnotations: 2,
totalNotes: 2,
expectedText: 'Show all annotations (2)',
},
].forEach(test => {
it(test.description, () => {
fakeStore.getState.returns({ fakeStore.getState.returns({
selectedAnnotationMap: { ann1: true }, filterQuery: null,
directLinkedGroupFetchFailed: false,
selectedAnnotationMap: { annId: true },
}); });
const elem = util.createDirective(document, 'searchStatusBar', {
totalNotes: 3, const wrapper = createComponent({
selectedTab: 'note', selectedTab: test.selectedTab,
totalAnnotations: test.totalAnnotations,
totalNotes: test.totalNotes,
}); });
const clearBtn = elem[0].querySelector('button');
assert.include(clearBtn.textContent, msg); const buttonText = wrapper.find('button').text();
assert.include(clearBtn.textContent, msgCount); assert.equal(buttonText, test.expectedText);
}); });
}); });
}); });
...@@ -181,7 +181,10 @@ function startAngularApp(config) { ...@@ -181,7 +181,10 @@ function startAngularApp(config) {
wrapReactComponent(require('./components/moderation-banner')) wrapReactComponent(require('./components/moderation-banner'))
) )
.component('newNoteBtn', require('./components/new-note-btn')) .component('newNoteBtn', require('./components/new-note-btn'))
.component('searchStatusBar', require('./components/search-status-bar')) .component(
'searchStatusBar',
wrapReactComponent(require('./components/search-status-bar'))
)
.component('selectionTabs', require('./components/selection-tabs')) .component('selectionTabs', require('./components/selection-tabs'))
.component('sidebarContent', require('./components/sidebar-content')) .component('sidebarContent', require('./components/sidebar-content'))
.component( .component(
......
<div class="search-status-bar" ng-if="vm.filterActive()">
<button class="primary-action-btn primary-action-btn--short"
ng-click="vm.onClearSelection()"
title="Clear the search filter and show all annotations"
>
<i class="primary-action-btn__icon h-icon-close"></i> Clear search
</button>
<span ng-pluralize
count="vm.filterMatchCount()"
when="{'0': 'No results for “{{vm.filterQuery()}}”',
'one': '1 search result',
'other': '{} search results'}"></span>
</div>
<div class="search-status-bar" ng-if="!vm.filterActive() && vm.areAllAnnotationsVisible()">
<button class="primary-action-btn primary-action-btn--short"
ng-click="vm.onClearSelection()"
title="Clear the selection and show all annotations">
<span ng-if="!vm.selectedTab || vm.selectedTab === vm.TAB_ORPHANS">
Show all annotations and notes
</span>
<span ng-if="vm.selectedTab === vm.TAB_ANNOTATIONS">
Show all annotations
<span ng-if="vm.totalAnnotations > 1">
({{vm.totalAnnotations}})
</span>
</span>
<span ng-if="vm.selectedTab === vm.TAB_NOTES">
Show all notes
<span ng-if="vm.totalNotes > 1">
({{vm.totalNotes}})
</span>
</span>
</button>
</div>
...@@ -9,7 +9,8 @@ ...@@ -9,7 +9,8 @@
</selection-tabs> </selection-tabs>
<search-status-bar <search-status-bar
ng-show="!vm.isLoading()" class="search-status-bar"
ng-if="!vm.isLoading()"
selected-tab="vm.selectedTab" selected-tab="vm.selectedTab"
total-annotations="vm.totalAnnotations" total-annotations="vm.totalAnnotations"
total-notes="vm.totalNotes"> total-notes="vm.totalNotes">
......
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