Unverified Commit 66a52c0d authored by Jyothish's avatar Jyothish Committed by GitHub
Browse files

EV bookmarks (#440)

* Add Bookmarks to Entity Viewer
parent 25f12055
Pipeline #135176 passed with stages
in 6 minutes and 56 seconds
......@@ -20,9 +20,4 @@
position: absolute;
right: 0;
top: 0;
img {
height: 15px;
width: 15px;
}
}
......@@ -29,14 +29,18 @@ import {
getEntityViewerActiveGenomeId,
getEntityViewerActiveEntityId
} from 'src/content/app/entity-viewer/state/general/entityViewerGeneralSelectors';
import { isEntityViewerSidebarOpen } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors';
import {
isEntityViewerSidebarOpen,
getEntityViewerSidebarModalView
} from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors';
import { setDataFromUrl } from 'src/content/app/entity-viewer/state/general/entityViewerGeneralActions';
import { toggleSidebar } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarActions';
import { StandardAppLayout } from 'src/shared/components/layout';
import EntityViewerAppBar from './shared/components/entity-viewer-app-bar/EntityViewerAppBar';
import EntityViewerSidebarToolstrip from './shared/components/entity-viewer-sidebar-toolstrip/EntityViewerSidebarToolstrip';
import EntityViewerSidebarToolstrip from './shared/components/entity-viewer-sidebar/entity-viewer-sidebar-toolstrip/EntityViewerSidebarToolstrip';
import EntityViewerSidebarModal from 'src/content/app/entity-viewer/shared/components/entity-viewer-sidebar/entity-viewer-sidebar-modal/EntityViewerSidebarModal';
import EntityViewerTopbar from './shared/components/entity-viewer-topbar/EntityViewerTopbar';
import ExampleLinks from './components/example-links/ExampleLinks';
import GeneView from './gene-view/GeneView';
......@@ -57,7 +61,10 @@ const EntityViewer = () => {
const viewportWidth = useSelector(getBreakpointWidth);
const dispatch = useDispatch();
const onSidebarToggle = () => dispatch(toggleSidebar());
const isSidebarModalOpen = Boolean(
useSelector(getEntityViewerSidebarModalView)
);
const params: EntityViewerParams = useParams(); // NOTE: will likely cause a problem when server-side rendering
const { genomeId, entityId } = params;
......@@ -69,13 +76,17 @@ const EntityViewer = () => {
genomeId: activeGenomeId,
entityId: entityIdForUrl
});
dispatch(replace(replacementUrl));
}
dispatch(setDataFromUrl(params));
}, [params.genomeId, params.entityId]);
const SideBarContent = isSidebarModalOpen ? (
<EntityViewerSidebarModal />
) : (
<GeneViewSideBar />
);
return (
<ApolloProvider client={client}>
<div className={styles.entityViewer}>
......@@ -86,11 +97,11 @@ const EntityViewer = () => {
topbarContent={
<EntityViewerTopbar genomeId={genomeId} entityId={entityId} />
}
sidebarContent={<GeneViewSideBar />}
sidebarContent={SideBarContent}
sidebarNavigation={<GeneViewSidebarTabs />}
sidebarToolstripContent={<EntityViewerSidebarToolstrip />}
isSidebarOpen={isSidebarOpen}
onSidebarToggle={onSidebarToggle}
onSidebarToggle={() => dispatch(toggleSidebar())}
isDrawerOpen={false}
viewportWidth={viewportWidth}
/>
......
......@@ -31,6 +31,9 @@ import {
View,
GeneViewTabName
} from 'src/content/app/entity-viewer/state/gene-view/view/geneViewViewSlice';
import { updatePreviouslyViewedEntities } from 'src/content/app/entity-viewer/state/bookmarks/entityViewerBookmarksSlice';
import { closeSidebarModal } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarActions';
import { isEntityViewerSidebarOpen } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors';
import * as urlFor from 'src/shared/helpers/urlHelper';
import { buildFocusIdForUrl } from 'src/shared/state/ens-object/ensObjectHelpers';
......@@ -49,12 +52,14 @@ import GeneFunction, {
import GeneRelationships from 'src/content/app/entity-viewer/gene-view/components/gene-relationships/GeneRelationships';
import ViewInApp from 'src/shared/components/view-in-app/ViewInApp';
import { CircleLoader } from 'src/shared/components/loader/Loader';
import { TicksAndScale } from 'src/content/app/entity-viewer/gene-view/components/base-pairs-ruler/BasePairsRuler';
import { FullGene } from 'src/shared/types/thoas/gene';
import styles from './GeneView.scss';
type Gene = GeneOverviewImageProps['gene'] &
type Gene = Pick<FullGene, 'symbol'> &
GeneOverviewImageProps['gene'] &
DefaultTranscriptsListProps['gene'] &
GeneFunctionProps['gene'];
......@@ -66,6 +71,7 @@ const QUERY = gql`
query Gene($genomeId: String!, $geneId: String!) {
gene(byId: { genome_id: $genomeId, stable_id: $geneId }) {
stable_id
symbol
unversioned_stable_id
version
slice {
......@@ -179,6 +185,7 @@ const GeneViewWithData = (props: GeneViewWithDataProps) => {
setBasePairsRulerTicks
] = useState<TicksAndScale | null>(null);
const dispatch = useDispatch();
const { search } = useLocation();
const view = new URLSearchParams(search).get('view');
......@@ -192,6 +199,25 @@ const GeneViewWithData = (props: GeneViewWithDataProps) => {
const focusId = buildFocusIdForUrl({ type: 'gene', objectId: geneId });
const gbUrl = urlFor.browser({ genomeId, focus: focusId });
const isSidebarOpen = useSelector(isEntityViewerSidebarOpen);
useEffect(() => {
if (!genomeId || !props.gene) {
return;
}
if (isSidebarOpen) {
dispatch(closeSidebarModal());
}
dispatch(
updatePreviouslyViewedEntities({
genomeId,
gene: props.gene
})
);
}, [genomeId, geneId]);
return (
<div className={styles.geneView} ref={targetElementRef}>
<div className={styles.featureImage}>
......
......@@ -15,32 +15,26 @@
*/
import React from 'react';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import {
openSidebar,
closeSidebarModal,
setSidebarTabName
} from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarActions';
import {
isEntityViewerSidebarOpen,
getEntityViewerSidebarTabName
getEntityViewerSidebarTabName,
getEntityViewerSidebarModalView
} from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors';
import { SidebarTabName } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarState';
import { RootState } from 'src/store';
import Tabs, { Tab } from 'src/shared/components/tabs/Tabs';
import styles from './GeneViewSidebarTabs.scss';
type Props = {
selectedTabName: SidebarTabName | null;
isSidebarOpen: boolean;
setSidebarTabName: (name: SidebarTabName) => void;
openSidebar: () => void;
};
const tabsData: Tab[] = [];
Object.values(SidebarTabName).forEach((value) =>
tabsData.push({
......@@ -50,16 +44,27 @@ Object.values(SidebarTabName).forEach((value) =>
const DEFAULT_TAB = tabsData[0].title;
const GeneViewSidebarTabs = (props: Props) => {
if (!props.selectedTabName) {
const GeneViewSidebarTabs = () => {
const isSidebarOpen = useSelector(isEntityViewerSidebarOpen);
const selectedTabName = useSelector(getEntityViewerSidebarTabName);
const isSidebarModalViewOpen = Boolean(
useSelector(getEntityViewerSidebarModalView)
);
const dispatch = useDispatch();
if (!selectedTabName) {
return null;
}
const handleTabChange = (name: string) => {
if (!props.isSidebarOpen) {
props.openSidebar();
if (!isSidebarOpen) {
dispatch(openSidebar());
}
props.setSidebarTabName(name as SidebarTabName);
if (isSidebarModalViewOpen) {
dispatch(closeSidebarModal());
}
dispatch(setSidebarTabName(name as SidebarTabName));
};
const tabClassNames = {
......@@ -69,29 +74,16 @@ const GeneViewSidebarTabs = (props: Props) => {
tabsContainer: styles.tabsContainer
};
const isSidebarActive = isSidebarOpen && !isSidebarModalViewOpen;
return (
<Tabs
tabs={tabsData}
selectedTab={
!props.isSidebarOpen ? null : props.selectedTabName || DEFAULT_TAB
}
selectedTab={isSidebarActive ? selectedTabName || DEFAULT_TAB : null}
onTabChange={handleTabChange}
classNames={tabClassNames}
/>
);
};
const mapStateToProps = (state: RootState) => ({
selectedTabName: getEntityViewerSidebarTabName(state),
isSidebarOpen: isEntityViewerSidebarOpen(state)
});
const mapDispatchToProps = {
setSidebarTabName,
openSidebar
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(GeneViewSidebarTabs);
export default GeneViewSidebarTabs;
/**
* 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 storageService, {
StorageServiceInterface
} from 'src/services/storage-service';
import { PreviouslyViewedEntities } from 'src/content/app/entity-viewer/state/bookmarks/entityViewerBookmarksSlice';
export enum StorageKeys {
BOOKMARKS = 'entityViewer.bookmarks',
PREVIOUSLY_VIEWED = 'entityViewer.previouslyViewedEntities'
}
export class EntityViewerBookmarksStorageService {
private storageService: StorageServiceInterface;
public constructor(storageService: StorageServiceInterface) {
this.storageService = storageService;
}
public getPreviouslyViewedEntities(): PreviouslyViewedEntities {
return this.storageService.get(StorageKeys.PREVIOUSLY_VIEWED) || {};
}
public updatePreviouslyViewedEntities(
activeGenomePreviouslyViewedEntities: PreviouslyViewedEntities
) {
this.storageService.update(
StorageKeys.PREVIOUSLY_VIEWED,
activeGenomePreviouslyViewedEntities
);
}
}
export default new EntityViewerBookmarksStorageService(storageService);
@import 'src/styles/common';
.entityViewerSidebarModal {
position: relative;
overflow: auto;
h3 {
font-size: 14px;
}
p {
margin-bottom: 30px;
}
}
.closeButton {
position: absolute;
right: 0;
top: 0;
}
.sectionTitle {
color: $dark-grey;
font-size: 12px;
border-bottom: 1px solid $grey;
margin-bottom: 15px;
position: relative;
margin-top: 20px;
font-weight: $light;
}
/**
* 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, { lazy, Suspense, LazyExoticComponent } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { getEntityViewerSidebarModalView } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors';
import { closeSidebarModal } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarActions';
import CloseButton from 'src/shared/components/close-button/CloseButton';
import { SidebarModalView } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarState';
import styles from './EntityViewerSidebarModal.scss';
const entityViewerSidebarModals: Record<
SidebarModalView,
LazyExoticComponent<() => JSX.Element | null>
> = {
[SidebarModalView.SEARCH]: lazy(
() => import('./modal-views/EntityViewerSearch')
),
[SidebarModalView.BOOKMARKS]: lazy(
() => import('./modal-views/EntityViewerBookmarks')
),
[SidebarModalView.DOWNLOADS]: lazy(
() => import('./modal-views/EntityViewerDownloads')
)
};
export const EntityViewerSidebarModal = () => {
const dispatch = useDispatch();
const entityViewerSidebarModalView = useSelector(
getEntityViewerSidebarModalView
);
if (!entityViewerSidebarModalView) {
return null;
}
const ModalView = entityViewerSidebarModals[entityViewerSidebarModalView];
return (
<section className={styles.entityViewerSidebarModal}>
<div className={styles.closeButton}>
<CloseButton onClick={() => dispatch(closeSidebarModal())} />
</div>
<div>
<Suspense fallback={<div>Loading...</div>}>{<ModalView />}</Suspense>
</div>
</section>
);
};
export default EntityViewerSidebarModal;
@import 'src/styles/common';
.linkHolder {
margin: 5px 0 0 20px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.previouslyViewedType {
color: $dark-grey;
margin-left: 8px;
font-size: 12px;
font-weight: $light;
}
.title{
font-size: 14px;
margin: 5px 0 10px 10px;
font-weight: $bold;
}
/**
* 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 { screen, render } from '@testing-library/react';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import { EntityViewerSidebarBookmarks } from './EntityViewerBookmarks';
jest.mock('react-router-dom', () => ({
Link: (props: any) => (
<a className="link" href={props.to}>
{props.children}
</a>
)
}));
const mockStore = configureMockStore();
const exampleEntities = [
{
id: 'human-brca2',
type: 'gene'
}
];
const previouslyViewedEntities = [
{
stable_id: 'human-fry',
label: 'FRY',
type: 'gene'
},
{
stable_id: 'human-tp53',
label: 'TP53',
type: 'gene'
}
];
const mockState = {
genome: {
genomeInfo: {
genomeInfoData: {
human: {
example_objects: exampleEntities
}
}
}
},
entityViewer: {
general: {
activeGenomeId: 'human',
activeEntityIds: {
human: 'human:gene:braf'
}
},
bookmarks: {
previouslyViewed: {
human: previouslyViewedEntities
}
}
}
};
const wrapInRedux = (state: typeof mockState = mockState) => {
return render(
<Provider store={mockStore(state)}>
<EntityViewerSidebarBookmarks />
</Provider>
);
};
describe('<EntityViewerSidebarBookmarks />', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('shows example links if they are present', () => {
wrapInRedux();
const exampleLinksSection = screen.getByTestId('example links');
const links = exampleLinksSection.querySelectorAll('a');
expect(links.length).toBe(exampleEntities.length);
});
it('shows previously viewed entities if present', () => {
wrapInRedux();
const previouslyViewedSection = screen.getByTestId(
'previously viewed links'
);
const links = previouslyViewedSection.querySelectorAll('a');
expect(links.length).toBe(previouslyViewedEntities.length);
});
});
/**
* 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.
*/