Unverified Commit 7214c8c0 authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Add in-app search (#521)

parent cc56383d
Pipeline #173763 passed with stages
in 4 minutes and 42 seconds
......@@ -4,17 +4,12 @@
// We should extract it in a component (or at least reuse the same CSS classes)
.topPanel {
background-color: $light-grey;
height: 235px;
padding: 65px 40px;
min-height: 235px;
padding: 40px;
padding-left: $global-padding-left;
box-shadow: 0 3px 5px $global-box-shadow;
}
.searchField {
max-width: 485px;
height: 30px;
}
.exampleLinks {
display: flex;
flex-direction: column;
......
......@@ -42,7 +42,11 @@ const BrowserInterstitial = () => {
return (
<div>
<div className={styles.topPanel}>
<InAppSearch className={styles.searchField} />
<InAppSearch
app="genomeBrowser"
genomeId={activeGenomeId}
mode="interstitial"
/>
</div>
<ExampleLinks />
</div>
......
......@@ -21,13 +21,16 @@ import {
getIsTrackPanelOpened,
getTrackPanelModalView
} from '../trackPanelSelectors';
import { getIsDrawerOpened } from 'src/content/app/browser/drawer/drawerSelectors';
import { getBrowserActiveGenomeId } from 'src/content/app/browser/browserSelectors';
import {
toggleTrackPanel,
closeTrackPanelModal,
openTrackPanelModal
} from '../trackPanelActions';
import { toggleDrawer } from 'src/content/app/browser/drawer/drawerActions';
import { getIsDrawerOpened } from 'src/content/app/browser/drawer/drawerSelectors';
import { clearSearch } from 'src/shared/state/in-app-search/inAppSearchSlice';
import ImageButton from 'src/shared/components/image-button/ImageButton';
......@@ -43,6 +46,7 @@ import { Status } from 'src/shared/types/status';
import styles from 'src/shared/components/layout/StandardAppLayout.scss';
export const TrackPanelBar = () => {
const activeGenomeId = useSelector(getBrowserActiveGenomeId);
const isTrackPanelOpened = useSelector(getIsTrackPanelOpened);
const trackPanelModalView = useSelector(getTrackPanelModalView);
const isDrawerOpened = useSelector(getIsDrawerOpened);
......@@ -57,6 +61,15 @@ export const TrackPanelBar = () => {
dispatch(toggleDrawer(false));
}
if (selectedItem === 'search') {
dispatch(
clearSearch({
app: 'genomeBrowser',
genomeId: activeGenomeId as string
})
);
}
if (selectedItem === trackPanelModalView) {
dispatch(closeTrackPanelModal());
} else {
......@@ -74,8 +87,8 @@ export const TrackPanelBar = () => {
<>
<div className={styles.sidebarIcon} key="search">
<ImageButton
status={Status.DISABLED}
description="Track search"
status={getViewIconStatus('search')}
description="Search"
onClick={() => toggleModalView('search')}
image={searchIcon}
/>
......
......@@ -2,9 +2,9 @@
.trackPanelModal {
background: $white;
font-weight: $light;
position: relative;
overflow: auto;
height: 100%;
h3 {
font-size: 14px;
......
......@@ -15,13 +15,27 @@
*/
import React from 'react';
import { useSelector } from 'react-redux';
import { getBrowserActiveGenomeId } from 'src/content/app/browser/browserSelectors';
import InAppSearch from 'src/shared/components/in-app-search/InAppSearch';
const TrackPanelSearch = () => {
const activeGenomeId = useSelector(getBrowserActiveGenomeId);
return (
<section className="trackPanelSearch">
<h3>Search</h3>
<p>Quickly find the tracks you want to show or hide in the browser</p>
<p>Not ready yet &hellip;</p>
<div>
{activeGenomeId && (
<InAppSearch
app="genomeBrowser"
genomeId={activeGenomeId}
mode="sidebar"
/>
)}
</div>
</section>
);
};
......
......@@ -4,13 +4,8 @@
// We should extract it in a component (or at least reuse the same CSS classes)
.topPanel {
background-color: $light-grey;
height: 235px;
padding: 65px 40px;
min-height: 235px;
padding: 40px;
padding-left: $global-padding-left;
box-shadow: 0 3px 5px $global-box-shadow;
}
.searchField {
max-width: 485px;
height: 30px;
}
......@@ -35,7 +35,11 @@ const EntityViewerInterstitial = () => {
return (
<div>
<div className={styles.topPanel}>
<InAppSearch className={styles.searchField} />
<InAppSearch
app="entityViewer"
mode="interstitial"
genomeId={activeGenomeId}
/>
</div>
<ExampleLinks />
</div>
......
......@@ -2,6 +2,7 @@
.entityViewerSidebarModal {
position: relative;
height: 100%;
overflow: auto;
h3 {
......
......@@ -15,13 +15,29 @@
*/
import React from 'react';
import { useSelector } from 'react-redux';
const EntityViewerSidebarSearch = () => (
<section>
<h3>Search</h3>
<p>Quickly search in entity viewer</p>
<p>Not ready yet &hellip;</p>
</section>
);
import { getEntityViewerActiveGenomeId } from 'src/content/app/entity-viewer/state/general/entityViewerGeneralSelectors';
import InAppSearch from 'src/shared/components/in-app-search/InAppSearch';
const EntityViewerSidebarSearch = () => {
const activeGenomeId = useSelector(getEntityViewerActiveGenomeId);
return (
<section>
<h3>Search</h3>
<div>
{activeGenomeId && (
<InAppSearch
app="entityViewer"
genomeId={activeGenomeId}
mode="sidebar"
/>
)}
</div>
</section>
);
};
export default EntityViewerSidebarSearch;
......@@ -66,10 +66,10 @@ export const EntityViewerSidebarToolstrip = () => {
return (
<>
<ImageButton
status={Status.DISABLED}
status={getViewIconStatus(SidebarModalView.SEARCH)}
description="Search"
className={styles.sidebarIcon}
onClick={noop}
onClick={() => toggleModalView(SidebarModalView.SEARCH)}
image={searchIcon}
/>
<ImageButton
......
......@@ -24,6 +24,7 @@ import customDownload from '../content/app/custom-download/state/customDownloadR
import global from '../global/globalReducer';
import header from '../header/headerReducer';
import ensObjects from '../shared/state/ens-object/ensObjectReducer';
import inAppSearch from '../shared/state/in-app-search/inAppSearchSlice';
import speciesSelector from '../content/app/species-selector/state/speciesSelectorReducer';
import entityViewer from 'src/content/app/entity-viewer/state/entityViewerReducer';
import speciesPage from 'src/content/app/species/state/index';
......@@ -34,6 +35,7 @@ const createRootReducer = (history: any) =>
drawer,
customDownload,
ensObjects,
inAppSearch,
genome,
global,
header,
......
......@@ -53,6 +53,7 @@ const Button = (props: Props) => {
<button
className={classNames(styles.button, props.className)}
onClick={handleClick}
disabled={props.isDisabled}
>
{props.children}
</button>
......
@import 'src/styles/common';
.inAppSearchTopInterstitial {
display: inline-grid;
grid-template-areas:
'label label'
'search-field search-button'
'hits-count .';
grid-template-columns: 485px auto;
column-gap: 48px;
}
.inAppSearchTopSidebar {
display: grid;
grid-template-areas:
'label label'
'search-field search-field'
'hits-count search-button';
grid-template-columns: 1fr min-content;
align-items: top;
}
.searchFieldWrapper {
grid-area: search-field;
padding: 4px;
box-shadow: inset 2px 2px 4px -2px $dark-grey;
background: white;
&Interstitial {
height: 30px;
}
// nesting to increase specificity of the selector
.searchField {
border: none;
......@@ -13,11 +38,97 @@
}
}
// TODO: remove this temporary class when in-app search becomes functional
.fauxSearchField {
color: $grey;
height: 36px;
box-shadow: inset 1px 1px 3px 0 $dark-grey;
.label {
grid-area: label;
color: $dark-grey;
margin-bottom: 15px;
}
.searchButton {
grid-area: search-button;
}
.inAppSearchTopSidebar .searchButton {
margin-top: 18px;
}
.hitsCount {
grid-area: hits-count;
padding: 20px 0 0 20px;
.hitsNumber {
font-weight: $bold;
}
}
.searchMatches {
display: flex;
padding-left: 20px;
flex-direction: column;
align-items: flex-start;
margin-top: 30px;
}
.searchMatch {
position: relative;
display: inline-block;
color: $blue;
cursor: pointer;
line-height: 1;
&:not(:last-child) {
margin-bottom: 1rem;
}
& > span:nth-child(2) {
margin-left: 0.6rem;
}
.searchMatchAnchor {
position: absolute;
height: 100%;
&Interstitial {
right: -1.5ch;
}
&Sidebar {
left: 3ch;
}
}
}
.tooltip {
background: $black;
padding: 12px 20px;
width: 485px;
filter: drop-shadow(2px 2px 3px $shadow-color);
color: $white;
}
.tooltipTip {
fill: $black;
}
.tooltipContent {
font-weight: $light;
& > div:first-child span:first-child {
margin-right: 28px;
}
& > div:nth-child(3) span:first-child {
font-weight: $bold;
}
& > div:not(:last-child) {
margin-bottom: 6px;
}
& > div:last-child {
margin-top: 28px;
}
.transcriptsCount {
margin-right: 1ch;
}
}
/**
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import apiService from 'src/services/api-service';
import * as inAppSearchSlice from 'src/shared/state/in-app-search/inAppSearchSlice';
import inAppSearchReducer from 'src/shared/state/in-app-search/inAppSearchSlice';
import InAppSearch, { Props as InAppSearchProps } from './InAppSearch';
import { brca2SearchResults } from './test/response-fixture';
const rootReducer = {
inAppSearch: inAppSearchReducer
};
const getStore = (initialState = {}) => {
return configureStore({
reducer: rootReducer,
devTools: false,
preloadedState: initialState
});
};
const defaultProps: InAppSearchProps = {
app: 'genomeBrowser',
genomeId: 'human',
mode: 'interstitial'
};
describe('<InAppSearch />', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('initial rendering', () => {
it('renders correctly before the request', () => {
const { container, queryByText } = render(
<Provider store={getStore()}>
<InAppSearch {...defaultProps} />
</Provider>
);
const searchField = container.querySelector('.searchField');
expect(searchField).toBeTruthy();
const label = queryByText('Find a gene in this species');
expect(label).toBeTruthy();
const button = container.querySelector('button');
expect(button).toBeTruthy();
expect(button?.getAttribute('disabled')).not.toBe(null);
});
it('uses the mode property correctly', () => {
const { rerender, queryByTestId } = render(
<Provider store={getStore()}>
<InAppSearch {...defaultProps} />
</Provider>
);
let inAppSearchTop = queryByTestId('in-app search top') as HTMLElement;
expect(
inAppSearchTop.classList.contains('inAppSearchTopInterstitial')
).toBe(true);
const sidebarProps = { ...defaultProps, mode: 'sidebar' as const };
rerender(
<Provider store={getStore()}>
<InAppSearch {...sidebarProps} />
</Provider>
);
inAppSearchTop = queryByTestId('in-app search top') as HTMLElement;
expect(inAppSearchTop.classList.contains('inAppSearchTopSidebar')).toBe(
true
);
});
});
describe('search', () => {
beforeAll(() => {
jest
.spyOn(apiService, 'fetch')
.mockImplementation(() => Promise.resolve(brca2SearchResults));
});
it('handles query submission', () => {
// check that correct arguments are passed to the search function
jest
.spyOn(inAppSearchSlice, 'search')
.mockImplementation(() => ({ type: 'action' } as any));
const { container, rerender } = render(
<Provider store={getStore()}>
<InAppSearch {...defaultProps} />
</Provider>
);
const searchField = container.querySelector(
'.searchField input'
) as HTMLInputElement;
userEvent.type(searchField, 'BRCA2{enter}');
const [search1Args] = (inAppSearchSlice.search as any).mock.calls[0];
expect(search1Args).toEqual({
app: defaultProps.app,
genome_id: defaultProps.genomeId,
query: 'BRCA2',
page: 1,
per_page: 50
});
// let's try passing a different app name and a different genome id in props
rerender(
<Provider store={getStore()}>
<InAppSearch {...defaultProps} app="entityViewer" genomeId="wheat" />
</Provider>
);
userEvent.clear(searchField);
userEvent.type(searchField, 'Traes');
// also, let's try to submit the search by pressing on the button
const submitButton = container.querySelector('button') as HTMLElement;
userEvent.click(submitButton);
const [search2Args] = (inAppSearchSlice.search as any).mock.calls[1];
expect(search2Args).toEqual({
app: 'entityViewer',
genome_id: 'wheat',
query: 'Traes',
page: 1,
per_page: 50
});
(inAppSearchSlice.search as any).mockRestore();
});
it('displays search results', async () => {
const { container } = render(
<Provider store={getStore()}>
<InAppSearch {...defaultProps} />
</Provider>
);
const searchField = container.querySelector(
'.searchField input'
) as HTMLInputElement;
userEvent.type(searchField, 'BRCA2{enter}');
await waitFor(() => {
const hitsCount = container.querySelector('.hitsCount');
expect(hitsCount).toBeTruthy();
});
// now we can test the results in the DOM
expect(container.querySelector('.hitsCount')?.textContent).toBe(
'12 genes'
); // as defined in the fixture
expect(container.querySelectorAll('.searchMatch').length).toBe(10); // as defined in the fixture; 10 matches per page
});
});
});
......@@ -14,54 +14,130 @@
* limitations under the License.