Commit e4427ce9 authored by Robert Knight's avatar Robert Knight

Remove old search UI

Remove the `search_panel` feature flag tests and old search UI.
parent aa8995dc
......@@ -66,8 +66,6 @@ function HypothesisApp({
const isThirdParty = isThirdPartyService(settings);
const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const login = async () => {
if (serviceConfig(settings)) {
// Let the host page handle the login request
......@@ -166,7 +164,7 @@ function HypothesisApp({
<div className="container">
<ToastMessages />
<HelpPanel />
{searchPanelEnabled && <SearchPanel />}
<SearchPanel />
<ShareDialog shareTab={!isThirdParty} />
{route && (
......
......@@ -12,7 +12,6 @@ import SelectionTabs from './SelectionTabs';
import SidebarContentError from './SidebarContentError';
import ThreadList from './ThreadList';
import { useRootThread } from './hooks/use-root-thread';
import FilterStatus from './old-search/FilterStatus';
import FilterControls from './search/FilterControls';
export type SidebarViewProps = {
......@@ -40,8 +39,6 @@ function SidebarView({
// Store state values
const store = useSidebarStore();
const focusedGroupId = store.focusedGroupId();
const hasSelection = store.hasSelectedAnnotations();
const hasAppliedFilter = store.hasAppliedFilter() || hasSelection;
const isLoading = store.isLoading();
const isLoggedIn = store.isLoggedIn();
......@@ -67,19 +64,13 @@ function SidebarView({
const hasContentError =
hasDirectLinkedAnnotationError || hasDirectLinkedGroupError;
const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const showTabs =
!hasContentError && (searchPanelEnabled || !hasAppliedFilter);
// Whether to render the old filter status UI. If no filter is active, this
// will render nothing.
const showFilterStatus = !hasContentError && !searchPanelEnabled;
const showTabs = !hasContentError;
// Whether to render the new filter UI. Note that when the search panel is
// open, filter controls are integrated into it. The UI may render nothing
// if no filters are configured or selection is active.
const isSearchPanelOpen = store.isSidebarPanelOpen('searchAnnotations');
const showFilterControls = searchPanelEnabled && !isSearchPanelOpen;
const showFilterControls = !hasContentError && !isSearchPanelOpen;
// Show a CTA to log in if successfully viewing a direct-linked annotation
// and not logged in
......@@ -143,7 +134,6 @@ function SidebarView({
return (
<div>
<h2 className="sr-only">Annotations</h2>
{showFilterStatus && <FilterStatus />}
{showFilterControls && <FilterControls withCardContainer />}
<LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} />
{hasDirectLinkedAnnotationError && (
......
......@@ -13,7 +13,6 @@ import PendingUpdatesButton from './PendingUpdatesButton';
import PressableIconButton from './PressableIconButton';
import SortMenu from './SortMenu';
import UserMenu from './UserMenu';
import SearchInput from './old-search/SearchInput';
import SearchIconButton from './search/SearchIconButton';
import StreamSearchInput from './search/StreamSearchInput';
......@@ -70,10 +69,8 @@ function TopBar({
const loginLinkStyle = applyTheme(['accentColor'], settings);
const store = useSidebarStore();
const filterQuery = store.filterQuery();
const isLoggedIn = store.isLoggedIn();
const hasFetchedProfile = store.hasFetchedProfile();
const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const toggleSharePanel = () => {
store.toggleSidebarPanel('shareGroupAnnotations');
......@@ -118,13 +115,7 @@ function TopBar({
{isSidebar && (
<>
<PendingUpdatesButton />
{!searchPanelEnabled && (
<SearchInput
query={filterQuery || null}
onSearch={store.setFilterQuery}
/>
)}
{searchPanelEnabled && <SearchIconButton />}
<SearchIconButton />
<SortMenu />
<TopBarToggleButton
icon={ShareIcon}
......
......@@ -64,42 +64,4 @@ describe('sidebar/components/hooks/use-root-thread', () => {
assert.equal(threadState.showTabs, showTabs);
});
});
[
// When using search UI, always filter by tab.
{ newSearchUI: true, hasFilter: true, hasSelection: false, showTabs: true },
// When using old search UI, only filter by tab if no selection or filter
// is active.
{
newSearchUI: false,
hasFilter: true,
hasSelection: false,
showTabs: false,
},
{
newSearchUI: false,
hasFilter: false,
hasSelection: true,
showTabs: false,
},
{
newSearchUI: false,
hasFilter: false,
hasSelection: false,
showTabs: true,
},
].forEach(({ newSearchUI, hasFilter, hasSelection, showTabs }) => {
it('if `search_panel` is disabled, does not filter by tab if there is a filter active', () => {
fakeStore.route.returns('sidebar');
fakeStore.isFeatureEnabled.withArgs('search_panel').returns(newSearchUI);
fakeStore.hasAppliedFilter.returns(hasFilter);
fakeStore.hasSelectedAnnotations.returns(hasSelection);
mount(<DummyComponent />);
const threadState = fakeThreadAnnotations.getCall(0).args[0];
assert.equal(threadState.showTabs, showTabs);
});
});
});
......@@ -18,14 +18,7 @@ export function useRootThread(): ThreadAnnotationsResult {
const route = store.route();
const selectionState = store.selectionState();
const filters = store.getFilterValues();
// This logic mirrors code in `SidebarView`. It can be simplified once
// the "search_panel" feature is turned on everywhere.
const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const hasAppliedFilter =
store.hasAppliedFilter() || store.hasSelectedAnnotations();
const showTabs =
route === 'sidebar' && (searchPanelEnabled || !hasAppliedFilter);
const showTabs = route === 'sidebar';
const threadState = useMemo((): ThreadState => {
const selection = { ...selectionState, filterQuery: query, filters };
......
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;
/** Currently-applied filter query string, if any */
filterQuery: string | null;
/** Display name for the user currently focused, if any */
focusDisplayName?: string | null;
/**
* The number of items that match the current filter(s). When searching by
* query or 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,
filterQuery,
focusDisplayName,
resultCount,
}: FilterStatusMessageProps) {
return (
<>
{resultCount > 0 && <span>Showing </span>}
<span className="whitespace-nowrap font-bold">
{resultCount > 0 ? resultCount : 'No'}{' '}
{resultCount === 1 ? entitySingular : entityPlural}
</span>
{filterQuery && (
<span>
{' '}
for <span className="break-words">{`'${filterQuery}'`}</span>
</span>
)}
{focusDisplayName && (
<span>
{' '}
by{' '}
<span className="whitespace-nowrap font-bold">
{focusDisplayName}
</span>
</span>
)}
{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 four filter modes. Exactly one is applicable at any time. In order
* of precedence:
*
* 1. selection
* One or more annotations is "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. query
* A search query filter is applied
*
* Message formatting:
* "[Showing] (No|<resultCount>) result[s] for '<filterQuery>'
* by <focusDisplayName] [\(and <additionalCount> more\)]"
* Button:
* "<cancel icon> Clear search" - Clears the search query
*
* 3. 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)
*
* 4. 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 FilterStatus() {
const store = useSidebarStore();
const { rootThread } = useRootThread();
const annotationCount = store.annotationCount();
const directLinkedId = store.directLinkedAnnotationId();
const filterQuery = store.filterQuery();
const focusState = store.focusState();
const forcedVisibleCount = store.forcedVisibleThreads().length;
const selectedCount = store.selectedAnnotations().length;
const filterMode = useMemo(() => {
if (selectedCount > 0) {
return 'selection';
} else if (filterQuery) {
return 'query';
} else if (focusState.configured) {
return 'focus';
}
return null;
}, [selectedCount, filterQuery, 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';
}
return focusState.active
? 'Show all'
: `Show only ${focusState.displayName}`;
}
return 'Clear search';
}, [
annotationCount,
directLinkedId,
focusState,
filterMode,
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-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 `filterQuery` strings. See
// https://css-tricks.com/flexbox-truncated-text/
'grow min-w-[0]',
)}
role="status"
>
{filterMode && (
<FilterStatusMessage
additionalCount={additionalCount}
entitySingular={
filterMode === 'query' ? 'result' : 'annotation'
}
entityPlural={
filterMode === 'query' ? 'results' : 'annotations'
}
filterQuery={filterQuery}
focusDisplayName={
filterMode !== 'selection' && focusState.active
? focusState.displayName
: ''
}
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 {
IconButton,
Input,
SearchIcon,
Spinner,
} from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import type { RefObject } from 'preact';
import { useCallback, useRef, useState } from 'preact/hooks';
import { useShortcut } from '../../../shared/shortcut';
import { isMacOS } from '../../../shared/user-agent';
import { useSidebarStore } from '../../store';
/**
* Respond to keydown events on the document (shortcut keys):
*
* - Focus the search input when the user presses '/', unless the user is
* currently typing in or focused on an input field.
* - Focus the search input when the user presses CMD-K (MacOS) or CTRL-K
* (everyone else)
* - Restore previous focus when the user presses 'Escape' while the search
* input is focused.
*/
function useSearchKeyboardShortcuts(
searchInputRef: RefObject<HTMLInputElement>,
) {
const prevFocusRef = useRef<HTMLOrSVGElement | null>(null);
const focusSearch = useCallback(
(event: KeyboardEvent) => {
// When user is in an input field, respond to CMD-/CTRL-K keypresses,
// but ignore '/' keypresses
if (
!event.metaKey &&
!event.ctrlKey &&
event.target instanceof HTMLElement &&
['INPUT', 'TEXTAREA'].includes(event.target.tagName)
) {
return;
}
prevFocusRef.current = document.activeElement as HTMLOrSVGElement | null;
if (searchInputRef.current) {
searchInputRef.current?.focus();
event.preventDefault();
event.stopPropagation();
}
},
[searchInputRef],
);
const restoreFocus = useCallback(() => {
if (document.activeElement === searchInputRef.current) {
if (prevFocusRef.current) {
prevFocusRef.current.focus();
prevFocusRef.current = null;
}
searchInputRef.current?.blur();
}
}, [searchInputRef]);
const modifierKey = isMacOS() ? 'meta' : 'ctrl';
useShortcut('/', focusSearch);
useShortcut(`${modifierKey}+k`, focusSearch);
useShortcut('escape', restoreFocus);
}
export type SearchInputProps = {
/**
* When true, the input field is always shown. If false, the input field is
* only shown if the query is non-empty.
*/
alwaysExpanded?: boolean;
/** The currently-active filter query */
query: string | null;
/** Callback for when the current filter query changes */
onSearch: (value: string) => void;
};
/**
* An input field in the top bar for entering a query that filters annotations
* (in the sidebar) or searches annotations (in the stream/single annotation
* view).
*
* This component also renders a eloading spinner to indicate when the client
* is fetching for data from the API or in a "loading" state for any other
* reason.
*/
export default function SearchInput({
alwaysExpanded,
query,
onSearch,
}: SearchInputProps) {
const store = useSidebarStore();
const isLoading = store.isLoading();
const input = useRef<HTMLInputElement | null>(null);
useSearchKeyboardShortcuts(input);
// The active filter query from the previous render.
const [prevQuery, setPrevQuery] = useState(query);
// The query that the user is currently typing, but may not yet have applied.
const [pendingQuery, setPendingQuery] = useState(query);
const onSubmit = (e: Event) => {
e.preventDefault();
if (input.current?.value || prevQuery) {
// Don't set an initial empty query, but allow a later empty query to
// clear `prevQuery`
onSearch(input.current?.value ?? '');
}
};
// When the active query changes outside of this component, update the input
// field to match. This happens when clearing the current filter for example.
if (query !== prevQuery) {
setPendingQuery(query);
setPrevQuery(query);
}
const isExpanded = alwaysExpanded || query;
return (
<form
action="#"
className={classnames(
// Relative positioning allows the search input to expand without
// pushing other things in the top bar to the right when there is
// a long group name (input will slide "over" end of group name in menu)
'relative',
'flex items-center',
// Having a nearly opaque white background makes the collision with
// group names to the left a little less jarring. Full white on hover
// to fully remove the distraction.
'bg-white/90 hover:bg-white transition-colors',
)}
name="searchForm"
onSubmit={onSubmit}
>
<Input
aria-label="Search"
classes={classnames(
// This element is ordered second in the flex layout (appears to the
// right of the search icon-button) but having it first in source
// ensures it is first in keyboard tab order
'order-1',
'text-base',
{
// Borders must be turned off when input is not expanded or focused
// to ensure it has 0 dimensions
'border-0': !isExpanded,
// The goal is to have a one-pixel grey border when `isExpanded`.
// Setting it both on focus (when it will be ofuscated by the focus
// ring) and when expanded prevents any change in the input's size
// when moving between the two states.
'focus:border': true,
border: isExpanded,
},
{
// Make the input dimensionless when not expanded (or focused)
// Make the 0-padding rule `!important` so that it doesn't get
// superseded by `Input` padding
'max-w-0 !p-0': !isExpanded,
// However, if the input it focused, it is visually expanded, and
// needs that padding back
'focus:!p-1.5': true,
// Make the input have dimensions and padding when focused or
// expanded. The left-margin is to make room for the focus ring of
// the search icon-button when navigating by keyboard. Set a
// max-width to allow transition to work when exact width is unknown.
'focus:max-w-[150px] focus:ml-[2px]': true,
'max-w-[150px] p-1.5 ml-[2px]': isExpanded,
},
'transition-[max-width] duration-300 ease-out',
)}
data-testid="search-input"
dir="auto"
type="text"
name="query"
placeholder={(isLoading && 'Loading…') || 'Search…'}
disabled={isLoading}
elementRef={input}
value={pendingQuery || ''}
onInput={(e: Event) =>
setPendingQuery((e.target as HTMLInputElement).value)
}
/>
{!isLoading && (
<div className="order-0">
<IconButton
icon={SearchIcon}
onClick={() => input.current?.focus()}
title="Search annotations"
// The containing form has a white background. The top bar is only
// 40px high. If we allow standard touch-minimum height here (44px),
// the visible white background exceeds the height of the top bar in
// touch contexts. Disable touch sizing via `size="custom"`, then
// add back the width rule and padding to keep horizontal spacing
// consistent.
size="custom"
classes="touch:min-w-touch-minimum p-1"
/>
</div>
)}
{isLoading && <Spinner />}
</form>
);
}
import { mockImportedComponents } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import FilterStatus, { $imports } from '../FilterStatus';
function getFocusState() {
return {
active: false,
configured: false,
focusDisplayName: '',
};
}
describe('FilterStatus', () => {
let fakeStore;
let fakeUseRootThread;
let fakeThreadUtil;
const createComponent = () => {
return mount(<FilterStatus />);
};
beforeEach(() => {
fakeThreadUtil = {
countVisible: sinon.stub().returns(0),
};
fakeStore = {
annotationCount: sinon.stub(),
clearSelection: sinon.stub(),
directLinkedAnnotationId: sinon.stub(),
filterQuery: sinon.stub().returns(null),
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);
}
function assertClearButton(wrapper) {
const button = wrapper.find('Button[data-testid="clear-button"]');
assert.equal(button.text(), 'Clear search');
assert.isTrue(button.find('CancelIcon').exists());
button.props().onClick();
assert.calledOnce(fakeStore.clearSelection);
}
context('Loading', () => {
it('shows a loading spinner', () => {
fakeStore.filterQuery.returns('foobar');
fakeStore.isLoading.returns(true);
const wrapper = createComponent();
assert.isTrue(wrapper.find('Spinner').exists());
});
});
context('(State 1): 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('(State 2): filtered by query', () => {
beforeEach(() => {
fakeStore.filterQuery.returns('foobar');
fakeThreadUtil.countVisible.returns(1);
});
it('should provide a "Clear search" button that clears the selection', () => {
assertClearButton(createComponent());
});
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('(State 3): 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' (and 3 more)",
);
});
it('should provide a "Clear search" button that clears the selection', () => {
assertClearButton(createComponent());
});
});
context('(State 4): 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('(State 5): 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('(State 6): user-focus mode active, filtered by query', () => {
beforeEach(() => {
fakeStore.focusState.returns({
active: true,
configured: true,
displayName: 'Ebenezer Studentolog',
});
fakeStore.filterQuery.returns('biscuits');
fakeThreadUtil.countVisible.returns(1);
});
it('should show a count of annotations by the focused user', () => {
assertFilterText(
createComponent(),
"Showing 1 result for 'biscuits' by Ebenezer Studentolog",
);
});
it('should pluralize annotations when needed', () => {
fakeThreadUtil.countVisible.returns(3);
assertFilterText(
createComponent(),
"Showing 3 results for 'biscuits' by Ebenezer Studentolog",
);
});
it('should show a no results message when user has no annotations', () => {
fakeThreadUtil.countVisible.returns(0);
assertFilterText(
createComponent(),
"No results for 'biscuits' by Ebenezer Studentolog",
);
});
it('should provide a "Clear search" button', () => {
assertClearButton(createComponent());
});
});
context(
'(State 7): user-focus mode active, filtered by query, force-expanded threads',
() => {
beforeEach(() => {
fakeStore.focusState.returns({
active: true,
configured: true,
displayName: 'Ebenezer Studentolog',
});
fakeStore.filterQuery.returns('biscuits');
fakeStore.forcedVisibleThreads.returns([1, 2]);
fakeThreadUtil.countVisible.returns(3);
});
it('should show a count of annotations by the focused user', () => {
assertFilterText(
createComponent(),
"Showing 1 result for 'biscuits' by Ebenezer Studentolog (and 2 more)",
);
});
it('should provide a "Clear search" button', () => {
assertClearButton(createComponent());
});
},
);
context('(State 8): 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('(State 9): user-focus mode active, force-expanded threads', () => {
beforeEach(() => {
fakeStore.focusState.returns({
active: true,
configured: true,
displayName: 'Ebenezer Studentolog',
});
fakeStore.forcedVisibleThreads.returns([1, 2, 3]);
fakeThreadUtil.countVisible.returns(7);
});
it('should show count of user results separately from forced-visible threads', () => {
assertFilterText(
createComponent(),
'Showing 4 annotations by Ebenezer Studentolog (and 3 more)',
);
});
it('should handle cases when there are no focused-user annotations', () => {
fakeStore.forcedVisibleThreads.returns([1, 2, 3, 4, 5, 6, 7]);
assertFilterText(
createComponent(),
'No annotations by Ebenezer Studentolog (and 7 more)',
);
});
it('should provide a "Reset filters" button', () => {
assertButton(createComponent(), {
text: 'Reset filters',
icon: false,
callback: fakeStore.clearSelection,
});
});
});
context('(State 10): 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,
});
});
});
});
import {
checkAccessibility,
mockImportedComponents,
} from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import SearchInput from '../SearchInput';
import { $imports } from '../SearchInput';
describe('SearchInput', () => {
let fakeIsMacOS;
let fakeStore;
let container;
let wrappers;
const createSearchInput = (props = {}) => {
const wrapper = mount(<SearchInput {...props} />, { attachTo: container });
wrappers.push(wrapper);
return wrapper;
};
function typeQuery(wrapper, query) {
const input = wrapper.find('input');
input.getDOMNode().value = query;
input.simulate('input');
}
beforeEach(() => {
wrappers = [];
container = document.createElement('div');
document.body.appendChild(container);
fakeIsMacOS = sinon.stub().returns(false);
fakeStore = { isLoading: sinon.stub().returns(false) };
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../store': { useSidebarStore: () => fakeStore },
'../../../shared/user-agent': {
isMacOS: fakeIsMacOS,
},
});
});
afterEach(() => {
wrappers.forEach(wrapper => wrapper.unmount());
container.remove();
$imports.$restore();
});
it('displays the active query', () => {
const wrapper = createSearchInput({ query: 'foo' });
assert.equal(wrapper.find('input').prop('value'), 'foo');
});
it('resets input field value to active query when active query changes', () => {
const wrapper = createSearchInput({ query: 'foo' });
// Simulate user editing the pending query, but not committing it.
typeQuery(wrapper, 'pending-query');
// Check that the pending query is displayed.
assert.equal(wrapper.find('input').prop('value'), 'pending-query');
// Simulate active query being reset.
wrapper.setProps({ query: '' });
assert.equal(wrapper.find('input').prop('value'), '');
});
it('invokes `onSearch` with pending query when form is submitted', () => {
const onSearch = sinon.stub();
const wrapper = createSearchInput({ query: 'foo', onSearch });
typeQuery(wrapper, 'new-query');
wrapper.find('form').simulate('submit');
assert.calledWith(onSearch, 'new-query');
});
it('does not set an initial empty query when form is submitted', () => {
// If the first query entered is empty, it will be ignored
const onSearch = sinon.stub();
const wrapper = createSearchInput({ onSearch });
typeQuery(wrapper, '');
wrapper.find('form').simulate('submit');
assert.notCalled(onSearch);
});
it('sets subsequent empty queries if entered', () => {
// If there has already been at least one query set, subsequent
// empty queries will be honored
const onSearch = sinon.stub();
const wrapper = createSearchInput({ query: 'foo', onSearch });
typeQuery(wrapper, '');
wrapper.find('form').simulate('submit');
assert.calledWith(onSearch, '');
});
it('renders loading indicator when app is in a "loading" state', () => {
fakeStore.isLoading.returns(true);
const wrapper = createSearchInput();
assert.isTrue(wrapper.exists('Spinner'));
});
it('doesn\'t render search button when app is in "loading" state', () => {
fakeStore.isLoading.returns(true);
const wrapper = createSearchInput();
assert.isFalse(wrapper.exists('button'));
});
it('doesn\'t render loading indicator when app is not in "loading" state', () => {
fakeStore.isLoading.returns(false);
const wrapper = createSearchInput();
assert.isFalse(wrapper.exists('Spinner'));
});
it('renders search button when app is not in "loading" state', () => {
fakeStore.isLoading.returns(false);
const wrapper = createSearchInput();
assert.isTrue(wrapper.exists('IconButton'));
});
it('focuses search input when button pressed', () => {
fakeStore.isLoading.returns(false);
const wrapper = createSearchInput();
const inputEl = wrapper.find('input').getDOMNode();
wrapper.find('IconButton').props().onClick();
assert.equal(document.activeElement, inputEl);
});
describe('shortcut key handling', () => {
it('focuses search input when "/" is pressed outside of the component element', () => {
const wrapper = createSearchInput();
const searchInputEl = wrapper.find('input').getDOMNode();
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: '/',
}),
);
assert.equal(document.activeElement, searchInputEl);
});
it('focuses search input for non-Mac OSes when "ctrl-K" is pressed outside of the component element', () => {
fakeIsMacOS.returns(false);
const wrapper = createSearchInput();
const searchInputEl = wrapper.find('input').getDOMNode();
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'K',
ctrlKey: true,
}),
);
assert.equal(document.activeElement, searchInputEl);
});
it('focuses search input for Mac OS when "Cmd-K" is pressed outside of the component element', () => {
fakeIsMacOS.returns(true);
const wrapper = createSearchInput();
const searchInputEl = wrapper.find('input').getDOMNode();
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'K',
metaKey: true,
}),
);
assert.equal(document.activeElement, searchInputEl);
});
it('restores previous focus when focused and "Escape" key pressed', () => {
const button = document.createElement('button');
button.id = 'a-button';
container.append(button);
const wrapper = createSearchInput();
const inputEl = wrapper.find('input').getDOMNode();
const buttonEl = document.querySelector('#a-button');
buttonEl.focus();
buttonEl.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: '/',
}),
);
assert.equal(
document.activeElement,
inputEl,
'focus is moved from button to input',
);
inputEl.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
}),
);
assert.equal(
document.activeElement,
buttonEl,
'focus has been restored to the button',
);
});
['textarea', 'input'].forEach(elementName => {
it('does not steal focus when "/" pressed if user is in an input field', () => {
const input = document.createElement(elementName);
input.id = 'an-input';
container.append(input);
createSearchInput();
const inputEl = document.querySelector('#an-input');
inputEl.focus();
assert.equal(document.activeElement, inputEl);
inputEl.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: '/',
}),
);
assert.equal(
document.activeElement,
inputEl,
'focus has not been moved to search input',
);
});
});
it('focuses search input if user is in an input field and presses "Ctrl-k"', () => {
fakeIsMacOS.returns(false);
const input = document.createElement('input');
input.id = 'an-input';
container.append(input);
const wrapper = createSearchInput();
const inputEl = document.querySelector('#an-input');
const searchInputEl = wrapper
.find('[data-testid="search-input"]')
.at(0)
.getDOMNode();
inputEl.focus();
assert.equal(document.activeElement, inputEl);
inputEl.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'k',
ctrlKey: true,
}),
);
assert.equal(
document.activeElement,
searchInputEl,
'focus has been moved to search input',
);
});
});
it(
'should pass a11y checks',
checkAccessibility([
{
content: () => createSearchInput(),
},
{
name: 'loading state',
content: () => {
fakeStore.isLoading.returns(true);
return createSearchInput();
},
},
]),
);
});
import { withServices } from '../../service-context';
import type { RouterService } from '../../services/router';
import { useSidebarStore } from '../../store';
import SearchInput from '../old-search/SearchInput';
import SearchField from './SearchField';
export type StreamSearchInputProps = {
......@@ -15,7 +14,6 @@ export type StreamSearchInputProps = {
*/
function StreamSearchInput({ router }: StreamSearchInputProps) {
const store = useSidebarStore();
const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const { q } = store.routeParams();
const setQuery = (query: string) => {
// Re-route the user to `/stream` if they are on `/a/:id` and then set
......@@ -23,14 +21,12 @@ function StreamSearchInput({ router }: StreamSearchInputProps) {
router.navigate('stream', { q: query });
};
return searchPanelEnabled ? (
return (
<SearchField
query={q ?? ''}
onSearch={setQuery}
onClearSearch={() => setQuery('')}
/>
) : (
<SearchInput query={q ?? ''} onSearch={setQuery} alwaysExpanded />
);
}
......
......@@ -14,7 +14,6 @@ describe('StreamSearchInput', () => {
};
fakeStore = {
routeParams: sinon.stub().returns({}),
isFeatureEnabled: sinon.stub().returns(false),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
......@@ -33,20 +32,18 @@ describe('StreamSearchInput', () => {
it('displays current "q" search param', () => {
fakeStore.routeParams.returns({ q: 'the-query' });
const wrapper = createSearchInput();
assert.equal(wrapper.find('SearchInput').prop('query'), 'the-query');
assert.equal(wrapper.find('SearchField').prop('query'), 'the-query');
});
it('sets path and query when user searches', () => {
const wrapper = createSearchInput();
act(() => {
wrapper.find('SearchInput').props().onSearch('new-query');
wrapper.find('SearchField').props().onSearch('new-query');
});
assert.calledWith(fakeRouter.navigate, 'stream', { q: 'new-query' });
});
it('renders new SearchField when search panel feature is enabled', () => {
fakeStore.isFeatureEnabled.returns(true);
const wrapper = createSearchInput();
assert.isFalse(wrapper.exists('SearchInput'));
......@@ -54,7 +51,6 @@ describe('StreamSearchInput', () => {
});
it('clears filter when clear button is clicked', () => {
fakeStore.isFeatureEnabled.returns(true);
const wrapper = createSearchInput();
act(() => {
wrapper.find('SearchField').props().onClearSearch();
......
......@@ -54,7 +54,6 @@ describe('HypothesisApp', () => {
route: sinon.stub().returns('sidebar'),
getLink: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(true),
};
fakeAuth = {};
......@@ -407,16 +406,4 @@ describe('HypothesisApp', () => {
assert.isFalse(container.hasClass('theme-clean'));
});
});
describe('search panel', () => {
[true, false].forEach(searchPanelEnabled => {
it('renders SearchPanel when feature is enabled', () => {
fakeStore.isFeatureEnabled.returns(searchPanelEnabled);
const wrapper = createComponent();
assert.equal(wrapper.exists('SearchPanel'), searchPanelEnabled);
});
});
});
});
......@@ -70,7 +70,6 @@ describe('SidebarView', () => {
profile: sinon.stub().returns({ userid: null }),
searchUris: sinon.stub().returns([]),
toggleFocusMode: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(false),
};
fakeTabsUtil = {
......@@ -215,7 +214,7 @@ describe('SidebarView', () => {
it('does not render filter status', () => {
const wrapper = createComponent();
assert.isFalse(wrapper.find('FilterStatus').exists());
assert.isFalse(wrapper.find('FilterControls').exists());
});
});
});
......@@ -244,19 +243,11 @@ describe('SidebarView', () => {
it('does not render filter status', () => {
const wrapper = createComponent();
assert.isFalse(wrapper.find('FilterStatus').exists());
assert.isFalse(wrapper.find('FilterControls').exists());
});
});
describe('filter controls', () => {
it('renders old filter controls when `search_panel` feature is disabled', () => {
fakeStore.isFeatureEnabled.withArgs('search_panel').returns(false);
const wrapper = createComponent();
assert.isTrue(wrapper.exists('FilterStatus'));
assert.isFalse(wrapper.exists('FilterControls'));
});
[
{
searchPanelOpen: false,
......@@ -267,15 +258,13 @@ describe('SidebarView', () => {
showControls: false,
},
].forEach(({ searchPanelOpen, showControls }) => {
it(`renders new filter controls when "search_panel" is enabled and search panel is not open`, () => {
fakeStore.isFeatureEnabled.withArgs('search_panel').returns(true);
it(`renders filter controls when search panel is not open`, () => {
fakeStore.isSidebarPanelOpen
.withArgs('searchAnnotations')
.returns(searchPanelOpen);
const wrapper = createComponent();
assert.isFalse(wrapper.exists('FilterStatus'));
assert.equal(wrapper.exists('FilterControls'), showControls);
});
});
......@@ -299,30 +288,6 @@ describe('SidebarView', () => {
});
});
describe('selection tabs', () => {
it('renders tabs', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.find('SelectionTabs').exists());
});
it('does not render tabs if there is an applied filter', () => {
fakeStore.hasAppliedFilter.returns(true);
const wrapper = createComponent();
assert.isFalse(wrapper.find('SelectionTabs').exists());
});
it('does not render tabs if there are selected annotations', () => {
fakeStore.hasSelectedAnnotations.returns(true);
const wrapper = createComponent();
assert.isFalse(wrapper.find('SelectionTabs').exists());
});
});
it(
'should pass a11y checks',
checkAccessibility({
......
......@@ -15,13 +15,10 @@ describe('TopBar', () => {
beforeEach(() => {
fakeStore = {
filterQuery: sinon.stub().returns(null),
hasFetchedProfile: sinon.stub().returns(false),
isLoggedIn: sinon.stub().returns(false),
isSidebarPanelOpen: sinon.stub().returns(false),
setFilterQuery: sinon.stub(),
toggleSidebarPanel: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(false),
};
fakeFrameSync = {
......@@ -177,41 +174,22 @@ describe('TopBar', () => {
assert.isTrue(shareButton.prop('expanded'));
});
it('displays search input in the sidebar', () => {
fakeStore.filterQuery.returns('test-query');
const wrapper = createTopBar();
assert.equal(wrapper.find('SearchInput').prop('query'), 'test-query');
});
it('updates current filter when changing search query in the sidebar', () => {
const wrapper = createTopBar();
wrapper.find('SearchInput').prop('onSearch')('new-query');
assert.calledWith(fakeStore.setFilterQuery, 'new-query');
});
it('displays search input in the single annotation view / stream', () => {
const wrapper = createTopBar({ isSidebar: false });
const searchInput = wrapper.find('StreamSearchInput');
assert.ok(searchInput.exists());
});
context('in the stream and single annotation pages', () => {
it('does not render the group list, sort menu or share menu', () => {
const wrapper = createTopBar({ isSidebar: false });
assert.isFalse(wrapper.exists('GroupList'));
assert.isFalse(wrapper.exists('SortMenu'));
assert.isFalse(wrapper.exists('button[title="Share this page"]'));
});
});
context('when sidebar panel feature is enabled', () => {
it('displays search input in the sidebar', () => {
fakeStore.isFeatureEnabled.returns(true);
const wrapper = createTopBar();
assert.isFalse(wrapper.exists('SearchInput'));
assert.isTrue(wrapper.exists('SearchIconButton'));
[true, false].forEach(isSidebar => {
it('renders certain controls only in the sidebar', () => {
const wrapper = createTopBar({ isSidebar });
assert.equal(wrapper.exists('GroupList'), isSidebar);
assert.equal(wrapper.exists('SortMenu'), isSidebar);
assert.equal(wrapper.exists('SearchIconButton'), isSidebar);
assert.equal(
wrapper.exists('button[data-testid="share-icon-button"]'),
isSidebar,
);
});
});
......
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