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
Expand all
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';
...
@@ -22,6 +22,7 @@ import SidebarView from './SidebarView';
import
StreamView
from
'./StreamView'
;
import
StreamView
from
'./StreamView'
;
import
ToastMessages
from
'./ToastMessages'
;
import
ToastMessages
from
'./ToastMessages'
;
import
TopBar
from
'./TopBar'
;
import
TopBar
from
'./TopBar'
;
import
SearchPanel
from
'./search/SearchPanel'
;
export
type
HypothesisAppProps
=
{
export
type
HypothesisAppProps
=
{
auth
:
AuthService
;
auth
:
AuthService
;
...
@@ -69,6 +70,8 @@ function HypothesisApp({
...
@@ -69,6 +70,8 @@ function HypothesisApp({
const
showShareButton
=
const
showShareButton
=
!
isThirdParty
||
exportAnnotations
||
importAnnotations
;
!
isThirdParty
||
exportAnnotations
||
importAnnotations
;
const
searchPanelEnabled
=
store
.
isFeatureEnabled
(
'search_panel'
);
const
login
=
async
()
=>
{
const
login
=
async
()
=>
{
if
(
serviceConfig
(
settings
))
{
if
(
serviceConfig
(
settings
))
{
// Let the host page handle the login request
// Let the host page handle the login request
...
@@ -168,6 +171,7 @@ function HypothesisApp({
...
@@ -168,6 +171,7 @@ function HypothesisApp({
<
div
className=
"container"
>
<
div
className=
"container"
>
<
ToastMessages
/>
<
ToastMessages
/>
<
HelpPanel
/>
<
HelpPanel
/>
{
searchPanelEnabled
&&
<
SearchPanel
/>
}
{
showShareButton
&&
(
{
showShareButton
&&
(
<
ShareDialog
<
ShareDialog
shareTab=
{
!
isThirdParty
}
shareTab=
{
!
isThirdParty
}
...
...
src/sidebar/components/SidebarPanel.tsx
View file @
400d3409
import
type
{
DialogProps
}
from
'@hypothesis/frontend-shared'
;
import
{
Dialog
,
Slider
}
from
'@hypothesis/frontend-shared'
;
import
{
Dialog
,
Slider
}
from
'@hypothesis/frontend-shared'
;
import
type
{
IconComponent
}
from
'@hypothesis/frontend-shared/lib/types'
;
import
type
{
IconComponent
}
from
'@hypothesis/frontend-shared/lib/types'
;
import
type
{
ComponentChildren
}
from
'preact'
;
import
type
{
ComponentChildren
}
from
'preact'
;
...
@@ -22,6 +23,7 @@ export type SidebarPanelProps = {
...
@@ -22,6 +23,7 @@ export type SidebarPanelProps = {
onActiveChanged
?:
(
active
:
boolean
)
=>
void
;
onActiveChanged
?:
(
active
:
boolean
)
=>
void
;
/** What Dialog variant to use */
/** What Dialog variant to use */
variant
?:
'panel'
|
'custom'
;
variant
?:
'panel'
|
'custom'
;
initialFocus
?:
DialogProps
[
'initialFocus'
];
};
};
/**
/**
...
@@ -35,6 +37,7 @@ export default function SidebarPanel({
...
@@ -35,6 +37,7 @@ export default function SidebarPanel({
title
,
title
,
variant
=
'panel'
,
variant
=
'panel'
,
onActiveChanged
,
onActiveChanged
,
initialFocus
,
}:
SidebarPanelProps
)
{
}:
SidebarPanelProps
)
{
const
store
=
useSidebarStore
();
const
store
=
useSidebarStore
();
const
panelIsActive
=
store
.
isSidebarPanelOpen
(
panelName
);
const
panelIsActive
=
store
.
isSidebarPanelOpen
(
panelName
);
...
@@ -61,6 +64,7 @@ export default function SidebarPanel({
...
@@ -61,6 +64,7 @@ export default function SidebarPanel({
<>
<>
{
panelIsActive
&&
(
{
panelIsActive
&&
(
<
Dialog
<
Dialog
initialFocus=
{
initialFocus
}
restoreFocus
restoreFocus
ref=
{
panelElement
}
ref=
{
panelElement
}
classes=
"mb-4"
classes=
"mb-4"
...
...
src/sidebar/components/SidebarView.tsx
View file @
400d3409
...
@@ -6,13 +6,14 @@ import type { FrameSyncService } from '../services/frame-sync';
...
@@ -6,13 +6,14 @@ import type { FrameSyncService } from '../services/frame-sync';
import
type
{
LoadAnnotationsService
}
from
'../services/load-annotations'
;
import
type
{
LoadAnnotationsService
}
from
'../services/load-annotations'
;
import
type
{
StreamerService
}
from
'../services/streamer'
;
import
type
{
StreamerService
}
from
'../services/streamer'
;
import
{
useSidebarStore
}
from
'../store'
;
import
{
useSidebarStore
}
from
'../store'
;
import
FilterStatus
from
'./FilterStatus'
;
import
LoggedOutMessage
from
'./LoggedOutMessage'
;
import
LoggedOutMessage
from
'./LoggedOutMessage'
;
import
LoginPromptPanel
from
'./LoginPromptPanel'
;
import
LoginPromptPanel
from
'./LoginPromptPanel'
;
import
SelectionTabs
from
'./SelectionTabs'
;
import
SelectionTabs
from
'./SelectionTabs'
;
import
SidebarContentError
from
'./SidebarContentError'
;
import
SidebarContentError
from
'./SidebarContentError'
;
import
ThreadList
from
'./ThreadList'
;
import
ThreadList
from
'./ThreadList'
;
import
{
useRootThread
}
from
'./hooks/use-root-thread'
;
import
{
useRootThread
}
from
'./hooks/use-root-thread'
;
import
FilterStatus
from
'./old-search/FilterStatus'
;
import
FilterAnnotationsStatus
from
'./search/FilterAnnotationsStatus'
;
export
type
SidebarViewProps
=
{
export
type
SidebarViewProps
=
{
onLogin
:
()
=>
void
;
onLogin
:
()
=>
void
;
...
@@ -66,7 +67,8 @@ function SidebarView({
...
@@ -66,7 +67,8 @@ function SidebarView({
const
hasContentError
=
const
hasContentError
=
hasDirectLinkedAnnotationError
||
hasDirectLinkedGroupError
;
hasDirectLinkedAnnotationError
||
hasDirectLinkedGroupError
;
const
showFilterStatus
=
!
hasContentError
;
const
searchPanelEnabled
=
store
.
isFeatureEnabled
(
'search_panel'
);
const
showFilterStatus
=
!
hasContentError
&&
!
searchPanelEnabled
;
const
showTabs
=
!
hasContentError
&&
!
hasAppliedFilter
;
const
showTabs
=
!
hasContentError
&&
!
hasAppliedFilter
;
// Show a CTA to log in if successfully viewing a direct-linked annotation
// Show a CTA to log in if successfully viewing a direct-linked annotation
...
@@ -132,6 +134,7 @@ function SidebarView({
...
@@ -132,6 +134,7 @@ function SidebarView({
<
div
>
<
div
>
<
h2
className=
"sr-only"
>
Annotations
</
h2
>
<
h2
className=
"sr-only"
>
Annotations
</
h2
>
{
showFilterStatus
&&
<
FilterStatus
/>
}
{
showFilterStatus
&&
<
FilterStatus
/>
}
{
searchPanelEnabled
&&
<
FilterAnnotationsStatus
/>
}
<
LoginPromptPanel
onLogin=
{
onLogin
}
onSignUp=
{
onSignUp
}
/>
<
LoginPromptPanel
onLogin=
{
onLogin
}
onSignUp=
{
onSignUp
}
/>
{
hasDirectLinkedAnnotationError
&&
(
{
hasDirectLinkedAnnotationError
&&
(
<
SidebarContentError
<
SidebarContentError
...
...
src/sidebar/components/TopBar.tsx
View file @
400d3409
...
@@ -10,10 +10,11 @@ import { useSidebarStore } from '../store';
...
@@ -10,10 +10,11 @@ import { useSidebarStore } from '../store';
import
GroupList
from
'./GroupList'
;
import
GroupList
from
'./GroupList'
;
import
PendingUpdatesButton
from
'./PendingUpdatesButton'
;
import
PendingUpdatesButton
from
'./PendingUpdatesButton'
;
import
PressableIconButton
from
'./PressableIconButton'
;
import
PressableIconButton
from
'./PressableIconButton'
;
import
SearchInput
from
'./SearchInput'
;
import
SortMenu
from
'./SortMenu'
;
import
SortMenu
from
'./SortMenu'
;
import
StreamSearchInput
from
'./StreamSearchInput'
;
import
UserMenu
from
'./UserMenu'
;
import
UserMenu
from
'./UserMenu'
;
import
SearchInput
from
'./old-search/SearchInput'
;
import
SearchIconButton
from
'./search/SearchIconButton'
;
import
StreamSearchInput
from
'./search/StreamSearchInput'
;
export
type
TopBarProps
=
{
export
type
TopBarProps
=
{
showShareButton
:
boolean
;
showShareButton
:
boolean
;
...
@@ -54,6 +55,7 @@ function TopBar({
...
@@ -54,6 +55,7 @@ function TopBar({
const
filterQuery
=
store
.
filterQuery
();
const
filterQuery
=
store
.
filterQuery
();
const
isLoggedIn
=
store
.
isLoggedIn
();
const
isLoggedIn
=
store
.
isLoggedIn
();
const
hasFetchedProfile
=
store
.
hasFetchedProfile
();
const
hasFetchedProfile
=
store
.
hasFetchedProfile
();
const
searchPanelEnabled
=
store
.
isFeatureEnabled
(
'search_panel'
);
const
toggleSharePanel
=
()
=>
{
const
toggleSharePanel
=
()
=>
{
store
.
toggleSidebarPanel
(
'shareGroupAnnotations'
);
store
.
toggleSidebarPanel
(
'shareGroupAnnotations'
);
...
@@ -98,10 +100,13 @@ function TopBar({
...
@@ -98,10 +100,13 @@ function TopBar({
{
isSidebar
&&
(
{
isSidebar
&&
(
<>
<>
<
PendingUpdatesButton
/>
<
PendingUpdatesButton
/>
{
!
searchPanelEnabled
&&
(
<
SearchInput
<
SearchInput
query=
{
filterQuery
||
null
}
query=
{
filterQuery
||
null
}
onSearch=
{
store
.
setFilterQuery
}
onSearch=
{
store
.
setFilterQuery
}
/>
/>
)
}
{
searchPanelEnabled
&&
<
SearchIconButton
/>
}
<
SortMenu
/>
<
SortMenu
/>
{
showShareButton
&&
(
{
showShareButton
&&
(
<
PressableIconButton
<
PressableIconButton
...
...
src/sidebar/components/FilterStatus.tsx
→
src/sidebar/components/
old-search/
FilterStatus.tsx
View file @
400d3409
...
@@ -8,9 +8,9 @@ import {
...
@@ -8,9 +8,9 @@ import {
import
classnames
from
'classnames'
;
import
classnames
from
'classnames'
;
import
{
useMemo
}
from
'preact/hooks'
;
import
{
useMemo
}
from
'preact/hooks'
;
import
{
countVisible
}
from
'../helpers/thread'
;
import
{
countVisible
}
from
'../
../
helpers/thread'
;
import
{
useSidebarStore
}
from
'../store'
;
import
{
useSidebarStore
}
from
'../
../
store'
;
import
{
useRootThread
}
from
'./hooks/use-root-thread'
;
import
{
useRootThread
}
from
'.
.
/hooks/use-root-thread'
;
type
FilterStatusMessageProps
=
{
type
FilterStatusMessageProps
=
{
/**
/**
...
...
src/sidebar/components/SearchInput.tsx
→
src/sidebar/components/
old-search/
SearchInput.tsx
View file @
400d3409
...
@@ -8,9 +8,9 @@ import classnames from 'classnames';
...
@@ -8,9 +8,9 @@ import classnames from 'classnames';
import
type
{
RefObject
}
from
'preact'
;
import
type
{
RefObject
}
from
'preact'
;
import
{
useCallback
,
useRef
,
useState
}
from
'preact/hooks'
;
import
{
useCallback
,
useRef
,
useState
}
from
'preact/hooks'
;
import
{
useShortcut
}
from
'../../shared/shortcut'
;
import
{
useShortcut
}
from
'../../
../
shared/shortcut'
;
import
{
isMacOS
}
from
'../../shared/user-agent'
;
import
{
isMacOS
}
from
'../../
../
shared/user-agent'
;
import
{
useSidebarStore
}
from
'../store'
;
import
{
useSidebarStore
}
from
'../
../
store'
;
/**
/**
* Respond to keydown events on the document (shortcut keys):
* Respond to keydown events on the document (shortcut keys):
...
...
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', () => {
...
@@ -40,9 +40,9 @@ describe('FilterStatus', () => {
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
({
$imports
.
$mock
({
'./hooks/use-root-thread'
:
{
useRootThread
:
fakeUseRootThread
},
'.
.
/hooks/use-root-thread'
:
{
useRootThread
:
fakeUseRootThread
},
'../store'
:
{
useSidebarStore
:
()
=>
fakeStore
},
'../
../
store'
:
{
useSidebarStore
:
()
=>
fakeStore
},
'../helpers/thread'
:
fakeThreadUtil
,
'../
../
helpers/thread'
:
fakeThreadUtil
,
});
});
});
});
...
...
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', () => {
...
@@ -34,8 +34,8 @@ describe('SearchInput', () => {
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
({
$imports
.
$mock
({
'../store'
:
{
useSidebarStore
:
()
=>
fakeStore
},
'../
../
store'
:
{
useSidebarStore
:
()
=>
fakeStore
},
'../../shared/user-agent'
:
{
'../../
../
shared/user-agent'
:
{
isMacOS
:
fakeIsMacOS
,
isMacOS
:
fakeIsMacOS
,
},
},
});
});
...
...
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
{
withServices
}
from
'../../service-context'
;
import
type
{
RouterService
}
from
'../services/router'
;
import
type
{
RouterService
}
from
'../../services/router'
;
import
{
useSidebarStore
}
from
'../store'
;
import
{
useSidebarStore
}
from
'../../store'
;
import
SearchInput
from
'./SearchInput'
;
import
SearchInput
from
'../old-search/SearchInput'
;
import
SearchField
from
'./SearchField'
;
export
type
StreamSearchInputProps
=
{
export
type
StreamSearchInputProps
=
{
router
:
RouterService
;
router
:
RouterService
;
...
@@ -14,6 +15,7 @@ export type StreamSearchInputProps = {
...
@@ -14,6 +15,7 @@ export type StreamSearchInputProps = {
*/
*/
function
StreamSearchInput
({
router
}:
StreamSearchInputProps
)
{
function
StreamSearchInput
({
router
}:
StreamSearchInputProps
)
{
const
store
=
useSidebarStore
();
const
store
=
useSidebarStore
();
const
searchPanelEnabled
=
store
.
isFeatureEnabled
(
'search_panel'
);
const
{
q
}
=
store
.
routeParams
();
const
{
q
}
=
store
.
routeParams
();
const
setQuery
=
(
query
:
string
)
=>
{
const
setQuery
=
(
query
:
string
)
=>
{
// Re-route the user to `/stream` if they are on `/a/:id` and then set
// Re-route the user to `/stream` if they are on `/a/:id` and then set
...
@@ -21,8 +23,10 @@ function StreamSearchInput({ router }: StreamSearchInputProps) {
...
@@ -21,8 +23,10 @@ function StreamSearchInput({ router }: StreamSearchInputProps) {
router
.
navigate
(
'stream'
,
{
q
:
query
});
router
.
navigate
(
'stream'
,
{
q
:
query
});
};
};
return
(
return
searchPanelEnabled
?
(
<
SearchInput
query=
{
q
??
''
}
onSearch=
{
setQuery
}
alwaysExpanded=
{
true
}
/>
<
SearchField
query=
{
q
??
''
}
onSearch=
{
setQuery
}
/>
)
:
(
<
SearchInput
query=
{
q
??
''
}
onSearch=
{
setQuery
}
alwaysExpanded
/>
);
);
}
}
...
...
src/sidebar/components/search/test/FilterAnnotationsStatus-test.js
0 → 100644
View file @
400d3409
This diff is collapsed.
Click to expand it.
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', () => {
...
@@ -14,10 +14,11 @@ describe('StreamSearchInput', () => {
};
};
fakeStore
=
{
fakeStore
=
{
routeParams
:
sinon
.
stub
().
returns
({}),
routeParams
:
sinon
.
stub
().
returns
({}),
isFeatureEnabled
:
sinon
.
stub
().
returns
(
false
),
};
};
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
(
mockImportedComponents
());
$imports
.
$mock
({
$imports
.
$mock
({
'../store'
:
{
useSidebarStore
:
()
=>
fakeStore
},
'../
../
store'
:
{
useSidebarStore
:
()
=>
fakeStore
},
});
});
});
});
...
@@ -42,4 +43,13 @@ describe('StreamSearchInput', () => {
...
@@ -42,4 +43,13 @@ describe('StreamSearchInput', () => {
});
});
assert
.
calledWith
(
fakeRouter
.
navigate
,
'stream'
,
{
q
:
'new-query'
});
assert
.
calledWith
(
fakeRouter
.
navigate
,
'stream'
,
{
q
:
'new-query'
});
});
});
it
(
'renders new SearchField when search panel feature is enabled'
,
()
=>
{
fakeStore
.
isFeatureEnabled
.
returns
(
true
);
const
wrapper
=
createSearchInput
();
assert
.
isFalse
(
wrapper
.
exists
(
'SearchInput'
));
assert
.
isTrue
(
wrapper
.
exists
(
'SearchField'
));
});
});
});
src/sidebar/components/test/HypothesisApp-test.js
View file @
400d3409
...
@@ -424,4 +424,16 @@ describe('HypothesisApp', () => {
...
@@ -424,4 +424,16 @@ describe('HypothesisApp', () => {
assert
.
isFalse
(
wrapper
.
find
(
'TopBar'
).
prop
(
'showShareButton'
));
assert
.
isFalse
(
wrapper
.
find
(
'TopBar'
).
prop
(
'showShareButton'
));
});
});
});
});
describe
(
'search panel'
,
()
=>
{
[
true
,
false
].
forEach
(
searchPanelEnabled
=>
{
it
(
'renders SearchPanel when feature is enabled'
,
()
=>
{
fakeStore
.
isFeatureEnabled
.
returns
(
searchPanelEnabled
);
const
wrapper
=
createComponent
();
assert
.
equal
(
wrapper
.
exists
(
'SearchPanel'
),
searchPanelEnabled
);
});
});
});
});
});
src/sidebar/components/test/SidebarView-test.js
View file @
400d3409
...
@@ -62,6 +62,7 @@ describe('SidebarView', () => {
...
@@ -62,6 +62,7 @@ describe('SidebarView', () => {
profile
:
sinon
.
stub
().
returns
({
userid
:
null
}),
profile
:
sinon
.
stub
().
returns
({
userid
:
null
}),
searchUris
:
sinon
.
stub
().
returns
([]),
searchUris
:
sinon
.
stub
().
returns
([]),
toggleFocusMode
:
sinon
.
stub
(),
toggleFocusMode
:
sinon
.
stub
(),
isFeatureEnabled
:
sinon
.
stub
().
returns
(
false
),
};
};
fakeTabsUtil
=
{
fakeTabsUtil
=
{
...
@@ -240,9 +241,20 @@ describe('SidebarView', () => {
...
@@ -240,9 +241,20 @@ describe('SidebarView', () => {
});
});
context
(
'user-focus mode'
,
()
=>
{
context
(
'user-focus mode'
,
()
=>
{
it
(
'shows filter status when focus mode configured'
,
()
=>
{
it
(
'shows
old
filter status when focus mode configured'
,
()
=>
{
const
wrapper
=
createComponent
();
const
wrapper
=
createComponent
();
assert
.
isTrue
(
wrapper
.
find
(
'FilterStatus'
).
exists
());
assert
.
isTrue
(
wrapper
.
find
(
'FilterStatus'
).
exists
());
assert
.
isFalse
(
wrapper
.
find
(
'FilterAnnotationsStatus'
).
exists
());
});
it
(
'shows filter status when focus mode configured'
,
()
=>
{
fakeStore
.
isFeatureEnabled
.
returns
(
true
);
const
wrapper
=
createComponent
();
assert
.
isFalse
(
wrapper
.
find
(
'FilterStatus'
).
exists
());
assert
.
isTrue
(
wrapper
.
find
(
'FilterAnnotationsStatus'
).
exists
());
});
});
});
});
...
@@ -264,11 +276,6 @@ describe('SidebarView', () => {
...
@@ -264,11 +276,6 @@ describe('SidebarView', () => {
});
});
});
});
it
(
'renders the filter status'
,
()
=>
{
const
wrapper
=
createComponent
();
assert
.
isTrue
(
wrapper
.
find
(
'FilterStatus'
).
exists
());
});
describe
(
'selection tabs'
,
()
=>
{
describe
(
'selection tabs'
,
()
=>
{
it
(
'renders tabs'
,
()
=>
{
it
(
'renders tabs'
,
()
=>
{
const
wrapper
=
createComponent
();
const
wrapper
=
createComponent
();
...
...
src/sidebar/components/test/TopBar-test.js
View file @
400d3409
...
@@ -21,6 +21,7 @@ describe('TopBar', () => {
...
@@ -21,6 +21,7 @@ describe('TopBar', () => {
isSidebarPanelOpen
:
sinon
.
stub
().
returns
(
false
),
isSidebarPanelOpen
:
sinon
.
stub
().
returns
(
false
),
setFilterQuery
:
sinon
.
stub
(),
setFilterQuery
:
sinon
.
stub
(),
toggleSidebarPanel
:
sinon
.
stub
(),
toggleSidebarPanel
:
sinon
.
stub
(),
isFeatureEnabled
:
sinon
.
stub
().
returns
(
false
),
};
};
fakeFrameSync
=
{
fakeFrameSync
=
{
...
@@ -213,6 +214,17 @@ describe('TopBar', () => {
...
@@ -213,6 +214,17 @@ describe('TopBar', () => {
});
});
});
});
context
(
'when sidebar panel feature is enabled'
,
()
=>
{
it
(
'displays search input in the sidebar'
,
()
=>
{
fakeStore
.
isFeatureEnabled
.
returns
(
true
);
const
wrapper
=
createTopBar
();
assert
.
isFalse
(
wrapper
.
exists
(
'SearchInput'
));
assert
.
isTrue
(
wrapper
.
exists
(
'SearchIconButton'
));
});
});
it
(
it
(
'should pass a11y checks'
,
'should pass a11y checks'
,
checkAccessibility
([
checkAccessibility
([
...
...
src/types/sidebar.ts
View file @
400d3409
...
@@ -5,7 +5,11 @@
...
@@ -5,7 +5,11 @@
/**
/**
* Defined panel components available in the sidebar.
* Defined panel components available in the sidebar.
*/
*/
export
type
PanelName
=
'help'
|
'loginPrompt'
|
'shareGroupAnnotations'
;
export
type
PanelName
=
|
'help'
|
'loginPrompt'
|
'shareGroupAnnotations'
|
'searchAnnotations'
;
/**
/**
* The top-level tabs in the sidebar interface. Used to reference which tab
* The top-level tabs in the sidebar interface. Used to reference which tab
...
...
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