Unverified Commit 5f3b1ec6 authored by Jyothish's avatar Jyothish Committed by GitHub
Browse files

Maintain state of accordion between Transcript and Protein views (#578)

parent 7e93819e
Pipeline #194535 passed with stages
in 5 minutes and 58 seconds
......@@ -15,7 +15,10 @@
*/
import React from 'react';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import { render } from '@testing-library/react';
import thunk from 'redux-thunk';
import userEvent from '@testing-library/user-event';
import {
......@@ -28,6 +31,7 @@ import {
createGene,
createRulerTicks
} from 'tests/fixtures/entity-viewer/gene';
import { updateExpandedTranscripts } from 'ensemblRoot/src/content/app/entity-viewer/state/gene-view/transcripts/geneViewTranscriptsSlice';
jest.mock('../transcripts-list-item-info/TranscriptsListItemInfo', () => () => (
<div data-test-id="transcriptsListItemInfo">TranscriptsListItemInfo</div>
......@@ -38,7 +42,30 @@ jest.mock(
() => () => <div data-test-id="unsplicedTranscript">UnsplicedTranscript</div>
);
const toggleTranscriptInfo = jest.fn();
const mockStore = configureMockStore([thunk]);
const mockState = {
entityViewer: {
general: {
activeGenomeId: 'human',
activeEntityIds: {
human: 'gene:brca2'
}
},
geneView: {
transcripts: {
human: {
'gene:brca2': {
expandedIds: [],
expandedDownloadIds: [],
filters: [],
sortingRule: 'default'
}
}
}
}
}
};
describe('<DefaultTranscriptListItem />', () => {
beforeEach(() => {
......@@ -51,12 +78,19 @@ describe('<DefaultTranscriptListItem />', () => {
rulerTicks: createRulerTicks(),
expandTranscript: false,
expandDownload: false,
expandMoreInfo: false,
toggleTranscriptInfo: toggleTranscriptInfo
expandMoreInfo: false
};
const renderComponent = (props?: Partial<DefaultTranscriptListItemProps>) =>
render(<DefaultTranscriptListItem {...defaultProps} {...props} />);
let store: ReturnType<typeof mockStore>;
const renderComponent = (props?: Partial<DefaultTranscriptListItemProps>) => {
store = mockStore(mockState);
return render(
<Provider store={store}>
<DefaultTranscriptListItem {...defaultProps} {...props} />
</Provider>
);
};
it('displays unspliced transcript', () => {
const { queryByTestId } = renderComponent();
......@@ -71,10 +105,19 @@ describe('<DefaultTranscriptListItem />', () => {
const transcriptLabel = container.querySelector('.right') as HTMLElement;
userEvent.click(clickableArea);
expect(toggleTranscriptInfo).toHaveBeenCalledTimes(1);
expect(
store
.getActions()
.filter((action) => action.type === updateExpandedTranscripts.type)
).toHaveLength(1);
userEvent.click(transcriptLabel);
expect(toggleTranscriptInfo).toHaveBeenCalledTimes(2);
expect(
store
.getActions()
.filter((action) => action.type === updateExpandedTranscripts.type)
).toHaveLength(2);
});
it('hides transcript info by default', () => {
......
......@@ -15,7 +15,7 @@
*/
import React from 'react';
import { connect } from 'react-redux';
import { useDispatch } from 'react-redux';
import UnsplicedTranscript, {
UnsplicedTranscriptProps
......@@ -47,7 +47,6 @@ export type DefaultTranscriptListItemProps = {
expandTranscript: boolean;
expandDownload: boolean;
expandMoreInfo: boolean;
toggleTranscriptInfo: (id: string) => void;
};
export const DefaultTranscriptListItem = (
......@@ -63,6 +62,12 @@ export const DefaultTranscriptListItem = (
const transcriptStartX = scale(relativeTranscriptStart) as number;
const transcriptWidth = scale(transcriptLength) as number;
const dispatch = useDispatch();
const handleTranscriptClick = () => {
dispatch(toggleTranscriptInfo(props.transcript.stable_id));
};
return (
<div className={styles.defaultTranscriptListItem}>
<div className={transcriptsListStyles.row}>
......@@ -71,9 +76,7 @@ export const DefaultTranscriptListItem = (
<div className={transcriptsListStyles.middle}>
<div
className={styles.clickableTranscriptArea}
onClick={() =>
props.toggleTranscriptInfo(props.transcript.stable_id)
}
onClick={handleTranscriptClick}
>
<div
className={styles.transcriptWrapper}
......@@ -89,7 +92,7 @@ export const DefaultTranscriptListItem = (
</div>
<div
className={transcriptsListStyles.right}
onClick={() => props.toggleTranscriptInfo(props.transcript.stable_id)}
onClick={handleTranscriptClick}
>
<span className={styles.transcriptId}>
{props.transcript.stable_id}
......@@ -108,8 +111,4 @@ export const DefaultTranscriptListItem = (
);
};
const mapDispatchToProps = {
toggleTranscriptInfo
};
export default connect(null, mapDispatchToProps)(DefaultTranscriptListItem);
export default DefaultTranscriptListItem;
......@@ -17,7 +17,9 @@
import React from 'react';
import faker from 'faker';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store';
import { MemoryRouter } from 'react-router';
import {
......@@ -97,17 +99,43 @@ const defaultProps = {
gene,
transcript,
expandDownload,
expandMoreInfo,
toggleTranscriptDownload: jest.fn(),
toggleTranscriptMoreInfo: jest.fn(),
onProteinLinkClick: jest.fn()
expandMoreInfo
};
const mockStore = configureMockStore([thunk]);
let store: ReturnType<typeof mockStore>;
const mockState = {
entityViewer: {
general: {
activeGenomeId: 'human',
activeEntityIds: {
human: 'gene:brca2'
}
},
geneView: {
transcripts: {
human: {
'gene:brca2': {
expandedIds: [],
expandedDownloadIds: [],
expandedMoreInfoIds: [],
filters: [],
sortingRule: 'default'
}
}
}
}
}
};
const renderComponent = (props?: Partial<TranscriptsListItemInfoProps>) => {
store = mockStore(mockState);
return render(
<MemoryRouter>
<TranscriptsListItemInfo {...defaultProps} {...props} />
</MemoryRouter>
<Provider store={store}>
<MemoryRouter>
<TranscriptsListItemInfo {...defaultProps} {...props} />
</MemoryRouter>
</Provider>
);
};
......@@ -145,18 +173,6 @@ describe('<TranscriptsListItemInfo /', () => {
expect(queryByTestId('instantDownloadTranscript')).toBeTruthy();
});
it('calls correct callback when protein link is clicked', () => {
const { container } = renderComponent();
const proteinId =
defaultProps.transcript.product_generating_contexts[0].product?.stable_id;
const proteinLink = [...container.querySelectorAll('a')].find(
(link) => link.textContent === proteinId
) as HTMLElement;
userEvent.click(proteinLink);
expect(defaultProps.onProteinLinkClick).toHaveBeenCalled();
});
it('displays metadata when it is available', () => {
const { queryByText } = renderComponent({
transcript: createGencodeBasicTranscript(),
......
......@@ -17,7 +17,7 @@
import React from 'react';
import { useParams, Link } from 'react-router-dom';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { useDispatch } from 'react-redux';
import { Pick2, Pick3, Pick4 } from 'ts-multipick';
import { getCommaSeparatedNumber } from 'src/shared/helpers/formatters/numberFormatter';
......@@ -42,7 +42,6 @@ import {
toggleTranscriptDownload,
toggleTranscriptMoreInfo
} from 'src/content/app/entity-viewer/state/gene-view/transcripts/geneViewTranscriptsSlice';
import { clearExpandedProteins } from 'src/content/app/entity-viewer/state/gene-view/proteins/geneViewProteinsSlice';
import { FullGene } from 'src/shared/types/thoas/gene';
import { FullTranscript } from 'src/shared/types/thoas/transcript';
......@@ -81,9 +80,6 @@ export type TranscriptsListItemInfoProps = {
transcript: Transcript;
expandDownload: boolean;
expandMoreInfo: boolean;
toggleTranscriptDownload: (id: string) => void;
toggleTranscriptMoreInfo: (id: string) => void;
onProteinLinkClick: () => void;
};
export const TranscriptsListItemInfo = (
......@@ -93,6 +89,8 @@ export const TranscriptsListItemInfo = (
const params: { [key: string]: string } = useParams();
const { genomeId, entityId } = params;
const dispatch = useDispatch();
const getTranscriptLocation = () => {
const { start, end } = getFeatureCoordinates(transcript);
const chromosome = getRegionName(transcript);
......@@ -136,11 +134,7 @@ export const TranscriptsListItemInfo = (
proteinId: proteinStableId
});
return (
<Link onClick={() => props.onProteinLinkClick()} to={proteinViewUrl}>
{proteinStableId}
</Link>
);
return <Link to={proteinViewUrl}>{proteinStableId}</Link>;
};
const getBrowserLink = () => {
......@@ -148,6 +142,10 @@ export const TranscriptsListItemInfo = (
return urlFor.browser({ genomeId: genomeId, focus: focusIdForUrl });
};
const handleDownloadLinkClick = () => {
dispatch(toggleTranscriptDownload(transcript.stable_id));
};
const moreInfoContent = () => {
return (
<>
......@@ -221,7 +219,9 @@ export const TranscriptsListItemInfo = (
{(hasRelevantMetadata || !!transcriptCCDS) && (
<ShowHide
onClick={() => props.toggleTranscriptMoreInfo(transcript.stable_id)}
onClick={() =>
dispatch(toggleTranscriptMoreInfo(transcript.stable_id))
}
label="More information"
isExpanded={props.expandMoreInfo}
classNames={{ wrapper: styles.moreInformationLink }}
......@@ -233,7 +233,7 @@ export const TranscriptsListItemInfo = (
)}
<ShowHide
onClick={() => props.toggleTranscriptDownload(transcript.stable_id)}
onClick={handleDownloadLinkClick}
label="Download"
isExpanded={props.expandDownload}
classNames={{ wrapper: styles.downloadLink }}
......@@ -270,10 +270,4 @@ const renderInstantDownload = ({
);
};
const mapDispatchToProps = {
toggleTranscriptDownload,
toggleTranscriptMoreInfo,
onProteinLinkClick: clearExpandedProteins
};
export default connect(null, mapDispatchToProps)(TranscriptsListItemInfo);
export default TranscriptsListItemInfo;
......@@ -30,9 +30,9 @@ import {
import { getTranscriptSortingFunction } from 'src/content/app/entity-viewer/shared/helpers/transcripts-sorter';
import { filterTranscripts } from 'src/content/app/entity-viewer/shared/helpers/transcripts-filter';
import { toggleExpandedProtein } from 'src/content/app/entity-viewer/state/gene-view/proteins/geneViewProteinsSlice';
import { getExpandedTranscriptIds } from 'src/content/app/entity-viewer/state/gene-view/proteins/geneViewProteinsSelectors';
import { toggleTranscriptInfo } from 'src/content/app/entity-viewer/state/gene-view/transcripts/geneViewTranscriptsSlice';
import {
getExpandedTranscriptIds,
getFilters,
getSortingRule
} from 'src/content/app/entity-viewer/state/gene-view/transcripts/geneViewTranscriptsSelectors';
......@@ -59,7 +59,7 @@ export type ProteinsListProps = {
};
const ProteinsList = (props: ProteinsListProps) => {
const expandedTranscriptIds = useSelector(getExpandedTranscriptIds);
const expandedProteinIds = useSelector(getExpandedTranscriptIds);
const dispatch = useDispatch();
const { search } = useLocation();
const proteinIdToFocus = new URLSearchParams(search).get('protein_id');
......@@ -83,13 +83,10 @@ const ProteinsList = (props: ProteinsListProps) => {
if (!proteinCodingTranscripts.length) {
return;
}
const hasExpandedTranscripts = !!expandedTranscriptIds.length;
const firstProteinId =
proteinCodingTranscripts[0].product_generating_contexts[0].product
.stable_id;
const hasExpandedTranscripts = !!expandedProteinIds.length;
// Expand the first transcript by default
if (!hasExpandedTranscripts && !proteinIdToFocus) {
dispatch(toggleExpandedProtein(firstProteinId));
dispatch(toggleTranscriptInfo(proteinCodingTranscripts[0].stable_id));
}
}, []);
......
......@@ -24,8 +24,8 @@ import { Pick2 } from 'ts-multipick';
import * as urlFor from 'src/shared/helpers/urlHelper';
import { getProductAminoAcidLength } from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
import { toggleExpandedProtein } from 'src/content/app/entity-viewer/state/gene-view/proteins/geneViewProteinsSlice';
import { getExpandedTranscriptIds } from 'src/content/app/entity-viewer/state/gene-view/proteins/geneViewProteinsSelectors';
import { getExpandedTranscriptIds } from 'src/content/app/entity-viewer/state/gene-view/transcripts/geneViewTranscriptsSelectors';
import { toggleTranscriptInfo } from 'src/content/app/entity-viewer/state/gene-view/transcripts/geneViewTranscriptsSlice';
import ProteinsListItemInfo, {
Props as ProteinsListItemInfoProps
......@@ -89,7 +89,7 @@ const ProteinsListItem = (props: Props) => {
dispatch(replace(url));
}
dispatch(toggleExpandedProtein(product.stable_id));
dispatch(toggleTranscriptInfo(transcript.stable_id));
};
const midStyles = classNames(transcriptsListStyles.middle, styles.middle);
......@@ -103,8 +103,8 @@ const ProteinsListItem = (props: Props) => {
});
}, 100);
if (!expandedTranscriptIds.includes(proteinIdToFocus)) {
dispatch(toggleExpandedProtein(product.stable_id));
if (!expandedTranscriptIds.includes(transcript.stable_id)) {
dispatch(toggleTranscriptInfo(transcript.stable_id));
}
}
}, [proteinIdToFocus]);
......@@ -137,7 +137,7 @@ const ProteinsListItem = (props: Props) => {
<span className={styles.transcriptId}>{transcript.stable_id}</span>
</div>
</div>
{expandedTranscriptIds.includes(product.stable_id) ? (
{expandedTranscriptIds.includes(transcript.stable_id) ? (
<ProteinsListItemInfo
transcript={transcript}
trackLength={trackLength}
......
......@@ -18,10 +18,8 @@ import { combineReducers } from 'redux';
import geneViewViewReducer from './view/geneViewViewSlice';
import geneViewTranscriptsReducer from './transcripts/geneViewTranscriptsSlice';
import geneViewProteinsReducer from './proteins/geneViewProteinsSlice';
export default combineReducers({
view: geneViewViewReducer,
transcripts: geneViewTranscriptsReducer,
proteins: geneViewProteinsReducer
transcripts: geneViewTranscriptsReducer
});
/**
* 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 {
getEntityViewerActiveGenomeId,
getEntityViewerActiveEntityId
} from 'src/content/app/entity-viewer/state/general/entityViewerGeneralSelectors';
import { RootState } from 'src/store';
import { ProteinsStatePerGene } from './geneViewProteinsSlice';
const getSliceForGene = (
state: RootState
): ProteinsStatePerGene | undefined => {
const activeGenomeId = getEntityViewerActiveGenomeId(state);
const activeEntityId = getEntityViewerActiveEntityId(state);
if (!activeGenomeId || !activeEntityId) {
return;
}
return state.entityViewer.geneView.proteins[activeGenomeId]?.[activeEntityId];
};
export const getExpandedTranscriptIds = (state: RootState): string[] => {
const proteinsSlice = getSliceForGene(state);
return proteinsSlice?.expandedTranscriptIds ?? [];
};
/**
* 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 { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import {
getEntityViewerActiveGenomeId,
getEntityViewerActiveEntityId
} from 'src/content/app/entity-viewer/state/general/entityViewerGeneralSelectors';
import { getExpandedTranscriptIds } from './geneViewProteinsSelectors';
import { RootState } from 'src/store';
export type ProteinsStatePerGene = {
expandedTranscriptIds: string[];
};
export type GeneViewProteinsState = {
[genomeId: string]: {
[geneId: string]: ProteinsStatePerGene;
};
};
const defaultStatePerGene: ProteinsStatePerGene = {
expandedTranscriptIds: []
};
export const toggleExpandedProtein = (
transcriptId: string
): ThunkAction<void, any, null, Action<string>> => (
dispatch,
getState: () => RootState
) => {
const state = getState();
const activeGenomeId = getEntityViewerActiveGenomeId(state);
const activeEntityId = getEntityViewerActiveEntityId(state);
if (!activeGenomeId || !activeEntityId) {
return;
}
const expandedIds = new Set<string>(getExpandedTranscriptIds(state));
if (expandedIds.has(transcriptId)) {
expandedIds.delete(transcriptId);
} else {
expandedIds.add(transcriptId);
}
dispatch(
proteinsSlice.actions.updateExpandedProteins({
activeGenomeId,
activeEntityId,
expandedIds: [...expandedIds.values()]
})
);
};
export const clearExpandedProteins = (): ThunkAction<
void,
any,
null,
Action<string>
> => (dispatch, getState: () => RootState) => {
const state = getState();
const activeGenomeId = getEntityViewerActiveGenomeId(state);
const activeEntityId = getEntityViewerActiveEntityId(state);
if (!activeGenomeId || !activeEntityId) {
return;
}
dispatch(
proteinsSlice.actions.updateExpandedProteins({
activeGenomeId,
activeEntityId,
expandedIds: []
})
);