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

Update in-app search (#715)

- While a search is in flight:
  - disable the "Go" button
  - show a spinner in the search results section
- Add two preset sizes (40x40 and 30x30px) to the CircleLoader
- CSS fixes to get rid of the unnecessary vertical scrollbar in an inner element within the sidebar modal
- Disabled spellchecking in input fields by default
- Added typed useAppDispatch and useAppSelector hooks, which correctly infer the types of the redux state
   and of return values of redux middleware, as suggested in the docs.
- Using a simple fetch instead of the apiService for requests to search api.
parent 3a6943a4
Pipeline #256319 passed with stages
in 6 minutes and 45 seconds
......@@ -53,7 +53,7 @@ const ExampleLinks = () => {
<div>
<div className={styles.exampleLinks__emptyTopbar} />
<div className={styles.exampleLinks}>
<CircleLoader />
<CircleLoader size="small" />
</div>
</div>
);
......
......@@ -30,8 +30,6 @@ import TrackPanelDownloads from './modal-views/TrackPanelDownloads';
import SidebarModal from 'src/shared/components/layout/sidebar-modal/SidebarModal';
import styles from './TrackPanelModal.scss';
export const TrackPanelModal = () => {
const trackPanelModalView = useSelector(getTrackPanelModalView);
const dispatch = useDispatch();
......@@ -65,11 +63,9 @@ export const TrackPanelModal = () => {
const { title, content } = getModalViewData();
return (
<section className={styles.trackPanelModal}>
<SidebarModal title={title} onClose={onClose}>
{content}
</SidebarModal>
</section>
<SidebarModal title={title} onClose={onClose}>
{content}
</SidebarModal>
);
};
......
......@@ -3,9 +3,9 @@
.inAppSearchTopInterstitial {
display: inline-grid;
grid-template-areas:
'label label'
'search-field search-button'
'hits-count .';
'label label'
'search-field search-button'
'hits-count .';
grid-template-columns: 485px auto;
column-gap: 48px;
}
......@@ -13,11 +13,10 @@
.inAppSearchTopSidebar {
display: grid;
grid-template-areas:
'label label'
'search-field search-field'
'hits-count search-button';
'label label'
'search-field search-field'
'hits-count search-button';
grid-template-columns: 1fr min-content;
align-items: top;
}
.searchFieldWrapper {
......@@ -49,6 +48,12 @@
padding: 6px 18px;
}
.spinner {
--circle-loader-display: block;
margin-top: 30px;
margin-left: 20px;
}
.hitsCount {
grid-area: hits-count;
padding: 20px 0 0 20px;
......
......@@ -17,11 +17,9 @@
import React from 'react';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { render, waitFor } from '@testing-library/react';
import { render, waitFor, act } 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';
......@@ -99,12 +97,16 @@ describe('<InAppSearch />', () => {
describe('search', () => {
beforeAll(() => {
jest
.spyOn(apiService, 'fetch')
.mockImplementation(() => Promise.resolve(brca2SearchResults));
global.fetch = jest.fn().mockImplementation(
() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(brca2SearchResults)
}) as any
);
});
it('handles query submission', () => {
it('handles query submission', async () => {
// check that correct arguments are passed to the search function
jest
.spyOn(inAppSearchSlice, 'search')
......@@ -119,7 +121,12 @@ describe('<InAppSearch />', () => {
const searchField = container.querySelector(
'.searchField input'
) as HTMLInputElement;
userEvent.type(searchField, 'BRCA2{enter}');
userEvent.type(searchField, 'BRCA2');
await act(async () => {
// this starts an async process that causes component's state update; therefore should be wrapped in 'act'
userEvent.type(searchField, '{enter}');
});
const [search1Args] = (inAppSearchSlice.search as any).mock.calls[0];
expect(search1Args).toEqual({
......@@ -141,7 +148,10 @@ describe('<InAppSearch />', () => {
// also, let's try to submit the search by pressing on the button
const submitButton = container.querySelector('button') as HTMLElement;
userEvent.click(submitButton);
await act(async () => {
// this starts an async process that causes component's state update; therefore should be wrapped in 'act'
userEvent.click(submitButton);
});
const [search2Args] = (inAppSearchSlice.search as any).mock.calls[1];
expect(search2Args).toEqual({
......
......@@ -14,11 +14,13 @@
* limitations under the License.
*/
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import classNames from 'classnames';
import upperFirst from 'lodash/upperFirst';
import { useAppDispatch } from 'src/store';
import {
search,
clearSearch,
......@@ -40,6 +42,7 @@ import QuestionButton, {
QuestionButtonOption
} from 'src/shared/components/question-button/QuestionButton';
import CloseButton from 'src/shared/components/close-button/CloseButton';
import { CircleLoader } from 'src/shared/components/loader';
import InAppSearchMatches from './InAppSearchMatches';
import type { RootState } from 'src/store';
......@@ -57,19 +60,22 @@ export type Props = {
const InAppSearch = (props: Props) => {
const { app, genomeId, mode } = props;
const [isLoading, setIsLoading] = useState(false);
const query = useSelector((state: RootState) =>
getSearchQuery(state, app, genomeId)
);
const searchResult = useSelector((state: RootState) =>
getSearchResults(state, app, genomeId)
);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const onQueryChange = (query: string) => {
dispatch(updateQuery({ app, genomeId, query }));
};
const onSearchSubmit = () => {
const onSearchSubmit = async () => {
setIsLoading(true);
const searchParams = {
app,
genome_id: genomeId,
......@@ -77,14 +83,19 @@ const InAppSearch = (props: Props) => {
page: 1,
per_page: 50
};
dispatch(search(searchParams));
if (app === 'entityViewer') {
analyticsTracking.trackEvent({
category: `${app}_${mode}_search`,
action: 'submit_search',
label: query
});
try {
await dispatch(search(searchParams));
if (app === 'entityViewer') {
analyticsTracking.trackEvent({
category: `${app}_${mode}_search`,
action: 'submit_search',
label: query
});
}
} finally {
setIsLoading(false);
}
};
......@@ -124,11 +135,11 @@ const InAppSearch = (props: Props) => {
<PrimaryButton
onClick={onSearchSubmit}
className={styles.searchButton}
isDisabled={!query}
isDisabled={!query || isLoading}
>
Go
</PrimaryButton>
{searchResult && (
{!isLoading && searchResult && (
<div className={styles.hitsCount}>
<span className={styles.hitsNumber}>
{getCommaSeparatedNumber(searchResult.meta.total_hits)}
......@@ -137,8 +148,12 @@ const InAppSearch = (props: Props) => {
</div>
)}
</div>
{searchResult && (
<InAppSearchMatches {...searchResult} app={app} mode={mode} />
{isLoading ? (
<CircleLoader className={styles.spinner} size="small" />
) : (
searchResult && (
<InAppSearchMatches {...searchResult} app={app} mode={mode} />
)
)}
</div>
);
......
......@@ -25,7 +25,9 @@ const Input = (props: Props, ref: ForwardedRef<HTMLInputElement>) => {
const { className: classNameFromProps, ...otherProps } = props;
const className = classNames(styles.input, classNameFromProps);
return <input className={className} ref={ref} {...otherProps} />;
return (
<input className={className} ref={ref} spellCheck={false} {...otherProps} />
);
};
export default forwardRef(Input);
......@@ -2,10 +2,12 @@
.wrapper {
display: grid;
grid-template-areas: "title close"
"content content";
grid-template-areas:
'title close'
'content content';
grid-template-columns: 1fr 20px;
grid-template-rows: 38px auto;
grid-template-rows: 38px 1fr;
height: 100%;
}
.title {
......@@ -14,7 +16,7 @@
margin-top: 0;
}
.content {
.content {
grid-area: content;
overflow: auto;
}
......
@import 'src/styles/common';
.circleLoader {
display: inline-block;
border: 3px solid $grey;
border-top-color: $red;
width: 40px;
height: 40px;
display: var(--circle-loader-display, inline-block);
border-radius: 100%;
animation: loader-spin 1.3s linear infinite;
border-style: solid;
border-color: $grey;
border-top-color: $red;
}
.circleLoaderDefault {
width: var(--circle-loader-diameter, 40px);
height: var(--circle-loader-diameter, 40px);
border-width: var(--circle-loader-border-width, 3px);
}
.circleLoaderSmall {
width: var(--circle-loader-diameter, 30px);
height: var(--circle-loader-diameter, 30px);
border-width: var(--circle-loader-border-width, 2px);
}
@keyframes loader-spin {
......
......@@ -16,15 +16,24 @@
import React from 'react';
import classNames from 'classnames';
import upperFirst from 'lodash/upperFirst';
import styles from './CircleLoader.scss';
type Size = 'default' | 'small';
type Props = {
className?: string;
size?: Size;
};
const CircleLoader = (props: Props) => {
const className = classNames(styles.circleLoader, props.className);
const loaderSize = props.size ?? 'default';
const className = classNames(
styles.circleLoader,
styles[`circleLoader${upperFirst(loaderSize)}`],
props.className
);
return <div className={className} />;
};
......
......@@ -16,8 +16,6 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import apiService, { HTTPMethod } from 'src/services/api-service';
import type { Strand } from 'src/shared/types/thoas/strand';
export type AppName = 'genomeBrowser' | 'entityViewer';
......@@ -85,14 +83,18 @@ export const search = createAsyncThunk(
};
const url = '/api/search';
const response: SearchResults = await apiService.fetch(url, {
const response: SearchResults = await fetch(url, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: HTTPMethod.POST,
noCache: true,
method: 'POST',
body: JSON.stringify(queryParams)
}).then((response) => {
if (!response.ok) {
throw new Error();
}
return response.json();
});
return {
......
......@@ -15,7 +15,7 @@
*/
import { configureStore } from '@reduxjs/toolkit';
import { StateType } from 'typesafe-actions';
import { useDispatch } from 'react-redux';
import { createEpicMiddleware } from 'redux-observable';
import config from 'config';
......@@ -30,8 +30,6 @@ const epicMiddleware = createEpicMiddleware();
const rootReducer = createRootReducer();
export type RootState = StateType<typeof rootReducer>;
const middleware = [
epicMiddleware,
thoasApiSlice.middleware,
......@@ -53,3 +51,8 @@ export default function getReduxStore() {
return store;
}
type AppStore = ReturnType<typeof getReduxStore>;
export type RootState = ReturnType<typeof rootReducer>;
export type AppDispatch = AppStore['dispatch'];
export const useAppDispatch = () => useDispatch<AppDispatch>();
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment