Unverified Commit 63796935 authored by Manoj Pandian Sakthivel's avatar Manoj Pandian Sakthivel Committed by GitHub

Retain EntityViewer state (#315)

The following states are now maintained in entity viewer:
- View
- Expanded transcripts
- Expanded transcript downloads
- Expanded proteins
parent b889e61d
Pipeline #88732 passed with stages
in 9 minutes and 6 seconds
......@@ -17,11 +17,18 @@
import React, { useEffect } from 'react';
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
import { connect } from 'react-redux';
import { replace, Replace } from 'connected-react-router';
import { useParams } from 'react-router-dom';
import { BreakpointWidth } from 'src/global/globalConfig';
import * as urlFor from 'src/shared/helpers/urlHelper';
import { buildFocusIdForUrl } from 'src/shared/state/ens-object/ensObjectHelpers';
import { getBreakpointWidth } from 'src/global/globalSelectors';
import {
getEntityViewerActiveGenomeId,
getEntityViewerActiveEnsObjectId
} from 'src/content/app/entity-viewer/state/general/entityViewerGeneralSelectors';
import { isEntityViewerSidebarOpen } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors';
import { fetchGenomeData } from 'src/shared/state/genome/genomeActions';
......@@ -45,7 +52,10 @@ import styles from './EntityViewer.scss';
type Props = {
isSidebarOpen: boolean;
activeGenomeId: string | null;
activeEntityId: string | null;
viewportWidth: BreakpointWidth;
replace: Replace;
setDataFromUrl: (params: EntityViewerParams) => void;
fetchGenomeData: (genomeId: string) => void;
toggleSidebar: (status?: SidebarStatus) => void;
......@@ -64,8 +74,17 @@ const client = new ApolloClient({
const EntityViewer = (props: Props) => {
const params: EntityViewerParams = useParams(); // NOTE: will likely cause a problem when server-side rendering
const { genomeId, entityId } = params;
const { activeGenomeId, activeEntityId } = props;
useEffect(() => {
if (activeGenomeId && activeEntityId && !entityId) {
const entityIdForUrl = buildFocusIdForUrl(activeEntityId);
const replacementUrl = urlFor.entityViewer({
genomeId: activeGenomeId,
entityId: entityIdForUrl
});
props.replace(replacementUrl);
}
props.setDataFromUrl(params);
}, [params.genomeId, params.entityId]);
......@@ -98,12 +117,15 @@ const EntityViewer = (props: Props) => {
const mapStateToProps = (state: RootState) => {
return {
activeGenomeId: getEntityViewerActiveGenomeId(state),
activeEntityId: getEntityViewerActiveEnsObjectId(state),
isSidebarOpen: isEntityViewerSidebarOpen(state),
viewportWidth: getBreakpointWidth(state)
};
};
const mapDispatchToProps = {
replace,
setDataFromUrl,
fetchGenomeData,
toggleSidebar
......
......@@ -56,7 +56,7 @@ const ExampleLinks = (props: ExampleLinksProps) => {
const exampleGeneId = props.exampleEntities.find(
({ type }) => type === 'gene'
)?.id;
const { loading, data } = useQuery<{ gene: ExampleGene }>(QUERY, {
const { loading, data, error } = useQuery<{ gene: ExampleGene }>(QUERY, {
variables: { id: exampleGeneId },
skip: !exampleGeneId
});
......@@ -72,7 +72,8 @@ const ExampleLinks = (props: ExampleLinksProps) => {
);
}
if (!data) {
// TODO: Data doesn't get changed when there is an error?
if (!data || error) {
return null;
}
......
......@@ -26,7 +26,10 @@ import {
getGeneViewName
} from 'src/content/app/entity-viewer/state/gene-view/entityViewerGeneViewSelectors';
import { setGeneViewName } from 'src/content/app/entity-viewer/state/gene-view/entityViewerGeneViewActions';
import { GeneViewTabName } from 'src/content/app/entity-viewer/state/gene-view/entityViewerGeneViewState';
import {
GeneViewTabName,
View
} from 'src/content/app/entity-viewer/state/gene-view/entityViewerGeneViewState';
import * as urlFor from 'src/shared/helpers/urlHelper';
import { buildFocusIdForUrl } from 'src/shared/state/ens-object/ensObjectHelpers';
......@@ -173,23 +176,22 @@ const useGeneViewRouting = () => {
const { objectId: geneId } = parseFocusIdFromUrl(entityId);
const { search } = useLocation();
// TODO: discuss – is using URLSearchParams better than using the querystring package?
const view = new URLSearchParams(search).get('view');
const view = new URLSearchParams(search).get('view') || 'transcripts';
const viewInRedux = useSelector(getGeneViewName);
const previousGenomeId = usePrevious(genomeId); // genomeId during previous render
const selectedTabs = useSelector(getSelectedGeneViewTabs);
useEffect(() => {
if (previousGenomeId !== genomeId) {
if (viewInRedux && viewInRedux !== view) {
const url = urlFor.entityViewer({
genomeId,
entityId,
view: viewInRedux
});
dispatch(replace(url));
}
} else if (viewInRedux !== view) {
dispatch(setGeneViewName(view));
if (view && viewInRedux !== view) {
dispatch(setGeneViewName(view as View));
} else {
const url = urlFor.entityViewer({
genomeId,
entityId,
view: viewInRedux
});
dispatch(replace(url));
}
}, [view, viewInRedux, genomeId, previousGenomeId]);
......
......@@ -15,6 +15,7 @@
*/
import React, { useState } from 'react';
import { connect } from 'react-redux';
import { getFeatureCoordinates } from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
import { defaultSort } from 'src/content/app/entity-viewer/shared/helpers/transcripts-sorter';
......@@ -24,47 +25,68 @@ import TranscriptsFilter from 'src/content/app/entity-viewer/gene-view/component
import { TicksAndScale } from 'src/content/app/entity-viewer/gene-view/components/base-pairs-ruler/BasePairsRuler';
import { Gene } from 'src/content/app/entity-viewer/types/gene';
import { EntityViewerGeneViewTranscriptsUI } from 'src/content/app/entity-viewer/state/gene-view/transcripts/entityViewerGeneViewTranscriptsState';
import { getTranscriptsUI } from 'src/content/app/entity-viewer/state/gene-view/transcripts/entityViewerGeneViewTranscriptsSelectors';
import { RootState } from 'src/store';
import {ReactComponent as ChevronDown} from 'static/img/shared/chevron-down.svg';
import { ReactComponent as ChevronDown } from 'static/img/shared/chevron-down.svg';
import styles from './DefaultTranscriptsList.scss';
type Props = {
gene: Gene;
rulerTicks: TicksAndScale;
transcriptsUI?: EntityViewerGeneViewTranscriptsUI;
};
const DefaultTranscriptslist = (props: Props) => {
const { gene } = props;
const sortedTranscripts = defaultSort(gene.transcripts);
const expandedTranscriptIds =
props.transcriptsUI?.expandedTranscriptIds || [];
const expandedTranscriptDownloads =
props.transcriptsUI?.expandedTranscriptDownloads || [];
const [isFilterOpen, setFilterOpen] = useState(false);
const toggleFilter = () => { setFilterOpen(!isFilterOpen); }
const toggleFilter = () => {
setFilterOpen(!isFilterOpen);
};
return (
<div>
<div className={styles.header}>
{isFilterOpen && <TranscriptsFilter toggleFilter={toggleFilter} />}
<div className={styles.row}>
{ !isFilterOpen &&
{!isFilterOpen && (
<div className={styles.filterLabel} onClick={toggleFilter}>
Filter & sort
<ChevronDown className={styles.chevron}/>
<ChevronDown className={styles.chevron} />
</div>
}
)}
<div className={styles.right}>Transcript ID</div>
</div>
</div>
<div className={styles.content}>
<StripedBackground {...props} />
{sortedTranscripts.map((transcript, index) => (
<DefaultTranscriptsListItem
key={index}
gene={gene}
transcript={transcript}
rulerTicks={props.rulerTicks}
/>
))}
{sortedTranscripts.map((transcript, index) => {
const expandTranscript =
expandedTranscriptIds?.includes(transcript.id) || false;
const expandDownload =
expandedTranscriptDownloads?.includes(transcript.id) || false;
return (
<DefaultTranscriptsListItem
key={index}
gene={gene}
transcript={transcript}
rulerTicks={props.rulerTicks}
expandTranscript={expandTranscript}
expandDownload={expandDownload}
/>
);
})}
</div>
</div>
);
......@@ -85,4 +107,8 @@ const StripedBackground = (props: Props) => {
return <div className={styles.stripedBackground}>{stripes}</div>;
};
export default DefaultTranscriptslist;
const mapStateToProps = (state: RootState) => ({
transcriptsUI: getTranscriptsUI(state)
});
export default connect(mapStateToProps)(DefaultTranscriptslist);
......@@ -17,7 +17,10 @@
import React from 'react';
import { mount } from 'enzyme';
import DefaultTranscriptListItem from './DefaultTranscriptListItem';
import {
DefaultTranscriptListItem,
DefaultTranscriptListItemProps
} from './DefaultTranscriptListItem';
import TranscriptsListItemInfo from '../transcripts-list-item-info/TranscriptsListItemInfo';
import UnsplicedTranscript from 'src/content/app/entity-viewer/gene-view/components/unspliced-transcript/UnsplicedTranscript';
......@@ -36,28 +39,49 @@ jest.mock(
() => () => <div>UnsplicedTranscript</div>
);
const toggleTranscriptInfo = jest.fn();
describe('<DefaultTranscriptListItem />', () => {
let wrapper: any;
beforeEach(() => {
const props = {
gene: createGene(),
transcript: createTranscript(),
rulerTicks: createRulerTicks()
};
wrapper = mount(<DefaultTranscriptListItem {...props} />);
afterEach(() => {
jest.resetAllMocks();
});
const defaultProps = {
gene: createGene(),
transcript: createTranscript(),
rulerTicks: createRulerTicks(),
expandTranscript: false,
expandDownload: false,
toggleTranscriptInfo: toggleTranscriptInfo
};
const renderComponent = (props?: Partial<DefaultTranscriptListItemProps>) =>
mount(<DefaultTranscriptListItem {...defaultProps} {...props} />);
it('displays unspliced transcript', () => {
wrapper = renderComponent();
expect(wrapper.exists(UnsplicedTranscript)).toBe(true);
});
it('toggles transcript item info', () => {
it('toggles transcript item info onClick', () => {
wrapper = renderComponent();
wrapper.find('.middle').simulate('click');
expect(wrapper.exists(TranscriptsListItemInfo)).toBe(true);
expect(toggleTranscriptInfo).toHaveBeenCalledTimes(1);
wrapper.find('.right').simulate('click');
expect(toggleTranscriptInfo).toHaveBeenCalledTimes(2);
});
it('hides transcript info by default', () => {
wrapper = renderComponent();
expect(wrapper.exists(TranscriptsListItemInfo)).toBe(false);
});
it('displays transcript info if expandTranscript is true', () => {
wrapper = renderComponent({ expandTranscript: true });
expect(wrapper.exists(TranscriptsListItemInfo)).toBe(true);
});
});
......@@ -14,29 +14,37 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import React from 'react';
import { getFeatureCoordinates } from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
import UnsplicedTranscript from 'src/content/app/entity-viewer/gene-view/components/unspliced-transcript/UnsplicedTranscript';
import TranscriptsListItemInfo from '../transcripts-list-item-info/TranscriptsListItemInfo';
import { toggleTranscriptInfo } from 'src/content/app/entity-viewer/state/gene-view/transcripts/entityViewerGeneViewTranscriptsActions';
import { Gene } from 'src/content/app/entity-viewer/types/gene';
import { Transcript } from 'src/content/app/entity-viewer/types/transcript';
import { TicksAndScale } from 'src/content/app/entity-viewer/gene-view/components/base-pairs-ruler/BasePairsRuler';
import transcriptsListStyles from '../DefaultTranscriptsList.scss';
import styles from './DefaultTranscriptListItem.scss';
import { connect } from 'react-redux';
type Props = {
export type DefaultTranscriptListItemProps = {
gene: Gene;
transcript: Transcript;
rulerTicks: TicksAndScale;
expandTranscript: boolean;
expandDownload: boolean;
toggleTranscriptInfo: (id: string) => void;
};
// NOTE: the width of the middle column is the same as the width of GeneOverviewImage, i.e. 695px
const DefaultTranscriptListItem = (props: Props) => {
export const DefaultTranscriptListItem = (
props: DefaultTranscriptListItemProps
) => {
const { scale } = props.rulerTicks;
const { start: geneStart } = getFeatureCoordinates(props.gene);
const { start: transcriptStart, end: transcriptEnd } = getFeatureCoordinates(
......@@ -49,16 +57,13 @@ const DefaultTranscriptListItem = (props: Props) => {
cursor: 'pointer'
};
const [shouldShowInfo, setShouldShowInfo] = useState(false);
const toggleListItemInfo = () => setShouldShowInfo(!shouldShowInfo);
return (
<div className={styles.defaultTranscriptListItem}>
<div className={transcriptsListStyles.row}>
<div className={transcriptsListStyles.left}>Left</div>
<div
className={transcriptsListStyles.middle}
onClick={toggleListItemInfo}
onClick={() => props.toggleTranscriptInfo(props.transcript.id)}
>
<div style={style}>
<UnsplicedTranscript
......@@ -70,19 +75,24 @@ const DefaultTranscriptListItem = (props: Props) => {
</div>
<div
className={transcriptsListStyles.right}
onClick={toggleListItemInfo}
onClick={() => props.toggleTranscriptInfo(props.transcript.id)}
>
<span className={styles.transcriptId}>{props.transcript.id}</span>
</div>
</div>
{shouldShowInfo ? (
{props.expandTranscript ? (
<TranscriptsListItemInfo
gene={props.gene}
transcript={props.transcript}
expandDownload={props.expandDownload}
/>
) : null}
</div>
);
};
export default DefaultTranscriptListItem;
const mapDispatchToProps = {
toggleTranscriptInfo
};
export default connect(null, mapDispatchToProps)(DefaultTranscriptListItem);
......@@ -18,7 +18,11 @@ import React from 'react';
import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router';
import TranscriptsListItemInfo from './TranscriptsListItemInfo';
import {
TranscriptsListItemInfo,
TranscriptsListItemInfoProps
} from './TranscriptsListItemInfo';
import { InstantDownloadTranscript } from 'src/shared/components/instant-download';
import ViewInApp from 'src/shared/components/view-in-app/ViewInApp';
import { createGene } from 'tests/fixtures/entity-viewer/gene';
......@@ -28,21 +32,35 @@ jest.mock('src/shared/components/view-in-app/ViewInApp', () => () => (
<div>ViewInApp</div>
));
const transcript = createTranscript();
const gene = createGene({ transcripts: [transcript] });
const expandDownload = false;
const defaultProps = {
gene,
transcript,
expandDownload,
toggleTranscriptDownload: jest.fn()
};
const renderComponent = (props?: Partial<TranscriptsListItemInfoProps>) => {
const completeProps = {
...defaultProps,
...props
};
return mount(
<MemoryRouter>
<TranscriptsListItemInfo {...completeProps} />
</MemoryRouter>
);
};
describe('<TranscriptsListItemInfo /', () => {
let wrapper: any;
const transcript = createTranscript();
const gene = createGene({ transcripts: [transcript] });
const props = {
gene,
transcript
};
beforeEach(() => {
wrapper = mount(
<MemoryRouter>
<TranscriptsListItemInfo {...props} />
</MemoryRouter>
);
wrapper = renderComponent();
});
/*
......@@ -51,9 +69,12 @@ describe('<TranscriptsListItemInfo /', () => {
* 2) we will check that protein product is present on a transcript instead of looking at CDS
*/
it('displays amino acid length when transcript has CDS', () => {
const totalExonsLength = props.transcript.exons.reduce((sum, exon) => {
return sum + exon.slice.location.end - exon.slice.location.start + 1;
}, 0);
const totalExonsLength = defaultProps.transcript.exons.reduce(
(sum, exon) => {
return sum + exon.slice.location.end - exon.slice.location.start + 1;
},
0
);
const expectedProteinLength = Math.floor(totalExonsLength / 3);
expect(wrapper.find('.topMiddle strong').text()).toMatch(
`${expectedProteinLength}`
......@@ -67,4 +88,15 @@ describe('<TranscriptsListItemInfo /', () => {
it('renders ViewInApp component', () => {
expect(wrapper.find(ViewInApp)).toHaveLength(1);
});
it('hides Download component by default', () => {
expect(wrapper.find(InstantDownloadTranscript)).toHaveLength(0);
});
it('shows Download component by default if expandDownload is true', () => {
wrapper = renderComponent({
expandDownload: true
});
expect(wrapper.find(InstantDownloadTranscript)).toHaveLength(1);
});
});
......@@ -14,9 +14,10 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import React from 'react';
import { useParams } from 'react-router-dom';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { getCommaSeparatedNumber } from 'src/shared/helpers/formatters/numberFormatter';
import { getFormattedLocation } from 'src/shared/helpers/formatters/regionFormatter';
......@@ -32,6 +33,7 @@ import { buildFocusIdForUrl } from 'src/shared/state/ens-object/ensObjectHelpers
import { InstantDownloadTranscript } from 'src/shared/components/instant-download';
import ViewInApp from 'src/shared/components/view-in-app/ViewInApp';
import { toggleTranscriptDownload } from 'src/content/app/entity-viewer/state/gene-view/transcripts/entityViewerGeneViewTranscriptsActions';
import { ReactComponent as CloseIcon } from 'static/img/shared/close.svg';
......@@ -41,19 +43,19 @@ import { Transcript } from 'src/content/app/entity-viewer/types/transcript';
import transcriptsListStyles from '../DefaultTranscriptsList.scss';
import styles from './TranscriptsListItemInfo.scss';
type ItemInfoProps = {
export type TranscriptsListItemInfoProps = {
gene: Gene;
transcript: Transcript;
expandDownload: boolean;
toggleTranscriptDownload: (id: string) => void;
};
const ItemInfo = (props: ItemInfoProps) => {
const [isDownloadShown, setIsDownloadShown] = useState(false);
export const TranscriptsListItemInfo = (
props: TranscriptsListItemInfoProps
) => {
const { transcript } = props;
const params: { [key: string]: string } = useParams();
const openDownload = () => setIsDownloadShown(true);
const closeDownload = () => setIsDownloadShown(false);
const getTranscriptLocation = () => {
const { start, end } = getFeatureCoordinates(transcript);
const chromosome = getRegionName(transcript);
......@@ -155,13 +157,18 @@ const ItemInfo = (props: ItemInfoProps) => {
</div>
</div>
<div className={styles.downloadLink}>
{isDownloadShown ? (
<CloseIcon className={styles.closeIcon} onClick={closeDownload} />
{props.expandDownload ? (
<CloseIcon
className={styles.closeIcon}
onClick={() => props.toggleTranscriptDownload(transcript.id)}
/>
) : (
<span onClick={openDownload}>Download</span>
<span onClick={() => props.toggleTranscriptDownload(transcript.id)}>
Download
</span>
)}
</div>
{isDownloadShown && renderInstantDownload(props)}
{props.expandDownload && renderInstantDownload(props)}
</div>
<div className={transcriptsListStyles.right}>
<div>{transcript.symbol}</div>
......@@ -173,7 +180,10 @@ const ItemInfo = (props: ItemInfoProps) => {
);
};
const renderInstantDownload = ({ gene, transcript }: ItemInfoProps) => {
const renderInstantDownload = ({
gene,
transcript
}: TranscriptsListItemInfoProps) => {