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

Show sequences in genome browser drawer's sequence view (#731)

- Combine the common logic of the sequence view component in the DrawerSequenceView component
   and in the useDrawerSequenceSettings hook
- Move state-related logic to a redux sliice
- Add refget slice for fetching sequences
- Add the code for showing the reverse complement sequence
- Add the Copy button to copy the sequence into the clipboard
parent db2eee7b
Pipeline #268413 passed with stages
in 4 minutes and 25 seconds
@import 'src/styles/common';
.layout {
display: grid;
grid-template-areas:
'main-top aside-top'
'main-bottom aside-bottom';
grid-template-columns: 60ch 1fr;
grid-template-rows: 20px minmax(200px, 400px);
grid-column-gap: 24px;
align-items: start;
grid-row-gap: 17px;
}
.mainTop {
grid-area: main-top;
display: flex;
justify-content: space-between;
}
.sequenceLengthUnits {
font-weight: $light;
margin-left: 0.5ch;
}
.asideTop {
grid-area: aside-top;
}
.sequence {
grid-area: main-bottom;
margin-top: 10px;
height: 100%;
font-family: $font-family-monospace;
color: $dark-grey;
overflow-wrap: break-word;
overflow-y: auto;
overflow-x: hidden; // no idea why; but the browser creates a horizontal scrollbar otherwise
}
.asideBottom {
grid-area: aside-bottom;
}
.sequenceTypeSelection {
margin-top: 50px;
}
.reverseComplement {
margin-top: 43px;
}
.showHide {
margin-bottom: 16px;
}
.copyLozenge {
display: flex;
height: 18px;
padding: 0 14px;
align-items: center;
justify-content: center;
}
.copy {
color: $blue;
cursor: pointer;
}
.copyLozengeCopied {
color: $white;
background-color: $black;
border-radius: 30px;
}
/**
* 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, { useState, useMemo, useEffect } from 'react';
import classNames from 'classnames';
import { getReverseComplement } from 'src/shared/helpers/sequenceHelpers';
import RadioGroup from 'src/shared/components/radio-group/RadioGroup';
import Checkbox from 'src/shared/components/checkbox/Checkbox';
import ShowHide from 'src/shared/components/show-hide/ShowHide';
import type { SequenceType } from 'src/content/app/genome-browser/state/drawer/drawer-sequence/drawerSequenceSlice';
import styles from './DrawerSequenceView.scss';
const sequenceLabelsMap: Record<SequenceType, string> = {
genomic: 'Genomic sequence',
cdna: 'cDNA',
cds: 'CDS',
protein: 'Protein sequence'
};
// TODO: we probably also want to pass a sequence header in order to be able to blast it
type Props = {
isExpanded: boolean;
toggleSequenceVisibility: () => void;
sequence?: string;
sequenceTypes: SequenceType[];
selectedSequenceType: SequenceType;
isReverseComplement: boolean;
onSequenceTypeChange: (sequenceType: SequenceType) => void;
onReverseComplementChange: (isReverseComplement: boolean) => void;
};
const DrawerSequenceView = (props: Props) => {
const {
isExpanded,
toggleSequenceVisibility,
sequence,
sequenceTypes,
selectedSequenceType,
onSequenceTypeChange,
isReverseComplement,
onReverseComplementChange
} = props;
const sequenceTypeOptions = sequenceTypes.map((sequenceType) => ({
value: sequenceType,
label: sequenceLabelsMap[sequenceType]
}));
const canHaveReverseComplement = selectedSequenceType === 'genomic';
const showHideStyleProps = isExpanded
? {
classNames: { wrapper: styles.showHide }
}
: {};
return (
<div>
<ShowHide
label="Sequences"
isExpanded={isExpanded}
onClick={toggleSequenceVisibility}
{...showHideStyleProps}
/>
{isExpanded && (
<div className={styles.layout}>
{sequence && (
<Sequence
sequence={sequence}
sequenceType={selectedSequenceType}
isReverseComplement={isReverseComplement}
/>
)}
{/* The BLAST button will go here when ready
<div className={styles.asideTop}>
BLAST BUTTON HERE!
</div>
*/}
<div className={styles.asideBottom}>
<div className={styles.sequenceTypeSelection}>
<RadioGroup
options={sequenceTypeOptions}
onChange={(sequenceType) =>
onSequenceTypeChange(sequenceType as SequenceType)
}
selectedOption={selectedSequenceType}
/>
{canHaveReverseComplement && (
<div className={styles.reverseComplement}>
<Checkbox
label="Reverse complement"
checked={isReverseComplement}
onChange={onReverseComplementChange}
/>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
const Sequence = (props: {
sequence: string;
isReverseComplement: boolean;
sequenceType: SequenceType;
}) => {
const { sequence, sequenceType, isReverseComplement } = props;
const displaySequence = useMemo(() => {
return isReverseComplement ? getReverseComplement(sequence) : sequence;
}, [sequence, isReverseComplement]);
const sequenceLengthUnits = sequenceType === 'protein' ? 'aa' : 'bp';
return (
<>
<div className={styles.mainTop}>
<span>
{displaySequence.length}
<span className={styles.sequenceLengthUnits}>
{sequenceLengthUnits}
</span>
</span>
<Copy value={displaySequence} />
</div>
{/*
NOTE: the dangerouslySetInnerHTML on the line below shouldn't be necessary;
but for some reason, Chrome has problems wrapping the sequence
if it is just passed to the div as a child.
*/}
<div
className={styles.sequence}
dangerouslySetInnerHTML={{ __html: displaySequence }}
/>
</>
);
};
// QUESTION: is this going to become a a standalone component?
const Copy = (props: { value: string }) => {
const [copied, setCopied] = useState(false);
let timeout: ReturnType<typeof setTimeout>;
useEffect(() => {
return () => timeout && clearTimeout(timeout);
}, []);
const copy = () => {
setCopied(true);
navigator.clipboard.writeText(props.value);
timeout = setTimeout(() => setCopied(false), 1500);
};
const componentStyles = classNames(styles.copyLozenge, {
[styles.copyLozengeCopied]: copied
});
return (
<span className={componentStyles}>
{copied ? (
'Copied'
) : (
<span className={styles.copy} onClick={copy}>
Copy
</span>
)}
</span>
);
};
export default DrawerSequenceView;
......@@ -17,60 +17,75 @@
import React from 'react';
import noop from 'lodash/noop';
import * as urlFor from 'src/shared/helpers/urlHelper';
import { useAppSelector } from 'src/store';
import RadioGroup, {
RadioOptions
} from 'src/shared/components/radio-group/RadioGroup';
import { buildFocusObjectId } from 'src/shared/helpers/focusObjectHelpers';
import { GeneSummaryQueryResult } from 'src/content/app/genome-browser/state/api/queries/geneSummaryQuery';
import useDrawerSequenceSettings from './useDrawerSequenceSettings';
import { useRefgetSequenceQuery } from 'src/shared/state/api-slices/refgetSlice';
import styles from './SequenceView.scss';
import { getBrowserActiveGenomeId } from 'src/content/app/genome-browser/state/browser-general/browserGeneralSelectors';
type Gene = {
slice: GeneSummaryQueryResult['gene']['slice'];
};
import DrawerSequenceView from 'src/content/app/genome-browser/components/drawer/components/sequence-view/DrawerSequenceView';
import type { GeneSummaryQueryResult } from 'src/content/app/genome-browser/state/api/queries/geneSummaryQuery';
type Gene = Pick<GeneSummaryQueryResult['gene'], 'stable_id' | 'slice'>;
type Props = {
gene: Gene;
};
export const GeneSequenceView = (props: Props) => {
const sequenceType = 'genomicSequence';
const { checksum } = props.gene.slice.region.sequence;
const { start, end } = props.gene.slice.location;
const sequenceURL = urlFor.refget({ checksum, start, end });
const { gene } = props;
const genomeId = useAppSelector(getBrowserActiveGenomeId) as string;
const geneId = buildGeneId(genomeId, gene.stable_id);
const radioOptions: RadioOptions = [
const {
isExpanded,
toggleSequenceVisibility,
isReverseComplement,
toggleReverseComplement
} = useDrawerSequenceSettings({ genomeId, featureId: geneId });
const {
region: {
sequence: { checksum }
},
location: { start, end },
strand: { code: strand }
} = gene.slice;
const { data: sequence } = useRefgetSequenceQuery(
{
value: 'genomicSequence',
label: 'Genomic sequence'
}
];
checksum,
start,
end,
strand
},
{ skip: !isExpanded }
);
return (
<div className={styles.layout}>
<div>
<div>XXXX bp</div>
<div className={styles.sequenceWrapper}>
Fetching sequence for {sequenceType} : {sequenceURL}
</div>
</div>
<div>
<div>blast control</div>
<div className={styles.selectionWrapper}>
<RadioGroup
options={radioOptions}
onChange={noop}
selectedOption={sequenceType}
/>
<div className={styles.reverseWrapper}>
Reverse complement checkbox
</div>
</div>
</div>
</div>
<DrawerSequenceView
isExpanded={isExpanded}
toggleSequenceVisibility={toggleSequenceVisibility}
sequence={sequence}
sequenceTypes={['genomic']}
selectedSequenceType="genomic"
isReverseComplement={isReverseComplement}
onSequenceTypeChange={noop}
onReverseComplementChange={toggleReverseComplement}
/>
);
};
const buildGeneId = (genomeId: string, geneStableId: string) =>
buildFocusObjectId({
genomeId,
type: 'gene',
objectId: geneStableId
});
export default GeneSequenceView;
.layout {
display: grid;
align-items: start;
grid-template-columns: 400px 1fr;
grid-column-gap: 24px;
grid-template-rows: 20px minmax(200px, 400px);
grid-row-gap: 17px;
}
.selectionWrapper {
margin-top: 50px;
}
.sequenceWrapper {
margin-top: 10px;
}
.reverseWrapper {
margin-top: 43px;
}
......@@ -15,42 +15,215 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { render, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import userEvent from '@testing-library/user-event';
import { getReverseComplement } from 'src/shared/helpers/sequenceHelpers';
import createRootReducer from 'src/root/rootReducer';
import restApiSlice from 'src/shared/state/api-slices/restSlice';
import {
createProteinCodingTranscript,
createNonCodingTranscript
} from 'tests/fixtures/entity-viewer/transcript';
import TranscriptSequenceView from './TranscriptSequenceView';
import TranscriptSequenceView, { type Props } from './TranscriptSequenceView';
jest.mock('config', () => ({
refgetBaseUrl: 'http://refget-api' // need to provide absolute urls to the fetch running in Node
}));
const renderTranscriptSequenceView = (props: Props) => {
const genomeId = 'human';
const initialState = {
browser: {
browserGeneral: {
activeGenomeId: genomeId
},
drawer: {
sequence: {
[genomeId]: {
isVisible: true,
features: {}
}
}
}
}
};
const store = configureStore({
reducer: createRootReducer(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat([restApiSlice.middleware]),
preloadedState: initialState as any
});
const renderResult = render(
<Provider store={store}>
<TranscriptSequenceView {...props} />
</Provider>
);
return {
...renderResult,
store
};
};
const mockGenomicSequence = 'ACTGACTGACTG';
const mockCDNASequence = 'CTGACTGA';
const mockCDSSequence = 'TGACTG';
const mockProteinSequence = 'QS';
const proteinCodingTranscript = createProteinCodingTranscript();
const nonCodingTranscript = createNonCodingTranscript();
const server = setupServer(
rest.get('http://refget-api/sequence/:checksum', (req, res, ctx) => {
const checksum = req.params.checksum as string;
if (
[
proteinCodingTranscript.slice.region.sequence.checksum,
nonCodingTranscript.slice.region.sequence.checksum
].includes(checksum)
) {
return res(ctx.text(mockGenomicSequence));
} else if (
[
proteinCodingTranscript.product_generating_contexts[0].cdna.sequence
.checksum,
nonCodingTranscript.product_generating_contexts[0].cdna.sequence
.checksum
].includes(checksum)
) {
return res(ctx.text(mockCDNASequence));
} else if (
[
proteinCodingTranscript.product_generating_contexts[0].cds.sequence
.checksum
].includes(checksum)
) {
return res(ctx.text(mockCDSSequence));
} else if (
[
proteinCodingTranscript.product_generating_contexts[0].product.sequence
.checksum
].includes(checksum)
) {
return res(ctx.text(mockProteinSequence));
}
})
);
beforeAll(() =>
server.listen({
onUnhandledRequest(req) {
const errorMessage = `Found an unhandled ${req.method} request to ${req.url.href}`;
throw new Error(errorMessage);
}
})
);
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('<TranscriptSequenceView />', () => {
const proteinCodingTranscript = createProteinCodingTranscript();
it('displays correct list of sequence options for a protein-coding transcript', () => {
const { container } = render(
<TranscriptSequenceView transcript={proteinCodingTranscript} />
);
const renderedLabels = [
...container.querySelectorAll('.radioGroup .label')
].map((el) => el.innerHTML);
expect(renderedLabels).toEqual([
'Genomic sequence',
'cDNA',
'CDS',
'Protein sequence'
]);
describe('sequence options', () => {
it('displays correct list of sequence options for a protein-coding transcript', () => {
const { container } = renderTranscriptSequenceView({
transcript: proteinCodingTranscript
});
const renderedLabels = [