Unverified Commit c3f49f73 authored by Andrey Azov's avatar Andrey Azov Committed by GitHub

Set up species page (#338)

- Add the skeleton species page (/species/:genome_id)
- If species has not yet been selected, request its genome info and add it to the list of selected species
- Remove /app from the url
- Remove the Content component. Useless.
- Set up very basic redux state for the sidebar (open/close)
- Fix occasionally failing test of Checkbox
parent 0cef121e
Pipeline #94900 passed with stages
in 7 minutes and 46 seconds
......@@ -14,86 +14,71 @@
* limitations under the License.
*/
import React, { useEffect, FunctionComponent, lazy, Suspense } from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import React, { useEffect, lazy, Suspense } from 'react';
import { Route, Switch, useLocation } from 'react-router-dom';
import { connect } from 'react-redux';
import { changeCurrentApp } from 'src/header/headerActions';
import { getCurrentApp } from 'src/header/headerSelectors';
import ErrorBoundary from 'src/shared/components/error-boundary/ErrorBoundary';
import { NewTechError } from 'src/shared/components/error-screen';
import { RootState } from 'src/store';
const HomePage = lazy(() => import('../home/Home'));
const GlobalSearch = lazy(() => import('./global-search/GlobalSearch'));
const SpeciesSelector = lazy(() =>
import('./species-selector/SpeciesSelector')
);
const SpeciesPage = lazy(() => import('./species/SpeciesPage'));
const CustomDownload = lazy(() => import('./custom-download/CustomDownload'));
const Browser = lazy(() => import('./browser/Browser'));
const EntityViewer = lazy(() => import('./entity-viewer/EntityViewer'));
type StateProps = {
currentApp: string;
};
type DispatchProps = {
type AppProps = {
changeCurrentApp: (name: string) => void;
};
type OwnProps = {};
type AppProps = RouteComponentProps & StateProps & DispatchProps & OwnProps;
type AppShellProps = {
children: React.ReactNode;
};
export const AppShell = (props: AppShellProps) => {
return <>{props.children}</>;
return <div>{props.children}</div>;
};
const AppInner = (props: AppProps) => {
const { url } = props.match;
const location = useLocation();
useEffect(() => {
// remove /app/ from url to get app name
let appName = props.location.pathname.replace('/app/', '');
// check if app name still has forward slash (/) to be sure the app name is extracted
// if it isn't then remove rest of the URL and extract the app name
if (appName.indexOf('/') > -1) {
const matches = appName.match(/^[^\/]*/);
appName = matches ? matches[0] : '';
}
const appName: string = location.pathname.split('/').filter(Boolean)[0];
props.changeCurrentApp(appName);
return function unsetApp() {
props.changeCurrentApp('');
};
}, [props.match.path]);
}, [location.pathname]);
return (
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route path={`${url}/global-search`} component={GlobalSearch} />
<Route path={`${url}/species-selector`} component={SpeciesSelector} />
<Route path={`${url}/custom-download`} component={CustomDownload} />
<Route path={`/`} component={HomePage} exact />
<Route path={`/global-search`} component={GlobalSearch} />
<Route path={`/species-selector`} component={SpeciesSelector} />
<Route path={`/species/:genomeId`} component={SpeciesPage} />
<Route path={`/custom-download`} component={CustomDownload} />
<Route
path={`${url}/entity-viewer/:genomeId?/:entityId?`}
path={`/entity-viewer/:genomeId?/:entityId?`}
component={EntityViewer}
/>
<ErrorBoundary fallbackComponent={NewTechError}>
<Route path={`${url}/browser/:genomeId?`} component={Browser} />
<Route path={`/browser/:genomeId?`} component={Browser} />
</ErrorBoundary>
</Switch>
</Suspense>
);
};
export const App: FunctionComponent<AppProps> = (props: AppProps) => {
export const App = (props: AppProps) => {
return (
<AppShell>
<AppInner {...props} />
......@@ -101,12 +86,8 @@ export const App: FunctionComponent<AppProps> = (props: AppProps) => {
);
};
const mapStateToProps = (state: RootState): StateProps => ({
currentApp: getCurrentApp(state)
});
const mapDispatchToProps: DispatchProps = {
const mapDispatchToProps = {
changeCurrentApp
};
export default connect(mapStateToProps, mapDispatchToProps)(App);
export default connect(null, mapDispatchToProps)(App);
......@@ -14,22 +14,20 @@
* limitations under the License.
*/
import React, { Component } from 'react';
import React from 'react';
import SpeciesSearchPanel from 'src/content/app/species-selector/containers/species-search-panel/SpeciesSearchPanel';
import SpeciesSelectorAppBar from './components/species-selector-app-bar/SpeciesSelectorAppBar';
import PopularSpeciesPanel from 'src/content/app/species-selector/containers/popular-species-panel/PopularSpeciesPanel';
class SpeciesSelector extends Component {
public render() {
return (
<>
<SpeciesSelectorAppBar />
<SpeciesSearchPanel />
<PopularSpeciesPanel />
</>
);
}
}
const SpeciesSelector = () => {
return (
<>
<SpeciesSelectorAppBar />
<SpeciesSearchPanel />
<PopularSpeciesPanel />
</>
);
};
export default SpeciesSelector;
/**
* 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, { useEffect } from 'react';
import { useParams } from 'react-router';
import { useSelector, useDispatch } from 'react-redux';
import { BreakpointWidth } from 'src/global/globalConfig';
import { fetchGenomeData } from 'src/shared/state/genome/genomeActions';
import { getCommittedSpeciesById } from 'src/content/app/species-selector/state/speciesSelectorSelectors';
import { isSidebarOpen } from 'src/content/app/species/state/sidebar/speciesSidebarSelectors';
import { toggleSidebar } from 'src/content/app/species/state/sidebar/speciesSidebarSlice';
import SpeciesAppBar from './components/species-app-bar/SpeciesAppBar';
import {
StandardAppLayout,
SidebarBehaviourType
} from 'src/shared/components/layout';
import { RootState } from 'src/store';
type SpeciesPageParams = {
genomeId: string;
};
const SpeciesPage = () => {
const { genomeId } = useParams() as SpeciesPageParams;
const currentSpecies = useSelector((state: RootState) =>
getCommittedSpeciesById(state, genomeId)
);
const sidebarStatus = useSelector(isSidebarOpen);
const dispatch = useDispatch();
useEffect(() => {
if (!currentSpecies) {
dispatch(fetchGenomeData(genomeId));
}
}, [genomeId, currentSpecies]);
const mainContent = 'I am main content';
const sidebarContent = 'I am sidebar';
const sidebarNavigationContent = 'I am sidebar navigation';
const topbarContent = 'I am topbar content';
return (
<>
<SpeciesAppBar />
<StandardAppLayout
mainContent={mainContent}
sidebarContent={sidebarContent}
sidebarNavigation={sidebarNavigationContent}
topbarContent={topbarContent}
sidebarBehaviour={SidebarBehaviourType.SLIDEOVER}
isSidebarOpen={sidebarStatus}
onSidebarToggle={() => {
dispatch(toggleSidebar());
}}
viewportWidth={BreakpointWidth.DESKTOP}
/>
</>
);
};
export default SpeciesPage;
......@@ -14,46 +14,19 @@
* limitations under the License.
*/
import React, { FunctionComponent } from 'react';
import { Route } from 'react-router-dom';
import { connect } from 'react-redux';
import { RootState } from '../store';
import { getLaunchbarExpanded } from '../header/headerSelectors';
import Home from './home/Home';
import App from './app/App';
type StateProps = {
launchbarExpanded: boolean;
import React from 'react';
import AppBar from 'src/shared/components/app-bar/AppBar';
import { HelpPopupButton } from 'src/shared/components/help-popup';
const SpeciesAppBar = () => {
return (
<AppBar
appName="Species"
mainContent="ADD SELECTED SPECIES HERE"
aside={<HelpPopupButton slug="selecting-a-species" />}
/>
);
};
type OwnProps = {
children: React.ReactNode;
};
type ContentProps = StateProps & OwnProps;
const ContentRoutes = () => (
<>
<Route path="/" component={Home} exact={true} />
<Route path="/app" component={App} />
</>
);
export const Content: FunctionComponent<ContentProps> = (
props: ContentProps
) => {
return <main>{props.children}</main>;
};
// helper for making the Content component testable (no need to render the whole component tree nested in Content)
export const withInnerContent = (innerContent: React.ReactNode) => (
props: StateProps
) => <Content {...props}>{innerContent}</Content>;
const mapStateToProps = (state: RootState): StateProps => ({
launchbarExpanded: getLaunchbarExpanded(state)
});
export default connect(mapStateToProps)(withInnerContent(<ContentRoutes />));
export default SpeciesAppBar;
/**
* 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 { combineReducers } from 'redux';
import speciesPageSidebarReducer from './sidebar/speciesSidebarSlice';
export default combineReducers({
sidebar: speciesPageSidebarReducer
});
/**
* 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 { RootState } from 'src/store';
export const isSidebarOpen = (state: RootState) =>
state.speciesPage.sidebar.isOpen;
......@@ -14,43 +14,26 @@
* limitations under the License.
*/
import React from 'react';
import { render } from 'enzyme';
import { Content, withInnerContent } from './Content';
import styles from './Content.scss';
describe('<Content />', () => {
let contentComponent: any;
beforeEach(() => {
contentComponent = <Content launchbarExpanded={true}>foo</Content>;
});
test('renders without error', () => {
expect(() => render(contentComponent)).not.toThrow();
});
describe('<main> element', () => {
test('collapses correctly', () => {
const wrapper = render(contentComponent);
expect(wrapper.has(`.${styles.shorter}`)).toBeTruthy();
});
test('expands correctly', () => {
const wrapper = render(<Content launchbarExpanded={false}>foo</Content>);
expect(wrapper.has(`.${styles.taller}`)).toBeTruthy();
});
});
import { createSlice } from '@reduxjs/toolkit';
type SpeciesPageSidebarState = {
isOpen: boolean;
};
const initialState: SpeciesPageSidebarState = {
isOpen: true
};
const speciesPageSidebarSlice = createSlice({
name: 'species-page-sidebar',
initialState,
reducers: {
toggleSidebar(state) {
state.isOpen = !state.isOpen;
}
}
});
describe('withInnerContent', () => {
test('injects content into the Content component', () => {
const text = 'I am inner content';
const InnerContent = () => <div>{text}</div>;
const WithInnerContent = withInnerContent(<InnerContent />);
const wrapper = render(<WithInnerContent launchbarExpanded={false} />);
export const { toggleSidebar } = speciesPageSidebarSlice.actions;
expect(wrapper.text()).toBe(text);
});
});
});
export default speciesPageSidebarSlice.reducer;
......@@ -36,7 +36,7 @@ type LaunchbarButtonProps = {
const LaunchbarButton: FunctionComponent<LaunchbarButtonProps> = (
props: LaunchbarButtonProps
) => {
const pathTo = `/app/${props.app}`;
const pathTo = `/${props.app}`;
const isActive = new RegExp(`^${pathTo}`).test(props.location.pathname);
const imageButtonStatus = getImageButtonStatus({
isDisabled: !props.enabled,
......
......@@ -19,14 +19,14 @@ import { mount } from 'enzyme';
import { Root } from './Root';
import Header from '../header/Header';
import Content from '../content/Content';
import App from '../content/app/App';
import privacyBannerService from '../shared/components/privacy-banner/privacy-banner-service';
import windowService from 'src/services/window-service';
import { mockMatchMedia } from 'tests/mocks/mockWindowService';
jest.mock('../header/Header', () => () => 'Header');
jest.mock('../content/Content', () => () => 'Content');
jest.mock('../content/app/App', () => () => 'App');
jest.mock('../shared/components/privacy-banner/PrivacyBanner', () => () => (
<div className="privacyBanner">PrivacyBanner</div>
));
......@@ -53,19 +53,19 @@ describe('<Root />', () => {
jest.resetAllMocks();
});
test('contains Header', () => {
it('contains Header', () => {
expect(wrapper.contains(<Header />)).toBe(true);
});
test('contains Content', () => {
expect(wrapper.contains(<Content />)).toBe(true);
it('contains App', () => {
expect(wrapper.contains(<App />)).toBe(true);
});
test('calls updateBreakpointWidth on mount', () => {
it('calls updateBreakpointWidth on mount', () => {
expect(updateBreakpointWidth).toHaveBeenCalled();
});
test('shows privacy banner if privacy policy version is not set or if version does not match', () => {
it('shows privacy banner if privacy policy version is not set or if version does not match', () => {
jest
.spyOn(privacyBannerService, 'shouldShowBanner')
.mockImplementation(() => true);
......@@ -74,7 +74,7 @@ describe('<Root />', () => {
(privacyBannerService.shouldShowBanner as any).mockRestore();
});
test('does not show privacy banner if policy version is set', () => {
it('does not show privacy banner if policy version is set', () => {
jest
.spyOn(privacyBannerService, 'shouldShowBanner')
.mockImplementation(() => false);
......
......@@ -22,7 +22,7 @@ import { updateBreakpointWidth } from '../global/globalActions';
import { observeMediaQueries } from 'src/global/windowSizeHelpers';
import Header from '../header/Header';
import Content from '../content/Content';
import App from '../content/app/App';
import PrivacyBanner from '../shared/components/privacy-banner/PrivacyBanner';
import privacyBannerService from '../shared/components/privacy-banner/privacy-banner-service';
import ErrorBoundary from 'src/shared/components/error-boundary/ErrorBoundary';
......@@ -59,7 +59,7 @@ export const Root = (props: Props) => {
<div className={styles.root}>
<ErrorBoundary fallbackComponent={GeneralErrorScreen}>
<Header />
<Content />
<App />
{showPrivacyBanner && <PrivacyBanner closeBanner={closeBanner} />}
</ErrorBoundary>
</div>
......
......@@ -26,6 +26,7 @@ import header from '../header/headerReducer';
import ensObjects from '../shared/state/ens-object/ensObjectReducer';
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';
const rootReducer = (history: any) =>
combineReducers({
......@@ -38,6 +39,7 @@ const rootReducer = (history: any) =>
header,
router: connectRouter(history),
speciesSelector,
speciesPage,
entityViewer
});
......
......@@ -73,10 +73,10 @@ describe('<Checkbox />', () => {
it('correctly applies classes passed from the parent', () => {
const classesFromParent = {
checkboxHolder: faker.lorem.word(),
checked: faker.lorem.word(),
unchecked: faker.lorem.word(),
disabled: faker.lorem.word()
checkboxHolder: faker.random.uuid(),
checked: faker.random.uuid(),
unchecked: faker.random.uuid(),
disabled: faker.random.uuid()
};
const label = faker.lorem.words();
const labelClassFromParent = faker.lorem.word();
......
......@@ -14,4 +14,7 @@
* limitations under the License.
*/
export { default as StandardAppLayout } from './StandardAppLayout';
export {
default as StandardAppLayout,
SidebarBehaviourType
} from './StandardAppLayout';
......@@ -33,7 +33,7 @@ type EntityViewerUrlParams = {
export const browser = (params?: BrowserUrlParams) => {
if (params) {
const path = `/app/browser/${params.genomeId}`;
const path = `/browser/${params.genomeId}`;
// NOTE: if a parameter passed to queryString is null, it will still get into query;
// so assign it to undefined in order to omit it from the query
const query = queryString.stringify(
......@@ -47,7 +47,7 @@ export const browser = (params?: BrowserUrlParams) => {
);
return query ? `${path}?${query}` : path;
} else {
return `/app/browser/`;
return `/browser/`;
}
};
......@@ -59,7 +59,13 @@ export const entityViewer = (params?: EntityViewerUrlParams) => {
const genomeId = params?.genomeId || '';
const entityId = params?.entityId || '';
const path = `/app/entity-viewer/${genomeId}/${entityId}`;
let path = '/entity-viewer';
if (genomeId) {
path += `/${genomeId}`;
}
if (entityId) {
path += `/${entityId}`;
}
const query = queryString.stringify(
{
view: params?.view || undefined
......
......@@ -41,12 +41,9 @@ export const fetchGenomeInfoAsyncActions = createAsyncAction(
'genome/fetch_genome_info_failure'
)<undefined, GenomeInfoData, Error>();
export const fetchGenomeData: ActionCreator<ThunkAction<
void,
any,
null,
Action<string>
>> = (genomeId: string) => async (dispatch) => {
export const fetchGenomeData = (
genomeId: string
): ThunkAction<void, any, null, Action<string>> => async (dispatch) => {
await Promise.all([
dispatch(fetchGenomeInfo(genomeId)),
dispatch(fetchGenomeTrackCategories(genomeId)),
......
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