Commit ce465895 authored by Robert Knight's avatar Robert Knight

Update layout of filter and search UI

Take initial steps to update the design of the search panel and filter controls
to align with the mocks in https://github.com/hypothesis/client/issues/6006.

 - Move the filter status UI into the search panel. This reduces the amount of
   vertical space required when a search and/or filter are active.

 - Replace the filter status description string + single filter state toggle
   with a row of toggle buttons, one per filter.

   Using one toggle button per filter allows the user to toggle the different
   filter states independently. Removing the filter description saves space and
   also it was getting increasingly complicated to construct descriptions of all
   the possible search/filter combinations.
parent 44220125
......@@ -13,7 +13,6 @@ import SidebarContentError from './SidebarContentError';
import ThreadList from './ThreadList';
import { useRootThread } from './hooks/use-root-thread';
import FilterStatus from './old-search/FilterStatus';
import FilterAnnotationsStatus from './search/FilterAnnotationsStatus';
export type SidebarViewProps = {
onLogin: () => void;
......@@ -135,7 +134,6 @@ function SidebarView({
<div>
<h2 className="sr-only">Annotations</h2>
{showFilterStatus && <FilterStatus />}
{searchPanelEnabled && <FilterAnnotationsStatus />}
<LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} />
{hasDirectLinkedAnnotationError && (
<SidebarContentError
......
import {
Button,
CancelIcon,
Card,
CardContent,
Spinner,
} from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useMemo } from 'preact/hooks';
import { countVisible } from '../../helpers/thread';
import { useSidebarStore } from '../../store';
import { useRootThread } from '../hooks/use-root-thread';
type FilterStatusMessageProps = {
/**
* A count of items that are visible but do not match the filters (i.e. items
* that have been "forced visible" by the user)
*/
additionalCount: number;
/** Singular unit of the items being shown, e.g. "result" or "annotation" */
entitySingular: string;
/** Plural unit of the items being shown */
entityPlural: string;
/** Range of content currently focused (if not a page range). */
focusContentRange?: string | null;
/** Display name for the user currently focused, if any */
focusDisplayName?: string | null;
/** Page range that is currently focused, if any */
focusPageRange?: string | null;
/**
* The number of items that match the current filter(s). When focusing on a
* user, this value includes annotations and replies.
* When there are selected annotations, this number includes only top-level
* annotations.
*/
resultCount: number;
};
/**
* Render status text describing the currently-applied filters.
*/
function FilterStatusMessage({
additionalCount,
entitySingular,
entityPlural,
focusContentRange,
focusDisplayName,
focusPageRange,
resultCount,
}: FilterStatusMessageProps) {
let contentLabel;
if (focusContentRange) {
contentLabel = <span> in {focusContentRange}</span>;
} else if (focusPageRange) {
contentLabel = <span> in pages {focusPageRange}</span>;
}
return (
<>
{resultCount > 0 && <span>Showing </span>}
<span className="whitespace-nowrap font-bold">
{resultCount > 0 ? resultCount : 'No'}{' '}
{resultCount === 1 ? entitySingular : entityPlural}
</span>
{focusDisplayName && (
<span>
{' '}
by{' '}
<span className="whitespace-nowrap font-bold">
{focusDisplayName}
</span>
</span>
)}
{contentLabel}
{additionalCount > 0 && (
<span className="whitespace-nowrap italic text-color-text-light">
{' '}
(and {additionalCount} more)
</span>
)}
</>
);
}
/**
* Show a description of currently-applied filters and a button to clear the
* filter(s).
*
* There are three filter modes. Exactly one is applicable at any time. In order
* of precedence:
*
* 1. selection
* One or more annotations are "selected", either by direct user input or
* "direct-linked" annotation(s)
*
* Message formatting:
* "[Showing] (No|<resultCount>) annotation[s] [\(and <additionalCount> more\)]"
* Button:
* "<cancel icon> Show all [\(<totalCount)\)]" - clears the selection
*
* 2. focus
* User-focused mode is configured, but may or may not be active/applied.
*
* Message formatting:
* "[Showing] (No|<resultCount>) annotation[s] [by <focusDisplayName>]
* [\(and <additionalCount> more\)]"
* Button:
* - If there are no forced-visible threads:
* "Show (all|only <focusDisplayName>)" - Toggles the user filter activation
* - If there are any forced-visible threads:
* "Reset filters" - Clears selection/filters (does not affect user filter activation)
*
* 3. null
* No filters are applied.
*
* Message formatting:
* N/A (but container elements still render)
* Button:
* N/A
*
* This component must render its container elements if no filters are applied
* ("null" filter mode). This is because the element with `role="status"`
* needs to be continuously present in the DOM such that dynamic updates to its
* text content are available to assistive technology.
* See https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA22
*/
export default function FilterAnnotationsStatus() {
const store = useSidebarStore();
const { rootThread } = useRootThread();
const annotationCount = store.annotationCount();
const directLinkedId = store.directLinkedAnnotationId();
const focusState = store.focusState();
const forcedVisibleCount = store.forcedVisibleThreads().length;
const selectedCount = store.selectedAnnotations().length;
const filterMode = useMemo(() => {
if (selectedCount > 0) {
return 'selection';
} else if (focusState.configured) {
return 'focus';
}
return null;
}, [selectedCount, focusState]);
// Number of items that match the current filters
const resultCount = useMemo(() => {
return filterMode === 'selection'
? selectedCount
: countVisible(rootThread) - forcedVisibleCount;
}, [filterMode, selectedCount, rootThread, forcedVisibleCount]);
// Number of additional items that are visible but do not match current
// filters. This can happen when, e.g.:
// - A user manually expands a thread that does not match the current
// filtering
// - A user creates a new annotation when there are applied filters
const additionalCount = useMemo(() => {
if (filterMode === 'selection') {
// Selection filtering deals in top-level annotations only.
// Compare visible top-level annotations against the count of selected
// (top-level) annotatinos.
const visibleAnnotationCount = (rootThread.children || []).filter(
thread => thread.annotation && thread.visible,
).length;
return visibleAnnotationCount - selectedCount;
} else {
return forcedVisibleCount;
}
}, [filterMode, forcedVisibleCount, rootThread.children, selectedCount]);
const buttonText = useMemo(() => {
if (filterMode === 'selection') {
// Because of the confusion between counts of entities between selected
// annotations and filtered annotations, don't display the total number
// when in user-focus mode because the numbers won't appear to make sense.
// Don't display total count, either, when viewing a direct-linked annotation.
const showCount = !focusState.configured && !directLinkedId;
return showCount ? `Show all (${annotationCount})` : 'Show all';
} else if (filterMode === 'focus') {
if (forcedVisibleCount > 0) {
return 'Reset filters';
}
if (focusState.active) {
return 'Show all';
} else if (focusState.displayName) {
return `Show only ${focusState.displayName}`;
} else if (focusState.configured) {
// Generic label for button to re-enable focus mode, if we don't have
// a more specific one.
return 'Reset filter';
}
}
return 'Clear search';
}, [
annotationCount,
directLinkedId,
focusState,
filterMode,
forcedVisibleCount,
]);
const showFocusHint = filterMode !== 'selection' && focusState.active;
return (
<div
// This container element needs to be present at all times but
// should only be visible when there are applied filters
className={classnames('mb-3', { 'sr-only': !filterMode })}
data-testid="filter-status-container"
>
<Card>
<CardContent>
{store.isLoading() ? (
<Spinner size="md" />
) : (
<div className="flex items-center justify-center space-x-1">
<div
className={classnames(
// Setting `min-width: 0` here allows wrapping to work as
// expected for long strings.
// See https://css-tricks.com/flexbox-truncated-text/
'grow min-w-[0]',
)}
role="status"
>
{filterMode && (
<FilterStatusMessage
additionalCount={additionalCount}
entitySingular="annotation"
entityPlural="annotations"
focusContentRange={
showFocusHint ? focusState.contentRange : null
}
focusDisplayName={
showFocusHint ? focusState.displayName : null
}
focusPageRange={showFocusHint ? focusState.pageRange : null}
resultCount={resultCount}
/>
)}
</div>
{filterMode && (
<Button
onClick={
filterMode === 'focus' && !forcedVisibleCount
? () => store.toggleFocusMode()
: () => store.clearSelection()
}
size="sm"
title={buttonText}
variant="primary"
data-testid="clear-button"
>
{/** @TODO: Set `icon` prop in `Button` instead when https://github.com/hypothesis/frontend-shared/issues/675 is fixed*/}
{filterMode !== 'focus' && <CancelIcon />}
{buttonText}
</Button>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
import { Button } from '@hypothesis/frontend-shared';
import { useSidebarStore } from '../../store';
type FilterToggleProps = {
/** A short description of what the filter matches (eg. "Pages 10-20") */
label: string;
/**
* A longer description of what the filter matches (eg. "Show annotations
* on pages 10-20").
*/
description: string;
/** Is the filter currently active? */
active: boolean;
/** Toggle whether the filter is active. */
setActive: (active: boolean) => void;
testId?: string;
};
/**
* A switch for toggling whether a filter is active or not.
*/
function FilterToggle({
label,
description,
active,
setActive,
testId,
}: FilterToggleProps) {
return (
<Button
data-testid={testId}
onClick={() => setActive(!active)}
variant={active ? 'primary' : 'secondary'}
title={description}
>
{label}
</Button>
);
}
/**
* Displays the state of various filters and allows the user to toggle them.
*
* This includes:
*
* - Focus filters which show anotations from particular user, page range
* etc.
* - Selection state
*
* This doesn't include the state of other layers of filters which have their
* own UI controls such as search or the annotation type tabs.
*/
export default function FilterControls() {
const store = useSidebarStore();
const selectedCount = store.selectedAnnotations().length;
const hasSelection = selectedCount > 0;
const focusActive = store.getFocusActive();
const focusFilters = store.getFocusFilters();
// Are any focus filters configured?
const hasFocusFilters = Object.keys(focusFilters).length > 0;
const hasFilters = hasSelection || hasFocusFilters;
// When there are no active filters, remove the container so its padding
// doesn't unnecessarily take up empty space.
if (!hasFilters) {
return null;
}
return (
<div
className="flex flex-row gap-x-2 items-center"
data-testid="filter-controls"
>
<b>Filters</b>
{hasSelection && (
<FilterToggle
label={`${selectedCount} selected`}
description={`Show ${selectedCount} selected annotations`}
active={true}
setActive={() => store.clearSelection()}
testId="selection-toggle"
/>
)}
{focusFilters.user && (
<FilterToggle
label={`By ${focusFilters.user.display}`}
description={`Show annotations by ${focusFilters.user.display}`}
active={focusActive.has('user')}
setActive={() => store.toggleFocusMode({ key: 'user' })}
testId="user-focus-toggle"
/>
)}
{focusFilters.page && (
<FilterToggle
label={`Pages ${focusFilters.page.display}`}
description={`Show annotations on pages ${focusFilters.page.display}`}
active={focusActive.has('page')}
setActive={() => store.toggleFocusMode({ key: 'page' })}
testId="page-focus-toggle"
/>
)}
{focusFilters.cfi && (
<FilterToggle
label="Selected chapter"
description="Show annotations on selected book chapter(s)"
active={focusActive.has('cfi')}
setActive={() => store.toggleFocusMode({ key: 'cfi' })}
testId="cfi-focus-toggle"
/>
)}
</div>
);
}
import {
CancelIcon,
IconButton,
Input,
InputGroup,
......@@ -16,6 +17,8 @@ export type SearchFieldProps = {
/** The currently-active filter query */
query: string | null;
onClearSearch: () => void;
/** Callback for when the current filter query changes */
onSearch: (value: string) => void;
......@@ -34,11 +37,12 @@ export type SearchFieldProps = {
* or searches annotations (in the stream/single annotation view).
*/
export default function SearchField({
query,
onSearch,
inputRef,
classes,
inputRef,
onClearSearch,
onKeyDown,
onSearch,
query,
}: SearchFieldProps) {
const store = useSidebarStore();
const isLoading = store.isLoading();
......@@ -80,6 +84,7 @@ export default function SearchField({
className={classnames('space-y-3', classes)}
>
<InputGroup>
<IconButton icon={SearchIcon} title="Search" type="submit" />
<Input
aria-label="Search annotations"
classes={classnames(
......@@ -98,12 +103,14 @@ export default function SearchField({
}
onKeyDown={onKeyDown}
/>
{pendingQuery && (
<IconButton
icon={SearchIcon}
title="Search"
type="submit"
variant="dark"
icon={CancelIcon}
data-testid="clear-button"
title="Clear search"
onClick={onClearSearch}
/>
)}
</InputGroup>
</form>
);
......
import { Card, CardContent, CloseButton } from '@hypothesis/frontend-shared';
import { Card, CardContent } from '@hypothesis/frontend-shared';
import { useRef } from 'preact/hooks';
import { useSidebarStore } from '../../store';
import SidebarPanel from '../SidebarPanel';
import FilterControls from './FilterControls';
import SearchField from './SearchField';
import SearchStatus from './SearchStatus';
export default function SearchPanel() {
const store = useSidebarStore();
const filterQuery = store.filterQuery();
const inputRef = useRef<HTMLInputElement | null>(null);
const clearSearch = () => {
store.closeSidebarPanel('searchAnnotations');
};
return (
<SidebarPanel
panelName="searchAnnotations"
......@@ -30,22 +34,16 @@ export default function SearchPanel() {
inputRef={inputRef}
classes="grow"
query={filterQuery || null}
onClearSearch={clearSearch}
onSearch={store.setFilterQuery}
onKeyDown={e => {
// Close panel on Escape, which will also clear search
if (e.key === 'Escape') {
store.closeSidebarPanel('searchAnnotations');
clearSearch();
}
}}
/>
<CloseButton
classes="text-[16px] text-grey-6 hover:text-grey-7 hover:bg-grey-3/50"
title="Close"
variant="custom"
size="sm"
/>
</div>
{filterQuery && <SearchStatus />}
<FilterControls />
</CardContent>
</Card>
</SidebarPanel>
......
import classnames from 'classnames';
import { useMemo } from 'preact/hooks';
import { countVisible } from '../../helpers/thread';
import { useSidebarStore } from '../../store';
import { useRootThread } from '../hooks/use-root-thread';
export default function SearchStatus() {
const store = useSidebarStore();
const { rootThread } = useRootThread();
const filterQuery = store.filterQuery();
const forcedVisibleCount = store.forcedVisibleThreads().length;
// Number of items that match current search query
const resultCount = useMemo(
() => countVisible(rootThread) - forcedVisibleCount,
[rootThread, forcedVisibleCount],
);
return (
<div
// This container element needs to be present at all times but
// should only be visible when there are applied filters
className={classnames('mb-1 flex items-center justify-center space-x-1', {
'sr-only': !filterQuery,
})}
data-testid="search-status-container"
>
<div
className={classnames(
// Setting `min-width: 0` here allows wrapping to work as
// expected for long `filterQuery` strings. See
// https://css-tricks.com/flexbox-truncated-text/
'grow min-w-[0]',
)}
role="status"
>
{filterQuery && (
<>
{resultCount > 0 && <span>Showing </span>}
<span className="whitespace-nowrap font-bold">
{resultCount > 0 ? resultCount : 'No'}{' '}
{resultCount === 1 ? 'result' : 'results'}
</span>
<span>
{' '}
for <span className="break-words">{`'${filterQuery}'`}</span>
</span>
</>
)}
</div>
</div>
);
}
......@@ -24,7 +24,11 @@ function StreamSearchInput({ router }: StreamSearchInputProps) {
};
return searchPanelEnabled ? (
<SearchField query={q ?? ''} onSearch={setQuery} />
<SearchField
query={q ?? ''}
onSearch={setQuery}
onClearSearch={() => setQuery('')}
/>
) : (
<SearchInput query={q ?? ''} onSearch={setQuery} alwaysExpanded />
);
......
import { mockImportedComponents } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import FilterAnnotationsStatus, { $imports } from '../FilterAnnotationsStatus';
function getFocusState() {
return {
active: false,
configured: false,
focusDisplayName: '',
};
}
describe('FilterAnnotationsStatus', () => {
let fakeStore;
let fakeUseRootThread;
let fakeThreadUtil;
const createComponent = () => {
return mount(<FilterAnnotationsStatus />);
};
beforeEach(() => {
fakeThreadUtil = {
countVisible: sinon.stub().returns(0),
};
fakeStore = {
annotationCount: sinon.stub(),
clearSelection: sinon.stub(),
directLinkedAnnotationId: sinon.stub(),
focusState: sinon.stub().returns(getFocusState()),
forcedVisibleThreads: sinon.stub().returns([]),
isLoading: sinon.stub().returns(false),
selectedAnnotations: sinon.stub().returns([]),
toggleFocusMode: sinon.stub(),
};
fakeUseRootThread = sinon.stub().returns({ rootThread: { children: [] } });
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../hooks/use-root-thread': { useRootThread: fakeUseRootThread },
'../../store': { useSidebarStore: () => fakeStore },
'../../helpers/thread': fakeThreadUtil,
});
});
afterEach(() => {
$imports.$restore();
});
function assertFilterText(wrapper, text) {
const filterText = wrapper.find('[role="status"]').text();
assert.equal(filterText, text);
}
function assertButton(wrapper, expected) {
const button = wrapper.find('Button[data-testid="clear-button"]');
const buttonProps = button.props();
assert.equal(buttonProps.title, expected.text);
assert.equal(button.find('CancelIcon').exists(), !!expected.icon);
buttonProps.onClick();
assert.calledOnce(expected.callback);
}
context('Loading', () => {
it('shows a loading spinner', () => {
fakeStore.isLoading.returns(true);
const wrapper = createComponent();
assert.isTrue(wrapper.find('Spinner').exists());
});
});
context('no search filters active', () => {
it('should render hidden but available to screen readers', () => {
const wrapper = createComponent();
const containerEl = wrapper
.find('div[data-testid="filter-status-container"]')
.getDOMNode();
assert.include(containerEl.className, 'sr-only');
assertFilterText(wrapper, '');
});
});
context('selected annotations', () => {
beforeEach(() => {
fakeStore.selectedAnnotations.returns([1]);
});
it('should show the count of annotations', () => {
assertFilterText(createComponent(), 'Showing 1 annotation');
});
it('should pluralize annotations when necessary', () => {
fakeStore.selectedAnnotations.returns([1, 2, 3, 4]);
assertFilterText(createComponent(), 'Showing 4 annotations');
});
it('should show the count of additionally-shown top-level annotations', () => {
// In selection mode, "forced visible" count is computed by subtracting
// the selectedCount from the count of all visible top-level threads
// (children/replies are ignored in this count)
fakeUseRootThread.returns({
rootThread: {
id: '__default__',
children: [
{ id: '1', annotation: { $tag: '1' }, visible: true, children: [] },
{
id: '2',
annotation: { $tag: '2' },
visible: true,
children: [
{
id: '2a',
annotation: { $tag: '2a' },
visible: true,
children: [],
},
],
},
],
},
});
assertFilterText(createComponent(), 'Showing 1 annotation (and 1 more)');
});
it('should provide a "Show all" button that shows a count of all annotations', () => {
fakeStore.annotationCount.returns(5);
assertButton(createComponent(), {
text: 'Show all (5)',
icon: true,
callback: fakeStore.clearSelection,
});
});
it('should not show count of annotations on "Show All" button if direct-linked annotation present', () => {
fakeStore.annotationCount.returns(5);
fakeStore.directLinkedAnnotationId.returns(1);
assertButton(createComponent(), {
text: 'Show all',
icon: true,
callback: fakeStore.clearSelection,
});
});
});
context('user-focus mode active', () => {
beforeEach(() => {
fakeStore.focusState.returns({
active: true,
configured: true,
displayName: 'Ebenezer Studentolog',
});
fakeThreadUtil.countVisible.returns(1);
});
it('should show a count of annotations by the focused user', () => {
assertFilterText(
createComponent(),
'Showing 1 annotation by Ebenezer Studentolog',
);
});
it('should pluralize annotations when needed', () => {
fakeThreadUtil.countVisible.returns(3);
assertFilterText(
createComponent(),
'Showing 3 annotations by Ebenezer Studentolog',
);
});
it('should show a no results message when user has no annotations', () => {
fakeThreadUtil.countVisible.returns(0);
assertFilterText(
createComponent(),
'No annotations by Ebenezer Studentolog',
);
});
it('should provide a "Show all" button that toggles user focus mode', () => {
assertButton(createComponent(), {
text: 'Show all',
icon: false,
callback: fakeStore.toggleFocusMode,
});
});
});
context('user-focus mode active, force-expanded threads', () => {
beforeEach(() => {
fakeStore.focusState.returns({
active: true,
configured: true,
displayName: 'Ebenezer Studentolog',
});
fakeStore.forcedVisibleThreads.returns([1, 2]);
fakeThreadUtil.countVisible.returns(3);
});
it('should show a count of annotations by the focused user', () => {
assertFilterText(
createComponent(),
'Showing 1 annotation by Ebenezer Studentolog (and 2 more)',
);
});
it('should provide a "Show all" button', () => {
const wrapper = createComponent();
const button = wrapper.find('Button[data-testid="clear-button"]');
assert.equal(button.text(), 'Reset filters');
button.props().onClick();
assert.calledOnce(fakeStore.clearSelection);
});
});
context('user-focus mode active, selected annotations', () => {
beforeEach(() => {
fakeStore.focusState.returns({
active: true,
configured: true,
displayName: 'Ebenezer Studentolog',
});
fakeStore.selectedAnnotations.returns([1, 2]);
});
it('should ignore user and display selected annotations', () => {
assertFilterText(createComponent(), 'Showing 2 annotations');
});
it('should provide a "Show all" button', () => {
assertButton(createComponent(), {
text: 'Show all',
icon: true,
callback: fakeStore.clearSelection,
});
});
});
context('user-focus mode configured but inactive', () => {
beforeEach(() => {
fakeStore.focusState.returns({
active: false,
configured: true,
displayName: 'Ebenezer Studentolog',
});
fakeThreadUtil.countVisible.returns(7);
});
it("should show a count of everyone's annotations", () => {
assertFilterText(createComponent(), 'Showing 7 annotations');
});
it('should provide a button to activate user-focused mode', () => {
assertButton(createComponent(), {
text: 'Show only Ebenezer Studentolog',
icon: false,
callback: fakeStore.toggleFocusMode,
});
});
});
it('shows focused page range', () => {
fakeStore.focusState.returns({
active: true,
configured: true,
pageRange: '5-10',
});
fakeThreadUtil.countVisible.returns(7);
assertFilterText(createComponent(), 'Showing 7 annotations in pages 5-10');
});
it('shows focused content range', () => {
fakeStore.focusState.returns({
active: true,
configured: true,
contentRange: 'Chapter 2',
});
fakeThreadUtil.countVisible.returns(3);
assertFilterText(createComponent(), 'Showing 3 annotations in Chapter 2');
});
[{ pageRange: '5-10' }, { contentRange: 'Chapter 2' }].forEach(focusState => {
it('shows button to reset focus mode if content focus is configured but inactive', () => {
fakeStore.focusState.returns({
active: false,
configured: true,
...focusState,
});
fakeThreadUtil.countVisible.returns(7);
assertButton(createComponent(), {
text: 'Reset filter',
icon: false,
callback: fakeStore.toggleFocusMode,
});
});
});
});
import { mount } from 'enzyme';
import FilterControls, { $imports } from '../FilterControls';
describe('FilterControls', () => {
let fakeStore;
const createComponent = () => {
return mount(<FilterControls />);
};
beforeEach(() => {
fakeStore = {
clearSelection: sinon.stub(),
getFocusActive: sinon.stub().returns(new Set()),
getFocusFilters: sinon.stub().returns({}),
selectedAnnotations: sinon.stub().returns(0),
toggleFocusMode: sinon.stub(),
};
$imports.$mock({
'../../store': { useSidebarStore: () => fakeStore },
});
});
afterEach(() => {
$imports.$restore();
});
/** Wrapper around toggle buttons in test output. */
class ToggleButtonWrapper {
/** Wrap the toggle button with the given `data-testid`. */
constructor(wrapper, testId) {
this.testId = testId;
this.wrapper = wrapper;
this.update();
}
exists() {
return this.button.exists();
}
label() {
return this.button.text();
}
/** Return true if button is rendered in the active state. */
isActive() {
return this.button.prop('variant') === 'primary';
}
click() {
this.button.find('button').simulate('click');
}
/** Refresh the wrapper after the parent is re-rendered. */
update() {
this.button = this.wrapper.find(`Button[data-testid="${this.testId}"]`);
}
}
it('returns nothing if there are no filters', () => {
const wrapper = createComponent();
assert.equal(wrapper.html(), '');
});
it('displays selection toggle if there is a selection', () => {
fakeStore.selectedAnnotations.returns([{ id: 'abc' }]);
const wrapper = createComponent();
const toggle = new ToggleButtonWrapper(wrapper, 'selection-toggle');
assert.isTrue(toggle.exists());
assert.isTrue(toggle.isActive());
toggle.click();
assert.calledOnce(fakeStore.clearSelection);
});
[
{
filterType: 'user',
focusFilters: {
user: {
display: 'Some User',
},
},
label: 'By Some User',
},
{
filterType: 'page',
focusFilters: {
page: {
display: '10-30',
},
},
label: 'Pages 10-30',
},
{
filterType: 'cfi',
focusFilters: {
cfi: {
display: 'Chapter One',
},
},
label: 'Selected chapter',
},
].forEach(({ filterType, focusFilters, label }) => {
it(`displays ${filterType} toggle if there is a ${filterType} focus filter configured`, () => {
// Configure and activate focus filter.
fakeStore.getFocusFilters.returns(focusFilters);
fakeStore.getFocusActive.returns(new Set([filterType]));
// Toggle should be displayed and active.
const wrapper = createComponent();
const toggle = new ToggleButtonWrapper(
wrapper,
`${filterType}-focus-toggle`,
);
assert.isTrue(toggle.exists());
assert.isTrue(toggle.isActive());
assert.equal(toggle.label(), label);
toggle.click();
assert.calledWith(fakeStore.toggleFocusMode, { key: filterType });
// Simulate focus filter being disabled. The button should be rendered in
// a non-active state.
fakeStore.getFocusActive.returns(new Set());
wrapper.setProps({});
toggle.update();
assert.isFalse(toggle.isActive());
});
});
});
......@@ -23,6 +23,10 @@ describe('SearchField', () => {
input.simulate('input');
}
function getClearButton(wrapper) {
return wrapper.find('button[data-testid="clear-button"]');
}
beforeEach(() => {
wrappers = [];
container = document.createElement('div');
......@@ -123,6 +127,27 @@ describe('SearchField', () => {
assert.equal(document.activeElement, searchInputEl);
});
it('displays clear button when there is a pending query', () => {
const wrapper = createSearchField();
assert.isFalse(getClearButton(wrapper).exists());
typeQuery(wrapper, 'some query');
assert.isTrue(getClearButton(wrapper).exists());
typeQuery(wrapper, '');
assert.isFalse(getClearButton(wrapper).exists());
});
it('clears search when clear button is clicked', () => {
const onClearSearch = sinon.stub();
const wrapper = createSearchField({ onClearSearch });
typeQuery(wrapper, 'some query');
getClearButton(wrapper).simulate('click');
assert.calledOnce(onClearSearch);
});
it(
'should pass a11y checks',
checkAccessibility([
......
......@@ -61,17 +61,4 @@ describe('SearchPanel', () => {
assert.calledWith(fakeStore.setFilterQuery, 'foo');
});
[
{ query: null, searchStatusIsRendered: false },
{ query: '', searchStatusIsRendered: false },
{ query: 'foo', searchStatusIsRendered: true },
].forEach(({ query, searchStatusIsRendered }) => {
it("renders SearchStatus only when there's an active query", () => {
fakeStore.filterQuery.returns(query);
const wrapper = createSearchPanel();
assert.equal(wrapper.exists('SearchStatus'), searchStatusIsRendered);
});
});
});
import { mockImportedComponents } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import SearchStatus, { $imports } from '../SearchStatus';
describe('SearchStatus', () => {
let fakeStore;
let fakeUseRootThread;
let fakeThreadUtil;
const createComponent = () => {
return mount(<SearchStatus />);
};
beforeEach(() => {
fakeThreadUtil = {
countVisible: sinon.stub().returns(0),
};
fakeStore = {
clearSelection: sinon.stub(),
filterQuery: sinon.stub().returns(null),
forcedVisibleThreads: sinon.stub().returns([]),
};
fakeUseRootThread = sinon.stub().returns({});
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../hooks/use-root-thread': { useRootThread: fakeUseRootThread },
'../../store': { useSidebarStore: () => fakeStore },
'../../helpers/thread': fakeThreadUtil,
});
});
function assertFilterText(wrapper, text) {
const filterText = wrapper.find('[role="status"]').text();
assert.equal(filterText, text);
}
context('when no search filters are active', () => {
it('should render hidden but available to screen readers', () => {
const wrapper = createComponent();
const containerEl = wrapper
.find('div[data-testid="search-status-container"]')
.getDOMNode();
assert.include(containerEl.className, 'sr-only');
assertFilterText(wrapper, '');
});
});
context('when filtered by query', () => {
beforeEach(() => {
fakeStore.filterQuery.returns('foobar');
fakeThreadUtil.countVisible.returns(1);
});
it('should show the count of matching results', () => {
assertFilterText(createComponent(), "Showing 1 result for 'foobar'");
});
it('should show pluralized count of results when appropriate', () => {
fakeThreadUtil.countVisible.returns(5);
assertFilterText(createComponent(), "Showing 5 results for 'foobar'");
});
it('should show a no results message when no matches', () => {
fakeThreadUtil.countVisible.returns(0);
assertFilterText(createComponent(), "No results for 'foobar'");
});
});
context('when filtered by query with force-expanded threads', () => {
beforeEach(() => {
fakeStore.filterQuery.returns('foobar');
fakeStore.forcedVisibleThreads.returns([1, 2, 3]);
fakeThreadUtil.countVisible.returns(5);
});
it('should show a separate count for results versus forced visible', () => {
assertFilterText(createComponent(), "Showing 2 results for 'foobar'");
});
});
});
......@@ -52,4 +52,13 @@ describe('StreamSearchInput', () => {
assert.isFalse(wrapper.exists('SearchInput'));
assert.isTrue(wrapper.exists('SearchField'));
});
it('clears filter when clear button is clicked', () => {
fakeStore.isFeatureEnabled.returns(true);
const wrapper = createSearchInput();
act(() => {
wrapper.find('SearchField').props().onClearSearch();
});
assert.calledWith(fakeRouter.navigate, 'stream', { q: '' });
});
});
......@@ -248,20 +248,9 @@ describe('SidebarView', () => {
});
context('user-focus mode', () => {
it('shows old filter status when focus mode configured', () => {
it('shows old FilterStatus component when `search_panel` feature is disabled', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.find('FilterStatus').exists());
assert.isFalse(wrapper.find('FilterAnnotationsStatus').exists());
});
it('shows filter status when focus mode configured', () => {
fakeStore.isFeatureEnabled.returns(true);
const wrapper = createComponent();
assert.isFalse(wrapper.find('FilterStatus').exists());
assert.isTrue(wrapper.find('FilterAnnotationsStatus').exists());
});
});
......
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