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