Commit 400d3409 authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Add support for search sidebar panel via feature flag

parent 652bcfa0
...@@ -22,6 +22,7 @@ import SidebarView from './SidebarView'; ...@@ -22,6 +22,7 @@ import SidebarView from './SidebarView';
import StreamView from './StreamView'; import StreamView from './StreamView';
import ToastMessages from './ToastMessages'; import ToastMessages from './ToastMessages';
import TopBar from './TopBar'; import TopBar from './TopBar';
import SearchPanel from './search/SearchPanel';
export type HypothesisAppProps = { export type HypothesisAppProps = {
auth: AuthService; auth: AuthService;
...@@ -69,6 +70,8 @@ function HypothesisApp({ ...@@ -69,6 +70,8 @@ function HypothesisApp({
const showShareButton = const showShareButton =
!isThirdParty || exportAnnotations || importAnnotations; !isThirdParty || exportAnnotations || importAnnotations;
const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const login = async () => { const login = async () => {
if (serviceConfig(settings)) { if (serviceConfig(settings)) {
// Let the host page handle the login request // Let the host page handle the login request
...@@ -168,6 +171,7 @@ function HypothesisApp({ ...@@ -168,6 +171,7 @@ function HypothesisApp({
<div className="container"> <div className="container">
<ToastMessages /> <ToastMessages />
<HelpPanel /> <HelpPanel />
{searchPanelEnabled && <SearchPanel />}
{showShareButton && ( {showShareButton && (
<ShareDialog <ShareDialog
shareTab={!isThirdParty} shareTab={!isThirdParty}
......
import type { DialogProps } from '@hypothesis/frontend-shared';
import { Dialog, Slider } from '@hypothesis/frontend-shared'; import { Dialog, Slider } from '@hypothesis/frontend-shared';
import type { IconComponent } from '@hypothesis/frontend-shared/lib/types'; import type { IconComponent } from '@hypothesis/frontend-shared/lib/types';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
...@@ -22,6 +23,7 @@ export type SidebarPanelProps = { ...@@ -22,6 +23,7 @@ export type SidebarPanelProps = {
onActiveChanged?: (active: boolean) => void; onActiveChanged?: (active: boolean) => void;
/** What Dialog variant to use */ /** What Dialog variant to use */
variant?: 'panel' | 'custom'; variant?: 'panel' | 'custom';
initialFocus?: DialogProps['initialFocus'];
}; };
/** /**
...@@ -35,6 +37,7 @@ export default function SidebarPanel({ ...@@ -35,6 +37,7 @@ export default function SidebarPanel({
title, title,
variant = 'panel', variant = 'panel',
onActiveChanged, onActiveChanged,
initialFocus,
}: SidebarPanelProps) { }: SidebarPanelProps) {
const store = useSidebarStore(); const store = useSidebarStore();
const panelIsActive = store.isSidebarPanelOpen(panelName); const panelIsActive = store.isSidebarPanelOpen(panelName);
...@@ -61,6 +64,7 @@ export default function SidebarPanel({ ...@@ -61,6 +64,7 @@ export default function SidebarPanel({
<> <>
{panelIsActive && ( {panelIsActive && (
<Dialog <Dialog
initialFocus={initialFocus}
restoreFocus restoreFocus
ref={panelElement} ref={panelElement}
classes="mb-4" classes="mb-4"
......
...@@ -6,13 +6,14 @@ import type { FrameSyncService } from '../services/frame-sync'; ...@@ -6,13 +6,14 @@ import type { FrameSyncService } from '../services/frame-sync';
import type { LoadAnnotationsService } from '../services/load-annotations'; import type { LoadAnnotationsService } from '../services/load-annotations';
import type { StreamerService } from '../services/streamer'; import type { StreamerService } from '../services/streamer';
import { useSidebarStore } from '../store'; import { useSidebarStore } from '../store';
import FilterStatus from './FilterStatus';
import LoggedOutMessage from './LoggedOutMessage'; import LoggedOutMessage from './LoggedOutMessage';
import LoginPromptPanel from './LoginPromptPanel'; import LoginPromptPanel from './LoginPromptPanel';
import SelectionTabs from './SelectionTabs'; import SelectionTabs from './SelectionTabs';
import SidebarContentError from './SidebarContentError'; import SidebarContentError from './SidebarContentError';
import ThreadList from './ThreadList'; import ThreadList from './ThreadList';
import { useRootThread } from './hooks/use-root-thread'; import { useRootThread } from './hooks/use-root-thread';
import FilterStatus from './old-search/FilterStatus';
import FilterAnnotationsStatus from './search/FilterAnnotationsStatus';
export type SidebarViewProps = { export type SidebarViewProps = {
onLogin: () => void; onLogin: () => void;
...@@ -66,7 +67,8 @@ function SidebarView({ ...@@ -66,7 +67,8 @@ function SidebarView({
const hasContentError = const hasContentError =
hasDirectLinkedAnnotationError || hasDirectLinkedGroupError; hasDirectLinkedAnnotationError || hasDirectLinkedGroupError;
const showFilterStatus = !hasContentError; const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const showFilterStatus = !hasContentError && !searchPanelEnabled;
const showTabs = !hasContentError && !hasAppliedFilter; const showTabs = !hasContentError && !hasAppliedFilter;
// Show a CTA to log in if successfully viewing a direct-linked annotation // Show a CTA to log in if successfully viewing a direct-linked annotation
...@@ -132,6 +134,7 @@ function SidebarView({ ...@@ -132,6 +134,7 @@ function SidebarView({
<div> <div>
<h2 className="sr-only">Annotations</h2> <h2 className="sr-only">Annotations</h2>
{showFilterStatus && <FilterStatus />} {showFilterStatus && <FilterStatus />}
{searchPanelEnabled && <FilterAnnotationsStatus />}
<LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} /> <LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} />
{hasDirectLinkedAnnotationError && ( {hasDirectLinkedAnnotationError && (
<SidebarContentError <SidebarContentError
......
...@@ -10,10 +10,11 @@ import { useSidebarStore } from '../store'; ...@@ -10,10 +10,11 @@ import { useSidebarStore } from '../store';
import GroupList from './GroupList'; import GroupList from './GroupList';
import PendingUpdatesButton from './PendingUpdatesButton'; import PendingUpdatesButton from './PendingUpdatesButton';
import PressableIconButton from './PressableIconButton'; import PressableIconButton from './PressableIconButton';
import SearchInput from './SearchInput';
import SortMenu from './SortMenu'; import SortMenu from './SortMenu';
import StreamSearchInput from './StreamSearchInput';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import SearchInput from './old-search/SearchInput';
import SearchIconButton from './search/SearchIconButton';
import StreamSearchInput from './search/StreamSearchInput';
export type TopBarProps = { export type TopBarProps = {
showShareButton: boolean; showShareButton: boolean;
...@@ -54,6 +55,7 @@ function TopBar({ ...@@ -54,6 +55,7 @@ function TopBar({
const filterQuery = store.filterQuery(); const filterQuery = store.filterQuery();
const isLoggedIn = store.isLoggedIn(); const isLoggedIn = store.isLoggedIn();
const hasFetchedProfile = store.hasFetchedProfile(); const hasFetchedProfile = store.hasFetchedProfile();
const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const toggleSharePanel = () => { const toggleSharePanel = () => {
store.toggleSidebarPanel('shareGroupAnnotations'); store.toggleSidebarPanel('shareGroupAnnotations');
...@@ -98,10 +100,13 @@ function TopBar({ ...@@ -98,10 +100,13 @@ function TopBar({
{isSidebar && ( {isSidebar && (
<> <>
<PendingUpdatesButton /> <PendingUpdatesButton />
<SearchInput {!searchPanelEnabled && (
query={filterQuery || null} <SearchInput
onSearch={store.setFilterQuery} query={filterQuery || null}
/> onSearch={store.setFilterQuery}
/>
)}
{searchPanelEnabled && <SearchIconButton />}
<SortMenu /> <SortMenu />
{showShareButton && ( {showShareButton && (
<PressableIconButton <PressableIconButton
......
...@@ -8,9 +8,9 @@ import { ...@@ -8,9 +8,9 @@ import {
import classnames from 'classnames'; import classnames from 'classnames';
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { countVisible } from '../helpers/thread'; import { countVisible } from '../../helpers/thread';
import { useSidebarStore } from '../store'; import { useSidebarStore } from '../../store';
import { useRootThread } from './hooks/use-root-thread'; import { useRootThread } from '../hooks/use-root-thread';
type FilterStatusMessageProps = { type FilterStatusMessageProps = {
/** /**
......
...@@ -8,9 +8,9 @@ import classnames from 'classnames'; ...@@ -8,9 +8,9 @@ import classnames from 'classnames';
import type { RefObject } from 'preact'; import type { RefObject } from 'preact';
import { useCallback, useRef, useState } from 'preact/hooks'; import { useCallback, useRef, useState } from 'preact/hooks';
import { useShortcut } from '../../shared/shortcut'; import { useShortcut } from '../../../shared/shortcut';
import { isMacOS } from '../../shared/user-agent'; import { isMacOS } from '../../../shared/user-agent';
import { useSidebarStore } from '../store'; import { useSidebarStore } from '../../store';
/** /**
* Respond to keydown events on the document (shortcut keys): * Respond to keydown events on the document (shortcut keys):
......
...@@ -40,9 +40,9 @@ describe('FilterStatus', () => { ...@@ -40,9 +40,9 @@ describe('FilterStatus', () => {
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
'./hooks/use-root-thread': { useRootThread: fakeUseRootThread }, '../hooks/use-root-thread': { useRootThread: fakeUseRootThread },
'../store': { useSidebarStore: () => fakeStore }, '../../store': { useSidebarStore: () => fakeStore },
'../helpers/thread': fakeThreadUtil, '../../helpers/thread': fakeThreadUtil,
}); });
}); });
......
...@@ -34,8 +34,8 @@ describe('SearchInput', () => { ...@@ -34,8 +34,8 @@ describe('SearchInput', () => {
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
'../store': { useSidebarStore: () => fakeStore }, '../../store': { useSidebarStore: () => fakeStore },
'../../shared/user-agent': { '../../../shared/user-agent': {
isMacOS: fakeIsMacOS, isMacOS: fakeIsMacOS,
}, },
}); });
......
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;
/** Display name for the user currently focused, if any */
focusDisplayName?: 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,
focusDisplayName,
resultCount,
}: FilterStatusMessageProps) {
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>
)}
{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';
}
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 strings.
// See https://css-tricks.com/flexbox-truncated-text/
'grow min-w-[0]',
)}
role="status"
>
{filterMode && (
<FilterStatusMessage
additionalCount={additionalCount}
entitySingular="annotation"
entityPlural="annotations"
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,
InputGroup,
SearchIcon,
useSyncedRef,
} from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import type { RefObject, JSX } from 'preact';
import { useState } from 'preact/hooks';
import { useShortcut } from '../../../shared/shortcut';
import { useSidebarStore } from '../../store';
export type SearchFieldProps = {
/** The currently-active filter query */
query: string | null;
/** Callback for when the current filter query changes */
onSearch: (value: string) => void;
/** Callback for when a key is pressed in the input itself */
onKeyDown?: JSX.KeyboardEventHandler<HTMLInputElement>;
/** The input's ref object, in case it needs to be handled by consumers */
inputRef?: RefObject<HTMLInputElement | undefined>;
/** Classes to be added to the outermost element */
classes?: string | string[];
};
/**
* An input field for entering a query that filters annotations (in the sidebar)
* or searches annotations (in the stream/single annotation view).
*/
export default function SearchField({
query,
onSearch,
inputRef,
classes,
onKeyDown,
}: SearchFieldProps) {
const store = useSidebarStore();
const isLoading = store.isLoading();
const input = useSyncedRef(inputRef);
// 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);
// As long as this input is mounted, pressing `/` should make it recover focus
useShortcut('/', e => {
if (document.activeElement !== input.current) {
e.preventDefault();
input.current?.focus();
}
});
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);
}
return (
<form
name="searchForm"
onSubmit={onSubmit}
className={classnames('space-y-3', classes)}
>
<InputGroup>
<Input
aria-label="Search annotations"
classes={classnames(
'text-base p-1.5',
'transition-[max-width] duration-300 ease-out',
)}
data-testid="search-input"
dir="auto"
name="query"
placeholder={(isLoading && 'Loading…') || 'Search annotations…'}
disabled={isLoading}
elementRef={input}
value={pendingQuery || ''}
onInput={(e: Event) =>
setPendingQuery((e.target as HTMLInputElement).value)
}
onKeyDown={onKeyDown}
/>
<IconButton
icon={SearchIcon}
title="Search"
type="submit"
variant="dark"
/>
</InputGroup>
</form>
);
}
import { SearchIcon, Spinner } from '@hypothesis/frontend-shared';
import { useCallback, useRef } from 'preact/hooks';
import { useShortcut } from '../../../shared/shortcut';
import { isMacOS } from '../../../shared/user-agent';
import type { SidebarStore } from '../../store';
import { useSidebarStore } from '../../store';
import PressableIconButton from '../PressableIconButton';
/**
* Respond to keydown events on the document (shortcut keys):
*
* - Open the search panel when the user presses '/', unless the user is
* currently typing in or focused on an input field.
* - Open the search panel when the user presses CMD-K (MacOS) or CTRL-K
* (everyone else)
*/
function useSearchKeyboardShortcuts(store: SidebarStore) {
const prevFocusRef = useRef<HTMLOrSVGElement | null>(null);
const openSearch = 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 (!store.isSidebarPanelOpen('searchAnnotations')) {
store.openSidebarPanel('searchAnnotations');
event.preventDefault();
event.stopPropagation();
}
},
[store],
);
const modifierKey = isMacOS() ? 'meta' : 'ctrl';
useShortcut('/', openSearch);
useShortcut(`${modifierKey}+k`, openSearch);
}
export default function SearchIconButton() {
const store = useSidebarStore();
const isLoading = store.isLoading();
const isSearchPanelOpen = store.isSidebarPanelOpen('searchAnnotations');
const toggleSearchPanel = useCallback(() => {
store.toggleSidebarPanel('searchAnnotations');
}, [store]);
useSearchKeyboardShortcuts(store);
return (
<>
{isLoading && <Spinner />}
{!isLoading && (
<PressableIconButton
icon={SearchIcon}
expanded={isSearchPanelOpen}
pressed={isSearchPanelOpen}
onClick={toggleSearchPanel}
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"
/>
)}
</>
);
}
import { Card, CardContent, CloseButton } from '@hypothesis/frontend-shared';
import { useRef } from 'preact/hooks';
import { useSidebarStore } from '../../store';
import SidebarPanel from '../SidebarPanel';
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);
return (
<SidebarPanel
panelName="searchAnnotations"
variant="custom"
title="Search annotations"
initialFocus={inputRef}
onActiveChanged={active => {
if (!active) {
store.clearSelection();
}
}}
>
<Card>
<CardContent>
<div className="flex gap-x-3">
<SearchField
inputRef={inputRef}
classes="grow"
query={filterQuery || null}
onSearch={store.setFilterQuery}
onKeyDown={e => {
// Close panel on Escape, which will also clear search
if (e.key === 'Escape') {
store.closeSidebarPanel('searchAnnotations');
}
}}
/>
<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 />}
</CardContent>
</Card>
</SidebarPanel>
);
}
import { Button, CancelIcon } 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';
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],
);
const buttonText = 'Clear search';
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 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>
{filterQuery && (
<Button
onClick={() => 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 */}
{filterQuery && <CancelIcon />}
{buttonText}
</Button>
)}
</div>
);
}
import { withServices } from '../service-context'; import { withServices } from '../../service-context';
import type { RouterService } from '../services/router'; import type { RouterService } from '../../services/router';
import { useSidebarStore } from '../store'; import { useSidebarStore } from '../../store';
import SearchInput from './SearchInput'; import SearchInput from '../old-search/SearchInput';
import SearchField from './SearchField';
export type StreamSearchInputProps = { export type StreamSearchInputProps = {
router: RouterService; router: RouterService;
...@@ -14,6 +15,7 @@ export type StreamSearchInputProps = { ...@@ -14,6 +15,7 @@ export type StreamSearchInputProps = {
*/ */
function StreamSearchInput({ router }: StreamSearchInputProps) { function StreamSearchInput({ router }: StreamSearchInputProps) {
const store = useSidebarStore(); const store = useSidebarStore();
const searchPanelEnabled = store.isFeatureEnabled('search_panel');
const { q } = store.routeParams(); const { q } = store.routeParams();
const setQuery = (query: string) => { const setQuery = (query: string) => {
// Re-route the user to `/stream` if they are on `/a/:id` and then set // Re-route the user to `/stream` if they are on `/a/:id` and then set
...@@ -21,8 +23,10 @@ function StreamSearchInput({ router }: StreamSearchInputProps) { ...@@ -21,8 +23,10 @@ function StreamSearchInput({ router }: StreamSearchInputProps) {
router.navigate('stream', { q: query }); router.navigate('stream', { q: query });
}; };
return ( return searchPanelEnabled ? (
<SearchInput query={q ?? ''} onSearch={setQuery} alwaysExpanded={true} /> <SearchField query={q ?? ''} onSearch={setQuery} />
) : (
<SearchInput query={q ?? ''} onSearch={setQuery} alwaysExpanded />
); );
} }
......
import {
checkAccessibility,
mockImportedComponents,
} from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import SearchField, { $imports } from '../SearchField';
describe('SearchField', () => {
let fakeStore;
let container;
let wrappers;
const createSearchField = (props = {}) => {
const wrapper = mount(<SearchField {...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);
fakeStore = { isLoading: sinon.stub().returns(false) };
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../store': { useSidebarStore: () => fakeStore },
});
});
afterEach(() => {
wrappers.forEach(wrapper => wrapper.unmount());
container.remove();
$imports.$restore();
});
it('displays the active query', () => {
const wrapper = createSearchField({ query: 'foo' });
assert.equal(wrapper.find('input').prop('value'), 'foo');
});
it('resets input field value to active query when active query changes', () => {
const wrapper = createSearchField({ 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 = createSearchField({ 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 = createSearchField({ 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 = createSearchField({ query: 'foo', onSearch });
typeQuery(wrapper, '');
wrapper.find('form').simulate('submit');
assert.calledWith(onSearch, '');
});
it('disables input when app is in a "loading" state', () => {
fakeStore.isLoading.returns(true);
const wrapper = createSearchField();
const { placeholder, disabled } = wrapper.find('Input').props();
assert.equal(placeholder, 'Loading…');
assert.isTrue(disabled);
});
it('doesn\'t disable input when app is not in "loading" state', () => {
fakeStore.isLoading.returns(false);
const wrapper = createSearchField();
const { placeholder, disabled } = wrapper.find('Input').props();
assert.equal(placeholder, 'Search annotations…');
assert.isFalse(disabled);
});
it('focuses search input when "/" is pressed outside of the component element', () => {
const wrapper = createSearchField();
const searchInputEl = wrapper.find('input').getDOMNode();
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: '/',
}),
);
assert.equal(document.activeElement, searchInputEl);
});
it(
'should pass a11y checks',
checkAccessibility([
{
content: () => createSearchField(),
},
{
name: 'loading state',
content: () => {
fakeStore.isLoading.returns(true);
return createSearchField();
},
},
]),
);
});
import { checkAccessibility } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import SearchIconButton, { $imports } from '../SearchIconButton';
describe('SearchIconButton', () => {
let fakeIsMacOS;
let fakeStore;
let container;
let wrappers;
const createSearchIconButton = () => {
const wrapper = mount(<SearchIconButton />, { attachTo: container });
wrappers.push(wrapper);
return wrapper;
};
beforeEach(() => {
wrappers = [];
container = document.createElement('div');
document.body.appendChild(container);
fakeIsMacOS = sinon.stub().returns(false);
fakeStore = {
isLoading: sinon.stub().returns(false),
isSidebarPanelOpen: sinon.stub().returns(false),
openSidebarPanel: sinon.stub(),
toggleSidebarPanel: sinon.stub(),
};
$imports.$mock({
'../../store': { useSidebarStore: () => fakeStore },
'../../../shared/user-agent': {
isMacOS: fakeIsMacOS,
},
});
});
afterEach(() => {
wrappers.forEach(wrapper => wrapper.unmount());
container.remove();
$imports.$restore();
});
it('renders loading indicator when app is in a "loading" state', () => {
fakeStore.isLoading.returns(true);
const wrapper = createSearchIconButton();
assert.isTrue(wrapper.exists('Spinner'));
assert.isFalse(wrapper.exists('PressableIconButton'));
});
it('renders search button when app is not in "loading" state', () => {
fakeStore.isLoading.returns(false);
const wrapper = createSearchIconButton();
assert.isFalse(wrapper.exists('Spinner'));
assert.isTrue(wrapper.exists('PressableIconButton'));
});
it('toggles search panel when button is clicked', () => {
const wrapper = createSearchIconButton();
wrapper.find('PressableIconButton').find('button').simulate('click');
assert.calledWith(fakeStore.toggleSidebarPanel, 'searchAnnotations');
});
[true, false].forEach(isSearchPanelOpen => {
it('sets button state based on panel state', () => {
fakeStore.isSidebarPanelOpen.returns(isSearchPanelOpen);
const wrapper = createSearchIconButton();
const { expanded, pressed } = wrapper.find('PressableIconButton').props();
assert.equal(expanded, isSearchPanelOpen);
assert.equal(pressed, isSearchPanelOpen);
});
});
describe('shortcut key handling', () => {
context('when "/" is pressed outside of the component element', () => {
const pressForwardSlashKey = () =>
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: '/',
}),
);
it('opens search panel if it is closed', () => {
createSearchIconButton();
pressForwardSlashKey();
assert.calledWith(fakeStore.openSidebarPanel, 'searchAnnotations');
});
it('does nothing if search panel is already open', () => {
fakeStore.isSidebarPanelOpen.returns(true);
createSearchIconButton();
pressForwardSlashKey();
assert.notCalled(fakeStore.openSidebarPanel);
});
});
it('opens search panel on non-macOS systems when "ctrl-K" is pressed outside of the component element', () => {
fakeIsMacOS.returns(false);
createSearchIconButton();
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'k',
ctrlKey: true,
}),
);
assert.calledWith(fakeStore.openSidebarPanel, 'searchAnnotations');
});
it('opens search panel for macOS when "Cmd-K" is pressed outside of the component element', () => {
fakeIsMacOS.returns(true);
createSearchIconButton();
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'k',
metaKey: true,
}),
);
assert.calledWith(fakeStore.openSidebarPanel, 'searchAnnotations');
});
['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);
createSearchIconButton();
input.focus();
assert.equal(document.activeElement, input);
input.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: '/',
}),
);
assert.notCalled(fakeStore.openSidebarPanel);
});
});
it('opens search panel 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);
createSearchIconButton();
input.focus();
assert.equal(document.activeElement, input);
input.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'k',
ctrlKey: true,
}),
);
assert.calledWith(fakeStore.openSidebarPanel, 'searchAnnotations');
});
});
it(
'should pass a11y checks',
checkAccessibility([
{
content: () => createSearchIconButton(),
},
{
name: 'loading state',
content: () => {
fakeStore.isLoading.returns(true);
return createSearchIconButton();
},
},
]),
);
});
import { mockImportedComponents } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import SearchPanel, { $imports } from '../SearchPanel';
describe('SearchPanel', () => {
let fakeStore;
beforeEach(() => {
fakeStore = {
clearSelection: sinon.stub(),
setFilterQuery: sinon.stub(),
filterQuery: sinon.stub().returns(null),
closeSidebarPanel: sinon.stub(),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../store': {
useSidebarStore: () => fakeStore,
},
});
});
afterEach(() => {
$imports.$restore();
});
function createSearchPanel() {
return mount(<SearchPanel />);
}
[true, false].forEach(active => {
it('clears selection when search panel becomes inactive', () => {
const wrapper = createSearchPanel();
wrapper.find('SidebarPanel').props().onActiveChanged(active);
assert.equal(fakeStore.clearSelection.called, !active);
});
});
it('closes search panel when Escape is pressed in search field', () => {
const wrapper = createSearchPanel();
wrapper
.find('SearchField')
.props()
.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }));
assert.calledWith(fakeStore.closeSidebarPanel, 'searchAnnotations');
});
it('updates query onSearch', () => {
const wrapper = createSearchPanel();
wrapper.find('SearchField').props().onSearch('foo');
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);
}
function clickClearButton(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('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 provide a "Clear search" button that clears the selection', () => {
clickClearButton(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('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'");
});
it('should provide a "Clear search" button that clears the selection', () => {
clickClearButton(createComponent());
});
});
});
...@@ -14,10 +14,11 @@ describe('StreamSearchInput', () => { ...@@ -14,10 +14,11 @@ describe('StreamSearchInput', () => {
}; };
fakeStore = { fakeStore = {
routeParams: sinon.stub().returns({}), routeParams: sinon.stub().returns({}),
isFeatureEnabled: sinon.stub().returns(false),
}; };
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
'../store': { useSidebarStore: () => fakeStore }, '../../store': { useSidebarStore: () => fakeStore },
}); });
}); });
...@@ -42,4 +43,13 @@ describe('StreamSearchInput', () => { ...@@ -42,4 +43,13 @@ describe('StreamSearchInput', () => {
}); });
assert.calledWith(fakeRouter.navigate, 'stream', { q: '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'));
assert.isTrue(wrapper.exists('SearchField'));
});
}); });
...@@ -424,4 +424,16 @@ describe('HypothesisApp', () => { ...@@ -424,4 +424,16 @@ describe('HypothesisApp', () => {
assert.isFalse(wrapper.find('TopBar').prop('showShareButton')); assert.isFalse(wrapper.find('TopBar').prop('showShareButton'));
}); });
}); });
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);
});
});
});
}); });
...@@ -62,6 +62,7 @@ describe('SidebarView', () => { ...@@ -62,6 +62,7 @@ describe('SidebarView', () => {
profile: sinon.stub().returns({ userid: null }), profile: sinon.stub().returns({ userid: null }),
searchUris: sinon.stub().returns([]), searchUris: sinon.stub().returns([]),
toggleFocusMode: sinon.stub(), toggleFocusMode: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(false),
}; };
fakeTabsUtil = { fakeTabsUtil = {
...@@ -240,9 +241,20 @@ describe('SidebarView', () => { ...@@ -240,9 +241,20 @@ describe('SidebarView', () => {
}); });
context('user-focus mode', () => { context('user-focus mode', () => {
it('shows filter status when focus mode configured', () => { it('shows old filter status when focus mode configured', () => {
const wrapper = createComponent(); const wrapper = createComponent();
assert.isTrue(wrapper.find('FilterStatus').exists()); 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());
}); });
}); });
...@@ -264,11 +276,6 @@ describe('SidebarView', () => { ...@@ -264,11 +276,6 @@ describe('SidebarView', () => {
}); });
}); });
it('renders the filter status', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.find('FilterStatus').exists());
});
describe('selection tabs', () => { describe('selection tabs', () => {
it('renders tabs', () => { it('renders tabs', () => {
const wrapper = createComponent(); const wrapper = createComponent();
......
...@@ -21,6 +21,7 @@ describe('TopBar', () => { ...@@ -21,6 +21,7 @@ describe('TopBar', () => {
isSidebarPanelOpen: sinon.stub().returns(false), isSidebarPanelOpen: sinon.stub().returns(false),
setFilterQuery: sinon.stub(), setFilterQuery: sinon.stub(),
toggleSidebarPanel: sinon.stub(), toggleSidebarPanel: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(false),
}; };
fakeFrameSync = { fakeFrameSync = {
...@@ -213,6 +214,17 @@ describe('TopBar', () => { ...@@ -213,6 +214,17 @@ describe('TopBar', () => {
}); });
}); });
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'));
});
});
it( it(
'should pass a11y checks', 'should pass a11y checks',
checkAccessibility([ checkAccessibility([
......
...@@ -5,7 +5,11 @@ ...@@ -5,7 +5,11 @@
/** /**
* Defined panel components available in the sidebar. * Defined panel components available in the sidebar.
*/ */
export type PanelName = 'help' | 'loginPrompt' | 'shareGroupAnnotations'; export type PanelName =
| 'help'
| 'loginPrompt'
| 'shareGroupAnnotations'
| 'searchAnnotations';
/** /**
* The top-level tabs in the sidebar interface. Used to reference which tab * The top-level tabs in the sidebar interface. Used to reference which tab
......
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