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

Refactor/tracks persistence (#98)

- Use redux to keep tracks state
- Pass non-default track status to genome browser
   if a track list item has a non-default — which currently means, inactive —
   status upon mounting of the TrackListItem component, send an event
   to genome browser to show/hide the track accordingly
parent d777e2b4
Pipeline #24631 passed with stages
in 3 minutes and 39 seconds
......@@ -339,10 +339,7 @@ export const Browser: FunctionComponent<BrowserProps> = (
/>
</div>
</animated.div>
<TrackPanel
browserRef={browserRef}
trackStates={trackStatesFromStorage}
/>
<TrackPanel browserRef={browserRef} />
</div>
</section>
)}
......
......@@ -78,12 +78,7 @@ describe('BrowserStorageService', () => {
}
};
browserStorageService.saveTrackStates(
'homo_sapiens38',
'Genes & transcripts',
'gene-pc-fwd',
ImageButtonStatus.INACTIVE
);
browserStorageService.saveTrackStates(toggledTrack);
expect(mockStorageService.save).toHaveBeenCalledWith(
StorageKeys.TRACK_STATES,
......
......@@ -70,24 +70,7 @@ export class BrowserStorageService {
return this.storageService.get(StorageKeys.TRACK_STATES) || {};
}
public saveTrackStates(
genomeId: string,
categoryName: string,
trackName: string,
trackStatus: ImageButtonStatus
) {
const trackStates = this.getTrackStates();
if (!trackStates[genomeId]) {
trackStates[genomeId] = {};
}
if (!trackStates[genomeId][categoryName]) {
trackStates[genomeId][categoryName] = {};
}
trackStates[genomeId][categoryName][trackName] = trackStatus;
public saveTrackStates(trackStates: TrackStates) {
this.storageService.save(StorageKeys.TRACK_STATES, trackStates);
}
......
import { createAction } from 'typesafe-actions';
import { createAction, createStandardAction } from 'typesafe-actions';
import { Dispatch, ActionCreator, Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
......@@ -8,12 +8,22 @@ import { BrowserNavStates, ChrLocation, CogList } from './browserState';
import {
getBrowserActiveGenomeId,
getBrowserActiveEnsObjectId,
getBrowserTrackStates,
getDefaultChrLocation,
getChrLocation
} from './browserSelectors';
import { getBrowserAnalyticsObject } from 'src/analyticsHelper';
import browserStorageService from './browser-storage-service';
import { RootState } from 'src/store';
import { ImageButtonStatus } from 'src/shared/image-button/ImageButton';
import { TrackStates } from './track-panel/trackPanelConfig';
export type UpdateTrackStatesPayload = {
genomeId: string;
categoryName: string;
trackId: string;
status: ImageButtonStatus; // TODO: update types so that actions do not depend on ImageButton types
};
export const updateBrowserActivated = createAction(
'browser/update-browser-activated',
......@@ -79,6 +89,29 @@ export const updateBrowserActiveEnsObjectIdAndSave: ActionCreator<
};
};
export const updateTrackStates = createStandardAction(
'browser/update-tracks-state'
)<TrackStates>();
export const updateTrackStatesAndSave: ActionCreator<
ThunkAction<void, any, null, Action<string>>
> = (payload: UpdateTrackStatesPayload) => (
dispatch: Dispatch,
getState: () => RootState
) => {
const stateFragment = {
[payload.genomeId]: {
[payload.categoryName]: {
[payload.trackId]: payload.status
}
}
};
dispatch(updateTrackStates(stateFragment));
const trackStates = getBrowserTrackStates(getState());
browserStorageService.saveTrackStates(trackStates);
};
export const toggleBrowserNav = createAction(
'browser/toggle-browser-navigation',
(resolve) => {
......
import { combineReducers } from 'redux';
import { ActionType, getType } from 'typesafe-actions';
import merge from 'lodash/merge';
import { RootAction } from 'src/objects';
import * as browserActions from './browserActions';
......@@ -54,6 +55,11 @@ export function browserEntity(
return { ...state, activeGenomeId: action.payload };
case getType(browserActions.updateBrowserActiveEnsObjectId):
return { ...state, activeEnsObjectId: action.payload };
case getType(browserActions.updateTrackStates):
return {
...state,
trackStates: merge({}, state.trackStates, action.payload)
};
default:
return state;
}
......
......@@ -14,6 +14,9 @@ export const getBrowserActiveGenomeId = (state: RootState): string =>
export const getBrowserActiveEnsObjectId = (state: RootState) =>
state.browser.browserEntity.activeEnsObjectId;
export const getBrowserTrackStates = (state: RootState) =>
state.browser.browserEntity.trackStates;
export const getBrowserQueryParams = (
state: RootState
): { [key: string]: string } => getQueryParamsMap(state.router.location.search);
......
import browserStorageService from './browser-storage-service';
import { TrackStates } from './track-panel/trackPanelConfig';
const activeGenomeId = browserStorageService.getActiveGenomeId();
const activeEnsObjectId = browserStorageService.getActiveEnsObjectId();
const trackStates = browserStorageService.getTrackStates();
const chrLocation = browserStorageService.getChrLocation();
const defaultChrLocation = browserStorageService.getDefaultChrLocation();
......@@ -42,11 +45,13 @@ export const defaultBrowserState: BrowserState = {
export type BrowserEntityState = Readonly<{
activeGenomeId: string; // FIXME this should be nullable
activeEnsObjectId: { [genomeId: string]: string }; // FIXME this should be nullable
trackStates: TrackStates;
}>;
export const defaultBrowserEntityState: BrowserEntityState = {
activeGenomeId, // FIXME this can be null
activeEnsObjectId // FIXME this can be null
activeEnsObjectId, // FIXME this can be null
trackStates
};
export type BrowserNavState = Readonly<{
......
......@@ -8,6 +8,10 @@ import TrackPanelModal from './track-panel-modal/TrackPanelModal';
import Drawer from '../drawer/Drawer';
import { RootState } from 'src/store';
import {
updateTrackStatesAndSave,
UpdateTrackStatesPayload
} from 'src/content/app/browser/browserActions';
import {
toggleTrackPanel,
closeTrackPanelModal,
......@@ -24,7 +28,8 @@ import { getDrawerView, getDrawerOpened } from '../drawer/drawerSelectors';
import {
getBrowserActivated,
getDefaultChrLocation,
getBrowserActiveGenomeId
getBrowserActiveGenomeId,
getBrowserTrackStates
} from '../browserSelectors';
import {
getExampleEnsObjects,
......@@ -38,7 +43,7 @@ import { BreakpointWidth } from 'src/global/globalConfig';
import { TrackType, TrackStates } from './trackPanelConfig';
import { GenomeTrackCategory } from 'src/genome/genomeTypes';
import { getGenomeTrackCategories } from 'src/genome/genomeSelectors';
import { getGenomeTrackCategoriesById } from 'src/genome/genomeSelectors';
import {
EnsObject,
EnsObjectTrack,
......@@ -63,6 +68,7 @@ type StateProps = {
trackPanelModalOpened: boolean;
trackPanelModalView: string;
trackPanelOpened: boolean;
trackStates: TrackStates;
};
type DispatchProps = {
......@@ -71,11 +77,11 @@ type DispatchProps = {
openTrackPanelModal: (trackPanelModalView: string) => void;
toggleDrawer: (drawerOpened: boolean) => void;
toggleTrackPanel: (trackPanelOpened?: boolean) => void;
updateTrackStates: (payload: UpdateTrackStatesPayload) => void;
};
type OwnProps = {
browserRef: RefObject<HTMLDivElement>;
trackStates: TrackStates;
};
type TrackPanelProps = StateProps & DispatchProps & OwnProps;
......@@ -143,6 +149,7 @@ const TrackPanel: FunctionComponent<TrackPanelProps> = (
trackStates={props.trackStates}
genomeTrackCategories={props.genomeTrackCategories}
updateDrawerView={props.changeDrawerView}
updateTrackStates={props.updateTrackStates}
/>
{props.trackPanelModalOpened ? (
......@@ -159,32 +166,36 @@ const TrackPanel: FunctionComponent<TrackPanelProps> = (
);
};
const mapStateToProps = (state: RootState): StateProps => ({
activeGenomeId: getBrowserActiveGenomeId(state),
breakpointWidth: getBreakpointWidth(state),
browserActivated: getBrowserActivated(state),
defaultChrLocation: getDefaultChrLocation(state),
drawerOpened: getDrawerOpened(state),
drawerView: getDrawerView(state),
ensObjectInfo: getEnsObjectInfo(state),
ensObjectTracks: getEnsObjectTracks(state),
exampleEnsObjects: getExampleEnsObjects(state),
launchbarExpanded: getLaunchbarExpanded(state),
selectedBrowserTab: getSelectedBrowserTab(state),
genomeTrackCategories: getGenomeTrackCategories(state)[
getBrowserActiveGenomeId(state)
],
trackPanelModalOpened: getTrackPanelModalOpened(state),
trackPanelModalView: getTrackPanelModalView(state),
trackPanelOpened: getTrackPanelOpened(state)
});
const mapStateToProps = (state: RootState): StateProps => {
const activeGenomeId = getBrowserActiveGenomeId(state);
return {
activeGenomeId,
breakpointWidth: getBreakpointWidth(state),
browserActivated: getBrowserActivated(state),
defaultChrLocation: getDefaultChrLocation(state),
drawerOpened: getDrawerOpened(state),
drawerView: getDrawerView(state),
ensObjectInfo: getEnsObjectInfo(state),
ensObjectTracks: getEnsObjectTracks(state),
exampleEnsObjects: getExampleEnsObjects(state),
launchbarExpanded: getLaunchbarExpanded(state),
selectedBrowserTab: getSelectedBrowserTab(state),
genomeTrackCategories: getGenomeTrackCategoriesById(state, activeGenomeId),
trackPanelModalOpened: getTrackPanelModalOpened(state),
trackPanelModalView: getTrackPanelModalView(state),
trackPanelOpened: getTrackPanelOpened(state),
trackStates: getBrowserTrackStates(state)
};
};
const mapDispatchToProps: DispatchProps = {
changeDrawerView,
closeTrackPanelModal,
openTrackPanelModal,
toggleDrawer,
toggleTrackPanel
toggleTrackPanel,
updateTrackStates: updateTrackStatesAndSave
};
export default connect(
......
import React, {
FunctionComponent,
RefObject,
useCallback,
useState,
useEffect
} from 'react';
import React, { FunctionComponent, RefObject } from 'react';
import get from 'lodash/get';
import TrackPanelListItem from './TrackPanelListItem';
import { UpdateTrackStatesPayload } from 'src/content/app/browser/browserActions';
import { TrackType, TrackStates } from '../trackPanelConfig';
import { BrowserChrLocation } from '../../browserState';
......@@ -30,40 +26,34 @@ type TrackPanelListProps = {
genomeTrackCategories: GenomeTrackCategory[];
trackStates: TrackStates;
updateDrawerView: (drawerView: string) => void;
updateTrackStates: (payload: UpdateTrackStatesPayload) => void;
};
const TrackPanelList: FunctionComponent<TrackPanelListProps> = (
props: TrackPanelListProps
) => {
const [currentTrackCategories, setCurrentTrackCategories] = useState<
GenomeTrackCategory[]
>([]);
useEffect(() => {
const selectedBrowserTab =
props.selectedBrowserTab[props.activeGenomeId] || TrackType.GENOMIC;
if (props.genomeTrackCategories && props.genomeTrackCategories.length > 0) {
setCurrentTrackCategories(
props.genomeTrackCategories.filter((category: GenomeTrackCategory) =>
category.types.includes(selectedBrowserTab)
)
);
}
}, [props.selectedBrowserTab]);
const {
activeGenomeId,
selectedBrowserTab: selectedBrowserTabs,
genomeTrackCategories
} = props;
const selectedBrowserTab =
selectedBrowserTabs[activeGenomeId] || TrackType.GENOMIC;
const currentTrackCategories = genomeTrackCategories.filter(
(category: GenomeTrackCategory) =>
category.types.includes(selectedBrowserTab)
);
const changeDrawerView = useCallback(
(currentTrack: string) => {
const { drawerView, toggleDrawer, updateDrawerView } = props;
const changeDrawerView = (currentTrack: string) => {
const { drawerView, toggleDrawer, updateDrawerView } = props;
updateDrawerView(currentTrack);
updateDrawerView(currentTrack);
if (!drawerView) {
toggleDrawer(true);
}
},
[props.drawerView]
);
if (!drawerView) {
toggleDrawer(true);
}
};
const getTrackPanelListClasses = () => {
const heightClass: string = props.launchbarExpanded
......@@ -73,23 +63,9 @@ const TrackPanelList: FunctionComponent<TrackPanelListProps> = (
return `${styles.trackPanelList} ${heightClass}`;
};
const getDefaultTrackStatus = (categoryName: string, trackName: string) => {
let trackStatus = ImageButtonStatus.ACTIVE;
if (!props.trackStates[props.activeGenomeId]) {
return trackStatus;
}
const statesOfCategory =
props.trackStates[props.activeGenomeId][categoryName];
if (statesOfCategory && statesOfCategory[trackName]) {
trackStatus =
statesOfCategory[trackName] === 'active'
? ImageButtonStatus.ACTIVE
: ImageButtonStatus.INACTIVE;
}
return trackStatus;
// TODO: get default track status properly if it can ever be inactive
const getDefaultTrackStatus = () => {
return ImageButtonStatus.ACTIVE;
};
const getTrackListItem = (
......@@ -99,18 +75,28 @@ const TrackPanelList: FunctionComponent<TrackPanelListProps> = (
if (!track) {
return;
}
const { track_id } = track;
const defaultTrackStatus = getDefaultTrackStatus();
const trackStatus = get(
props.trackStates,
`${activeGenomeId}.${categoryName}.${track_id}`,
defaultTrackStatus
);
return (
<TrackPanelListItem
activeGenomeId={props.activeGenomeId}
browserRef={props.browserRef}
categoryName={categoryName}
defaultTrackStatus={getDefaultTrackStatus(categoryName, track.track_id)}
defaultTrackStatus={defaultTrackStatus as ImageButtonStatus}
trackStatus={trackStatus as ImageButtonStatus}
drawerOpened={props.drawerOpened}
drawerView={props.drawerView}
key={track.track_id}
track={track}
updateDrawerView={changeDrawerView}
updateTrackStates={props.updateTrackStates}
>
{track.child_tracks &&
track.child_tracks.map((childTrack: EnsObjectTrack) =>
......
......@@ -10,6 +10,7 @@ import React, {
import get from 'lodash/get';
import { TrackItemColour } from '../trackPanelConfig';
import { UpdateTrackStatesPayload } from 'src/content/app/browser/browserActions';
import chevronDownIcon from 'static/img/shared/chevron-down.svg';
import chevronUpIcon from 'static/img/shared/chevron-up.svg';
......@@ -29,11 +30,13 @@ type TrackPanelListItemProps = {
browserRef: RefObject<HTMLDivElement>;
categoryName: string;
children?: ReactNode[];
trackStatus: ImageButtonStatus;
defaultTrackStatus: ImageButtonStatus;
drawerOpened: boolean;
drawerView: string;
track: EnsObjectTrack;
updateDrawerView: (drawerView: string) => void;
updateTrackStates: (payload: UpdateTrackStatesPayload) => void;
};
// delete this when there is a better place to put this
......@@ -43,23 +46,16 @@ const TrackPanelListItem: FunctionComponent<TrackPanelListItemProps> = (
props: TrackPanelListItemProps
) => {
const [expanded, setExpanded] = useState(true);
const [trackStatus, setTrackStatus] = useState(props.defaultTrackStatus);
const { activeGenomeId, browserRef, categoryName, drawerView, track } = props;
// FIXME: rather reading trackstates from localStorage (multiple times!), they should be passed as props
// (and stored in redux store; localStorage should be used to store the relevant part of redux store between browser reloads)
const { trackStatus } = props;
useEffect(() => {
const trackStates = browserStorageService.getTrackStates();
const storedTrackStatus: ImageButtonStatus = get(
trackStates,
`${activeGenomeId}.${categoryName}.${track.track_id}`,
ImageButtonStatus.ACTIVE
) as ImageButtonStatus;
if (storedTrackStatus && storedTrackStatus !== trackStatus) {
setTrackStatus(storedTrackStatus);
const { defaultTrackStatus } = props;
if (trackStatus !== defaultTrackStatus) {
updateGenomeBrowser(trackStatus);
}
}, [props.activeGenomeId]);
}, []);
useEffect(() => {
const trackToggleStates = browserStorageService.getTrackListToggleStates();
......@@ -125,8 +121,24 @@ const TrackPanelListItem: FunctionComponent<TrackPanelListItemProps> = (
};
const toggleTrack = () => {
const newStatus =
trackStatus === ImageButtonStatus.ACTIVE
? ImageButtonStatus.INACTIVE
: ImageButtonStatus.ACTIVE;
updateGenomeBrowser(newStatus);
props.updateTrackStates({
genomeId: activeGenomeId,
categoryName,
trackId: track.track_id,
status: newStatus
});
};
const updateGenomeBrowser = (status: ImageButtonStatus) => {
const currentTrackStatus =
trackStatus === ImageButtonStatus.ACTIVE ? 'off' : 'on';
status === ImageButtonStatus.ACTIVE ? 'on' : 'off';
const trackEvent = new CustomEvent('bpane', {
bubbles: true,
......@@ -138,19 +150,6 @@ const TrackPanelListItem: FunctionComponent<TrackPanelListItemProps> = (
if (browserRef.current) {
browserRef.current.dispatchEvent(trackEvent);
}
const newImageButtonStatus =
trackStatus === ImageButtonStatus.ACTIVE
? ImageButtonStatus.INACTIVE
: ImageButtonStatus.ACTIVE;
browserStorageService.saveTrackStates(
activeGenomeId,
categoryName,
track.track_id,
newImageButtonStatus
);
setTrackStatus(newImageButtonStatus);
};
return (
......
......@@ -26,9 +26,9 @@ export type TrackPanelIcons = {
};
export type TrackStates = {
[key: string]: {
[key: string]: {
[genomeId: string]: ImageButtonStatus;
[genomeId: string]: {
[categoryName: string]: {
[trackName: string]: ImageButtonStatus;
};
};
};
......
......@@ -20,26 +20,17 @@ export const fetchGenomeInfoAsyncActions = createAsyncAction(
export const fetchGenomeInfo: ActionCreator<
ThunkAction<void, any, null, Action<string>>
> = () => (dispatch: Dispatch, getState: () => RootState) => {
> = (genomeId: string) => async (dispatch: Dispatch) => {
try {
const genomeInfo: GenomeInfoData = getGenomeInfo(getState());
const committedSpecies = getCommittedSpecies(getState());
committedSpecies.map(async (species) => {
if (!genomeInfo[species.genome_id]) {
dispatch(fetchGenomeInfoAsyncActions.request());
const url = `/api/genome/info?genome_id=${species.genome_id}`;
const response = await apiService.fetch(url);
dispatch(fetchGenomeInfoAsyncActions.request());
const url = `/api/genome/info?genome_id=${genomeId}`;
const response = await apiService.fetch(url);
dispatch(
fetchGenomeInfoAsyncActions.success({
[species.genome_id]: response.genome_info[0]
})
);
}
});
dispatch(
fetchGenomeInfoAsyncActions.success({
[genomeId]: response.genome_info[0] // FIXME: Why the response is an array instead of an object keyed by genomeId?
})
);
} catch (error) {
dispatch(fetchGenomeInfoAsyncActions.failure(error));
}
......
......@@ -13,6 +13,12 @@ export const getGenomeInfoFetchFailed = (state: RootState) =>
export const getGenomeTrackCategories = (state: RootState) =>
state.genome.genomeTrackCategories.genomeTrackCategoriesData;
export const getGenomeTrackCategoriesById = (
state: RootState,
genomeId: string
) =>
state.genome.genomeTrackCategories.genomeTrackCategoriesData[genomeId] || [];
export const getGenomeTrackCategoriesFetching = (state: RootState) =>
state.genome.genomeTrackCategories.genomeTrackCategoriesFetching;
......