From 400d3409fd07600de91ef2f960d6e9abf271ab32 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 5 Dec 2023 09:32:45 +0100 Subject: [PATCH] Add support for search sidebar panel via feature flag --- src/sidebar/components/HypothesisApp.tsx | 4 + src/sidebar/components/SidebarPanel.tsx | 4 + src/sidebar/components/SidebarView.tsx | 7 +- src/sidebar/components/TopBar.tsx | 17 +- .../{ => old-search}/FilterStatus.tsx | 6 +- .../{ => old-search}/SearchInput.tsx | 6 +- .../test/FilterStatus-test.js | 6 +- .../{ => old-search}/test/SearchInput-test.js | 4 +- .../search/FilterAnnotationsStatus.tsx | 247 +++++++++++++ src/sidebar/components/search/SearchField.tsx | 110 ++++++ .../components/search/SearchIconButton.tsx | 82 +++++ src/sidebar/components/search/SearchPanel.tsx | 53 +++ .../components/search/SearchStatus.tsx | 71 ++++ .../{ => search}/StreamSearchInput.tsx | 16 +- .../test/FilterAnnotationsStatus-test.js | 338 ++++++++++++++++++ .../search/test/SearchField-test.js | 141 ++++++++ .../search/test/SearchIconButton-test.js | 200 +++++++++++ .../search/test/SearchPanel-test.js | 74 ++++ .../search/test/SearchStatus-test.js | 100 ++++++ .../test/StreamSearchInput-test.js | 12 +- .../components/test/HypothesisApp-test.js | 12 + .../components/test/SidebarView-test.js | 19 +- src/sidebar/components/test/TopBar-test.js | 12 + src/types/sidebar.ts | 6 +- 24 files changed, 1514 insertions(+), 33 deletions(-) rename src/sidebar/components/{ => old-search}/FilterStatus.tsx (98%) rename src/sidebar/components/{ => old-search}/SearchInput.tsx (97%) rename src/sidebar/components/{ => old-search}/test/FilterStatus-test.js (98%) rename src/sidebar/components/{ => old-search}/test/SearchInput-test.js (98%) create mode 100644 src/sidebar/components/search/FilterAnnotationsStatus.tsx create mode 100644 src/sidebar/components/search/SearchField.tsx create mode 100644 src/sidebar/components/search/SearchIconButton.tsx create mode 100644 src/sidebar/components/search/SearchPanel.tsx create mode 100644 src/sidebar/components/search/SearchStatus.tsx rename src/sidebar/components/{ => search}/StreamSearchInput.tsx (60%) create mode 100644 src/sidebar/components/search/test/FilterAnnotationsStatus-test.js create mode 100644 src/sidebar/components/search/test/SearchField-test.js create mode 100644 src/sidebar/components/search/test/SearchIconButton-test.js create mode 100644 src/sidebar/components/search/test/SearchPanel-test.js create mode 100644 src/sidebar/components/search/test/SearchStatus-test.js rename src/sidebar/components/{ => search}/test/StreamSearchInput-test.js (75%) diff --git a/src/sidebar/components/HypothesisApp.tsx b/src/sidebar/components/HypothesisApp.tsx index 212eacc54fc..fed49c3864b 100644 --- a/src/sidebar/components/HypothesisApp.tsx +++ b/src/sidebar/components/HypothesisApp.tsx @@ -22,6 +22,7 @@ import SidebarView from './SidebarView'; import StreamView from './StreamView'; import ToastMessages from './ToastMessages'; import TopBar from './TopBar'; +import SearchPanel from './search/SearchPanel'; export type HypothesisAppProps = { auth: AuthService; @@ -69,6 +70,8 @@ function HypothesisApp({ const showShareButton = !isThirdParty || exportAnnotations || importAnnotations; + const searchPanelEnabled = store.isFeatureEnabled('search_panel'); + const login = async () => { if (serviceConfig(settings)) { // Let the host page handle the login request @@ -168,6 +171,7 @@ function HypothesisApp({
+ {searchPanelEnabled && } {showShareButton && ( void; /** What Dialog variant to use */ variant?: 'panel' | 'custom'; + initialFocus?: DialogProps['initialFocus']; }; /** @@ -35,6 +37,7 @@ export default function SidebarPanel({ title, variant = 'panel', onActiveChanged, + initialFocus, }: SidebarPanelProps) { const store = useSidebarStore(); const panelIsActive = store.isSidebarPanelOpen(panelName); @@ -61,6 +64,7 @@ export default function SidebarPanel({ <> {panelIsActive && ( void; @@ -66,7 +67,8 @@ function SidebarView({ const hasContentError = hasDirectLinkedAnnotationError || hasDirectLinkedGroupError; - const showFilterStatus = !hasContentError; + const searchPanelEnabled = store.isFeatureEnabled('search_panel'); + const showFilterStatus = !hasContentError && !searchPanelEnabled; const showTabs = !hasContentError && !hasAppliedFilter; // Show a CTA to log in if successfully viewing a direct-linked annotation @@ -132,6 +134,7 @@ function SidebarView({

Annotations

{showFilterStatus && } + {searchPanelEnabled && } {hasDirectLinkedAnnotationError && ( { store.toggleSidebarPanel('shareGroupAnnotations'); @@ -98,10 +100,13 @@ function TopBar({ {isSidebar && ( <> - + {!searchPanelEnabled && ( + + )} + {searchPanelEnabled && } {showShareButton && ( { $imports.$mock(mockImportedComponents()); $imports.$mock({ - './hooks/use-root-thread': { useRootThread: fakeUseRootThread }, - '../store': { useSidebarStore: () => fakeStore }, - '../helpers/thread': fakeThreadUtil, + '../hooks/use-root-thread': { useRootThread: fakeUseRootThread }, + '../../store': { useSidebarStore: () => fakeStore }, + '../../helpers/thread': fakeThreadUtil, }); }); diff --git a/src/sidebar/components/test/SearchInput-test.js b/src/sidebar/components/old-search/test/SearchInput-test.js similarity index 98% rename from src/sidebar/components/test/SearchInput-test.js rename to src/sidebar/components/old-search/test/SearchInput-test.js index 68c9685ba82..df272aa4a6e 100644 --- a/src/sidebar/components/test/SearchInput-test.js +++ b/src/sidebar/components/old-search/test/SearchInput-test.js @@ -34,8 +34,8 @@ describe('SearchInput', () => { $imports.$mock(mockImportedComponents()); $imports.$mock({ - '../store': { useSidebarStore: () => fakeStore }, - '../../shared/user-agent': { + '../../store': { useSidebarStore: () => fakeStore }, + '../../../shared/user-agent': { isMacOS: fakeIsMacOS, }, }); diff --git a/src/sidebar/components/search/FilterAnnotationsStatus.tsx b/src/sidebar/components/search/FilterAnnotationsStatus.tsx new file mode 100644 index 00000000000..9329f0bd075 --- /dev/null +++ b/src/sidebar/components/search/FilterAnnotationsStatus.tsx @@ -0,0 +1,247 @@ +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 && Showing } + + {resultCount > 0 ? resultCount : 'No'}{' '} + {resultCount === 1 ? entitySingular : entityPlural} + + {focusDisplayName && ( + + {' '} + by{' '} + + {focusDisplayName} + + + )} + {additionalCount > 0 && ( + + {' '} + (and {additionalCount} more) + + )} + + ); +} + +/** + * 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|) annotation[s] [\(and more\)]" + * Button: + * " Show all [\() annotation[s] [by ] + * [\(and more\)]" + * Button: + * - If there are no forced-visible threads: + * "Show (all|only )" - 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 ( +
+ + + {store.isLoading() ? ( + + ) : ( +
+
+ {filterMode && ( + + )} +
+ {filterMode && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/sidebar/components/search/SearchField.tsx b/src/sidebar/components/search/SearchField.tsx new file mode 100644 index 00000000000..79c4c0f2c46 --- /dev/null +++ b/src/sidebar/components/search/SearchField.tsx @@ -0,0 +1,110 @@ +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; + + /** The input's ref object, in case it needs to be handled by consumers */ + inputRef?: RefObject; + + /** 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 ( +
+ + + setPendingQuery((e.target as HTMLInputElement).value) + } + onKeyDown={onKeyDown} + /> + + +
+ ); +} diff --git a/src/sidebar/components/search/SearchIconButton.tsx b/src/sidebar/components/search/SearchIconButton.tsx new file mode 100644 index 00000000000..7d4499b6653 --- /dev/null +++ b/src/sidebar/components/search/SearchIconButton.tsx @@ -0,0 +1,82 @@ +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(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 && } + {!isLoading && ( + + )} + + ); +} diff --git a/src/sidebar/components/search/SearchPanel.tsx b/src/sidebar/components/search/SearchPanel.tsx new file mode 100644 index 00000000000..e83f925fcf8 --- /dev/null +++ b/src/sidebar/components/search/SearchPanel.tsx @@ -0,0 +1,53 @@ +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(null); + + return ( + { + if (!active) { + store.clearSelection(); + } + }} + > + + +
+ { + // Close panel on Escape, which will also clear search + if (e.key === 'Escape') { + store.closeSidebarPanel('searchAnnotations'); + } + }} + /> + +
+ {filterQuery && } +
+
+
+ ); +} diff --git a/src/sidebar/components/search/SearchStatus.tsx b/src/sidebar/components/search/SearchStatus.tsx new file mode 100644 index 00000000000..3746dfed108 --- /dev/null +++ b/src/sidebar/components/search/SearchStatus.tsx @@ -0,0 +1,71 @@ +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 ( +
+
+ {filterQuery && ( + <> + {resultCount > 0 && Showing } + + {resultCount > 0 ? resultCount : 'No'}{' '} + {resultCount === 1 ? 'result' : 'results'} + + + {' '} + for {`'${filterQuery}'`} + + + )} +
+ {filterQuery && ( + + )} +
+ ); +} diff --git a/src/sidebar/components/StreamSearchInput.tsx b/src/sidebar/components/search/StreamSearchInput.tsx similarity index 60% rename from src/sidebar/components/StreamSearchInput.tsx rename to src/sidebar/components/search/StreamSearchInput.tsx index 146f0179745..113cd86e6fd 100644 --- a/src/sidebar/components/StreamSearchInput.tsx +++ b/src/sidebar/components/search/StreamSearchInput.tsx @@ -1,7 +1,8 @@ -import { withServices } from '../service-context'; -import type { RouterService } from '../services/router'; -import { useSidebarStore } from '../store'; -import SearchInput from './SearchInput'; +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 = { router: RouterService; @@ -14,6 +15,7 @@ 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 @@ -21,8 +23,10 @@ function StreamSearchInput({ router }: StreamSearchInputProps) { router.navigate('stream', { q: query }); }; - return ( - + return searchPanelEnabled ? ( + + ) : ( + ); } diff --git a/src/sidebar/components/search/test/FilterAnnotationsStatus-test.js b/src/sidebar/components/search/test/FilterAnnotationsStatus-test.js new file mode 100644 index 00000000000..d9a38e78d43 --- /dev/null +++ b/src/sidebar/components/search/test/FilterAnnotationsStatus-test.js @@ -0,0 +1,338 @@ +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(); + }; + + 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({}); + + $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 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('(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): 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({ + 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 3): 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 4): 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', () => { + const wrapper = createComponent(); + const button = wrapper.find('Button[data-testid="clear-button"]'); + + assert.equal(button.text(), 'Show all'); + button.props().onClick(); + assert.calledOnce(fakeStore.toggleFocusMode); + }); + }); + + context('(State 5): 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('(State 6): 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 7): 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 8): 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, + }); + }); + }); +}); diff --git a/src/sidebar/components/search/test/SearchField-test.js b/src/sidebar/components/search/test/SearchField-test.js new file mode 100644 index 00000000000..78d7bd646d3 --- /dev/null +++ b/src/sidebar/components/search/test/SearchField-test.js @@ -0,0 +1,141 @@ +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(, { 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(); + }, + }, + ]), + ); +}); diff --git a/src/sidebar/components/search/test/SearchIconButton-test.js b/src/sidebar/components/search/test/SearchIconButton-test.js new file mode 100644 index 00000000000..d8ca7f5957b --- /dev/null +++ b/src/sidebar/components/search/test/SearchIconButton-test.js @@ -0,0 +1,200 @@ +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(, { 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(); + }, + }, + ]), + ); +}); diff --git a/src/sidebar/components/search/test/SearchPanel-test.js b/src/sidebar/components/search/test/SearchPanel-test.js new file mode 100644 index 00000000000..8ef9e846a87 --- /dev/null +++ b/src/sidebar/components/search/test/SearchPanel-test.js @@ -0,0 +1,74 @@ +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(); + } + + [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); + }); + }); +}); diff --git a/src/sidebar/components/search/test/SearchStatus-test.js b/src/sidebar/components/search/test/SearchStatus-test.js new file mode 100644 index 00000000000..2470b9dc712 --- /dev/null +++ b/src/sidebar/components/search/test/SearchStatus-test.js @@ -0,0 +1,100 @@ +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(); + }; + + 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()); + }); + }); +}); diff --git a/src/sidebar/components/test/StreamSearchInput-test.js b/src/sidebar/components/search/test/StreamSearchInput-test.js similarity index 75% rename from src/sidebar/components/test/StreamSearchInput-test.js rename to src/sidebar/components/search/test/StreamSearchInput-test.js index 0d0c4b2f807..c5edb047b06 100644 --- a/src/sidebar/components/test/StreamSearchInput-test.js +++ b/src/sidebar/components/search/test/StreamSearchInput-test.js @@ -14,10 +14,11 @@ describe('StreamSearchInput', () => { }; fakeStore = { routeParams: sinon.stub().returns({}), + isFeatureEnabled: sinon.stub().returns(false), }; $imports.$mock(mockImportedComponents()); $imports.$mock({ - '../store': { useSidebarStore: () => fakeStore }, + '../../store': { useSidebarStore: () => fakeStore }, }); }); @@ -42,4 +43,13 @@ describe('StreamSearchInput', () => { }); 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')); + }); }); diff --git a/src/sidebar/components/test/HypothesisApp-test.js b/src/sidebar/components/test/HypothesisApp-test.js index 84ae679e1e7..2f4cf59ca74 100644 --- a/src/sidebar/components/test/HypothesisApp-test.js +++ b/src/sidebar/components/test/HypothesisApp-test.js @@ -424,4 +424,16 @@ describe('HypothesisApp', () => { 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); + }); + }); + }); }); diff --git a/src/sidebar/components/test/SidebarView-test.js b/src/sidebar/components/test/SidebarView-test.js index de5b5360ad7..69e2465fa23 100644 --- a/src/sidebar/components/test/SidebarView-test.js +++ b/src/sidebar/components/test/SidebarView-test.js @@ -62,6 +62,7 @@ describe('SidebarView', () => { profile: sinon.stub().returns({ userid: null }), searchUris: sinon.stub().returns([]), toggleFocusMode: sinon.stub(), + isFeatureEnabled: sinon.stub().returns(false), }; fakeTabsUtil = { @@ -240,9 +241,20 @@ describe('SidebarView', () => { }); context('user-focus mode', () => { - it('shows filter status when focus mode configured', () => { + it('shows old filter status when focus mode configured', () => { 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()); }); }); @@ -264,11 +276,6 @@ describe('SidebarView', () => { }); }); - it('renders the filter status', () => { - const wrapper = createComponent(); - assert.isTrue(wrapper.find('FilterStatus').exists()); - }); - describe('selection tabs', () => { it('renders tabs', () => { const wrapper = createComponent(); diff --git a/src/sidebar/components/test/TopBar-test.js b/src/sidebar/components/test/TopBar-test.js index d3b07ba52b6..c364ff23b93 100644 --- a/src/sidebar/components/test/TopBar-test.js +++ b/src/sidebar/components/test/TopBar-test.js @@ -21,6 +21,7 @@ describe('TopBar', () => { isSidebarPanelOpen: sinon.stub().returns(false), setFilterQuery: sinon.stub(), toggleSidebarPanel: sinon.stub(), + isFeatureEnabled: sinon.stub().returns(false), }; fakeFrameSync = { @@ -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( 'should pass a11y checks', checkAccessibility([ diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts index 3f5fe3ad898..fa15d67f665 100644 --- a/src/types/sidebar.ts +++ b/src/types/sidebar.ts @@ -5,7 +5,11 @@ /** * 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