Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
400d3409
Commit
400d3409
authored
Dec 05, 2023
by
Alejandro Celaya
Committed by
Alejandro Celaya
Dec 06, 2023
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add support for search sidebar panel via feature flag
parent
652bcfa0
Changes
24
Show whitespace changes
Inline
Side-by-side
Showing
24 changed files
with
1514 additions
and
33 deletions
+1514
-33
HypothesisApp.tsx
src/sidebar/components/HypothesisApp.tsx
+4
-0
SidebarPanel.tsx
src/sidebar/components/SidebarPanel.tsx
+4
-0
SidebarView.tsx
src/sidebar/components/SidebarView.tsx
+5
-2
TopBar.tsx
src/sidebar/components/TopBar.tsx
+11
-6
FilterStatus.tsx
src/sidebar/components/old-search/FilterStatus.tsx
+3
-3
SearchInput.tsx
src/sidebar/components/old-search/SearchInput.tsx
+3
-3
FilterStatus-test.js
src/sidebar/components/old-search/test/FilterStatus-test.js
+3
-3
SearchInput-test.js
src/sidebar/components/old-search/test/SearchInput-test.js
+2
-2
FilterAnnotationsStatus.tsx
src/sidebar/components/search/FilterAnnotationsStatus.tsx
+247
-0
SearchField.tsx
src/sidebar/components/search/SearchField.tsx
+110
-0
SearchIconButton.tsx
src/sidebar/components/search/SearchIconButton.tsx
+82
-0
SearchPanel.tsx
src/sidebar/components/search/SearchPanel.tsx
+53
-0
SearchStatus.tsx
src/sidebar/components/search/SearchStatus.tsx
+71
-0
StreamSearchInput.tsx
src/sidebar/components/search/StreamSearchInput.tsx
+10
-6
FilterAnnotationsStatus-test.js
...ar/components/search/test/FilterAnnotationsStatus-test.js
+338
-0
SearchField-test.js
src/sidebar/components/search/test/SearchField-test.js
+141
-0
SearchIconButton-test.js
src/sidebar/components/search/test/SearchIconButton-test.js
+200
-0
SearchPanel-test.js
src/sidebar/components/search/test/SearchPanel-test.js
+74
-0
SearchStatus-test.js
src/sidebar/components/search/test/SearchStatus-test.js
+100
-0
StreamSearchInput-test.js
src/sidebar/components/search/test/StreamSearchInput-test.js
+11
-1
HypothesisApp-test.js
src/sidebar/components/test/HypothesisApp-test.js
+12
-0
SidebarView-test.js
src/sidebar/components/test/SidebarView-test.js
+13
-6
TopBar-test.js
src/sidebar/components/test/TopBar-test.js
+12
-0
sidebar.ts
src/types/sidebar.ts
+5
-1
No files found.
src/sidebar/components/HypothesisApp.tsx
View file @
400d3409
...
...
@@ -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({
<
div
className=
"container"
>
<
ToastMessages
/>
<
HelpPanel
/>
{
searchPanelEnabled
&&
<
SearchPanel
/>
}
{
showShareButton
&&
(
<
ShareDialog
shareTab=
{
!
isThirdParty
}
...
...
src/sidebar/components/SidebarPanel.tsx
View file @
400d3409
import
type
{
DialogProps
}
from
'@hypothesis/frontend-shared'
;
import
{
Dialog
,
Slider
}
from
'@hypothesis/frontend-shared'
;
import
type
{
IconComponent
}
from
'@hypothesis/frontend-shared/lib/types'
;
import
type
{
ComponentChildren
}
from
'preact'
;
...
...
@@ -22,6 +23,7 @@ export type SidebarPanelProps = {
onActiveChanged
?:
(
active
:
boolean
)
=>
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
&&
(
<
Dialog
initialFocus=
{
initialFocus
}
restoreFocus
ref=
{
panelElement
}
classes=
"mb-4"
...
...
src/sidebar/components/SidebarView.tsx
View file @
400d3409
...
...
@@ -6,13 +6,14 @@ import type { FrameSyncService } from '../services/frame-sync';
import
type
{
LoadAnnotationsService
}
from
'../services/load-annotations'
;
import
type
{
StreamerService
}
from
'../services/streamer'
;
import
{
useSidebarStore
}
from
'../store'
;
import
FilterStatus
from
'./FilterStatus'
;
import
LoggedOutMessage
from
'./LoggedOutMessage'
;
import
LoginPromptPanel
from
'./LoginPromptPanel'
;
import
SelectionTabs
from
'./SelectionTabs'
;
import
SidebarContentError
from
'./SidebarContentError'
;
import
ThreadList
from
'./ThreadList'
;
import
{
useRootThread
}
from
'./hooks/use-root-thread'
;
import
FilterStatus
from
'./old-search/FilterStatus'
;
import
FilterAnnotationsStatus
from
'./search/FilterAnnotationsStatus'
;
export
type
SidebarViewProps
=
{
onLogin
:
()
=>
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({
<
div
>
<
h2
className=
"sr-only"
>
Annotations
</
h2
>
{
showFilterStatus
&&
<
FilterStatus
/>
}
{
searchPanelEnabled
&&
<
FilterAnnotationsStatus
/>
}
<
LoginPromptPanel
onLogin=
{
onLogin
}
onSignUp=
{
onSignUp
}
/>
{
hasDirectLinkedAnnotationError
&&
(
<
SidebarContentError
...
...
src/sidebar/components/TopBar.tsx
View file @
400d3409
...
...
@@ -10,10 +10,11 @@ import { useSidebarStore } from '../store';
import
GroupList
from
'./GroupList'
;
import
PendingUpdatesButton
from
'./PendingUpdatesButton'
;
import
PressableIconButton
from
'./PressableIconButton'
;
import
SearchInput
from
'./SearchInput'
;
import
SortMenu
from
'./SortMenu'
;
import
StreamSearchInput
from
'./StreamSearchInput'
;
import
UserMenu
from
'./UserMenu'
;
import
SearchInput
from
'./old-search/SearchInput'
;
import
SearchIconButton
from
'./search/SearchIconButton'
;
import
StreamSearchInput
from
'./search/StreamSearchInput'
;
export
type
TopBarProps
=
{
showShareButton
:
boolean
;
...
...
@@ -54,6 +55,7 @@ function TopBar({
const
filterQuery
=
store
.
filterQuery
();
const
isLoggedIn
=
store
.
isLoggedIn
();
const
hasFetchedProfile
=
store
.
hasFetchedProfile
();
const
searchPanelEnabled
=
store
.
isFeatureEnabled
(
'search_panel'
);
const
toggleSharePanel
=
()
=>
{
store
.
toggleSidebarPanel
(
'shareGroupAnnotations'
);
...
...
@@ -98,10 +100,13 @@ function TopBar({
{
isSidebar
&&
(
<>
<
PendingUpdatesButton
/>
{
!
searchPanelEnabled
&&
(
<
SearchInput
query=
{
filterQuery
||
null
}
onSearch=
{
store
.
setFilterQuery
}
/>
)
}
{
searchPanelEnabled
&&
<
SearchIconButton
/>
}
<
SortMenu
/>
{
showShareButton
&&
(
<
PressableIconButton
...
...
src/sidebar/components/FilterStatus.tsx
→
src/sidebar/components/
old-search/
FilterStatus.tsx
View file @
400d3409
...
...
@@ -8,9 +8,9 @@ import {
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'
;
import
{
countVisible
}
from
'../
../
helpers/thread'
;
import
{
useSidebarStore
}
from
'../
../
store'
;
import
{
useRootThread
}
from
'.
.
/hooks/use-root-thread'
;
type
FilterStatusMessageProps
=
{
/**
...
...
src/sidebar/components/SearchInput.tsx
→
src/sidebar/components/
old-search/
SearchInput.tsx
View file @
400d3409
...
...
@@ -8,9 +8,9 @@ import classnames from 'classnames';
import
type
{
RefObject
}
from
'preact'
;
import
{
useCallback
,
useRef
,
useState
}
from
'preact/hooks'
;
import
{
useShortcut
}
from
'../../shared/shortcut'
;
import
{
isMacOS
}
from
'../../shared/user-agent'
;
import
{
useSidebarStore
}
from
'../store'
;
import
{
useShortcut
}
from
'../../
../
shared/shortcut'
;
import
{
isMacOS
}
from
'../../
../
shared/user-agent'
;
import
{
useSidebarStore
}
from
'../
../
store'
;
/**
* Respond to keydown events on the document (shortcut keys):
...
...
src/sidebar/components/test/FilterStatus-test.js
→
src/sidebar/components/
old-search/
test/FilterStatus-test.js
View file @
400d3409
...
...
@@ -40,9 +40,9 @@ describe('FilterStatus', () => {
$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
,
});
});
...
...
src/sidebar/components/test/SearchInput-test.js
→
src/sidebar/components/
old-search/
test/SearchInput-test.js
View file @
400d3409
...
...
@@ -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
,
},
});
...
...
src/sidebar/components/search/FilterAnnotationsStatus.tsx
0 → 100644
View file @
400d3409
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
>
);
}
src/sidebar/components/search/SearchField.tsx
0 → 100644
View file @
400d3409
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
>
);
}
src/sidebar/components/search/SearchIconButton.tsx
0 → 100644
View file @
400d3409
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"
/>
)
}
</>
);
}
src/sidebar/components/search/SearchPanel.tsx
0 → 100644
View file @
400d3409
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
>
);
}
src/sidebar/components/search/SearchStatus.tsx
0 → 100644
View file @
400d3409
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
>
);
}
src/sidebar/components/StreamSearchInput.tsx
→
src/sidebar/components/
search/
StreamSearchInput.tsx
View file @
400d3409
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
(
<
SearchInput
query=
{
q
??
''
}
onSearch=
{
setQuery
}
alwaysExpanded=
{
true
}
/>
return
searchPanelEnabled
?
(
<
SearchField
query=
{
q
??
''
}
onSearch=
{
setQuery
}
/>
)
:
(
<
SearchInput
query=
{
q
??
''
}
onSearch=
{
setQuery
}
alwaysExpanded
/>
);
}
...
...
src/sidebar/components/search/test/FilterAnnotationsStatus-test.js
0 → 100644
View file @
400d3409
import
{
mockImportedComponents
}
from
'@hypothesis/frontend-testing'
;
import
{
mount
}
from
'enzyme'
;
import
FilterAnnotationsStatus
,
{
$imports
}
from
'../FilterAnnotationsStatus'
;
function
getFocusState
()
{
return
{
active
:
false
,
configured
:
false
,
focusDisplayName
:
''
,
};
}
describe
(
'FilterAnnotationsStatus'
,
()
=>
{
let
fakeStore
;
let
fakeUseRootThread
;
let
fakeThreadUtil
;
const
createComponent
=
()
=>
{
return
mount
(
<
FilterAnnotationsStatus
/>
);
};
beforeEach
(()
=>
{
fakeThreadUtil
=
{
countVisible
:
sinon
.
stub
().
returns
(
0
),
};
fakeStore
=
{
annotationCount
:
sinon
.
stub
(),
clearSelection
:
sinon
.
stub
(),
directLinkedAnnotationId
:
sinon
.
stub
(),
focusState
:
sinon
.
stub
().
returns
(
getFocusState
()),
forcedVisibleThreads
:
sinon
.
stub
().
returns
([]),
isLoading
:
sinon
.
stub
().
returns
(
false
),
selectedAnnotations
:
sinon
.
stub
().
returns
([]),
toggleFocusMode
:
sinon
.
stub
(),
};
fakeUseRootThread
=
sinon
.
stub
().
returns
({});
$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
,
});
});
});
});
src/sidebar/components/search/test/SearchField-test.js
0 → 100644
View file @
400d3409
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
();
},
},
]),
);
});
src/sidebar/components/search/test/SearchIconButton-test.js
0 → 100644
View file @
400d3409
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
();
},
},
]),
);
});
src/sidebar/components/search/test/SearchPanel-test.js
0 → 100644
View file @
400d3409
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
);
});
});
});
src/sidebar/components/search/test/SearchStatus-test.js
0 → 100644
View file @
400d3409
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
());
});
});
});
src/sidebar/components/test/StreamSearchInput-test.js
→
src/sidebar/components/
search/
test/StreamSearchInput-test.js
View file @
400d3409
...
...
@@ -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'
));
});
});
src/sidebar/components/test/HypothesisApp-test.js
View file @
400d3409
...
...
@@ -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
);
});
});
});
});
src/sidebar/components/test/SidebarView-test.js
View file @
400d3409
...
...
@@ -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
();
...
...
src/sidebar/components/test/TopBar-test.js
View file @
400d3409
...
...
@@ -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
([
...
...
src/types/sidebar.ts
View file @
400d3409
...
...
@@ -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
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment