Unverified Commit 3ac40f10 authored by Jyothish's avatar Jyothish Committed by GitHub
Browse files

display multiple uniprot entries if available (#469)

display multiple uniprot entries
parent 52a060a4
Pipeline #156157 passed with stages
in 9 minutes and 20 seconds
......@@ -15,3 +15,4 @@
*/
export const SWISSPROT_SOURCE = 'Uniprot/SWISSPROT';
export const SPTREMBL_SOURCE = 'Uniprot/SPTREMBL';
......@@ -6,10 +6,7 @@ $content-width: $gene_image_width;
.proteinSummary {
margin-left: 178px;
width: $content-width;
> div {
padding: 10px 0 10px 0;
}
padding-top: 10px;
}
.keyline {
......@@ -21,9 +18,9 @@ $content-width: $gene_image_width;
}
.proteinSummaryTop {
align-items: center;
display: grid;
grid-template-columns: [references] 300px [download] 395px;
grid-template-columns: [references] 350px [download] 345px;
align-items: start;
}
.downloadWrapper {
......@@ -33,10 +30,8 @@ $content-width: $gene_image_width;
}
.proteinExternalReference {
display: inline-block;
&:first-child {
margin-right: 40px;
margin-right: 16px;
}
}
......@@ -44,6 +39,9 @@ $content-width: $gene_image_width;
display: inline-block;
}
.proteinStatsWrapper {
display: flex;
}
.imageLoadingContainer {
width: $content-width;
......@@ -60,3 +58,38 @@ $content-width: $gene_image_width;
justify-content: center;
margin-top: 40px;
}
.xrefWithChevron {
display: flex;
}
.xrefGroupChevron {
text-align: right;
padding-right: 2px;
cursor: pointer;
font-weight: $normal;
min-width: 50px;
}
.chevron {
margin-left: 6px;
margin-bottom: -1px;
height: 12px;
width: 12px;
transition: transform linear 0.2s;
&Up {
transform: rotate(-180deg);
}
}
.xrefCountChevronOpen {
color: $medium-dark-grey;
}
.xrefsWrapper {
padding-bottom: 10px;
&:first-child {
padding-top: 6px;
}
}
/**
* 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 { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import times from 'lodash/times';
import { ProteinExternalReferenceGroup } from './ProteinsListItemInfo';
import { createExternalReference } from 'tests/fixtures/entity-viewer/product';
import { ExternalSource } from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
jest.mock(
'src/shared/components/instant-download/instant-download-protein/InstantDownloadProtein',
() => jest.fn()
);
const swissprotXref = createExternalReference({
accession_id: 'SWISS123',
source: {
name: 'UniProtKB/Swiss-Prot',
id: 'swiss_123'
}
});
const tremblSource = {
name: 'UniProtKB/TrEMBL',
id: 'trembl1'
};
const tremblXrefs = times(4, () =>
createExternalReference({ source: tremblSource })
);
times(4, (index) => (tremblXrefs[index].accession_id = `trembl_${index}`));
describe('<ProteinsListItemInfo /', () => {
describe('<ProteinExternalReferenceGroup />', () => {
it('renders a single xref', () => {
const props = {
source: ExternalSource.UNIPROT_SWISSPROT,
xrefs: [swissprotXref]
};
// ProteinExternalReferenceGroup expect xrefs from a single source
const { queryByText } = render(
<ProteinExternalReferenceGroup {...props} />
);
expect(queryByText(swissprotXref.source.name)).toBeTruthy();
expect(queryByText(swissprotXref.accession_id)).toBeTruthy();
});
describe('multiple xrefs', () => {
it('renders 3 xrefs', () => {
const props = {
source: ExternalSource.UNIPROT_TREMBL,
xrefs: tremblXrefs.slice(0, 3)
};
const { queryByText, queryAllByText } = render(
<ProteinExternalReferenceGroup {...props} />
);
expect(queryAllByText(props.source)).toHaveLength(3);
expect(queryByText(props.xrefs[0].accession_id)).toBeTruthy();
expect(queryByText(props.xrefs[1].accession_id)).toBeTruthy();
expect(queryByText(props.xrefs[2].accession_id)).toBeTruthy();
});
it('displays xref with an option to expand if there are more than 3 xrefs', () => {
const props = {
source: ExternalSource.UNIPROT_TREMBL,
xrefs: tremblXrefs
};
const {
container,
getByText,
getAllByText,
queryByText,
queryAllByText
} = render(<ProteinExternalReferenceGroup {...props} />);
expect(queryByText(props.source)).toBeTruthy();
expect(queryAllByText(props.xrefs[0].accession_id)).toHaveLength(1);
const chevron = container.querySelector('.chevron') as HTMLElement;
expect(chevron).toBeTruthy();
userEvent.click(chevron);
expect(getByText(props.xrefs[1].accession_id)).toBeTruthy();
expect(getAllByText(props.source)).toHaveLength(4);
expect(
container.querySelectorAll('.externalLinkContainer')
).toHaveLength(4);
});
});
});
});
......@@ -18,6 +18,7 @@ import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import set from 'lodash/fp/set';
import { Pick2 } from 'ts-multipick';
import classNames from 'classnames';
import { CircleLoader } from 'src/shared/components/loader/Loader';
import ProteinDomainImage from 'src/content/app/entity-viewer/gene-view/components/protein-domain-image/ProteinDomainImage';
......@@ -25,10 +26,12 @@ import ProteinImage from 'src/content/app/entity-viewer/gene-view/components/pro
import ProteinFeaturesCount from 'src/content/app/entity-viewer/gene-view/components/protein-features-count/ProteinFeaturesCount';
import ExternalReference from 'src/shared/components/external-reference/ExternalReference';
import InstantDownloadProtein from 'src/shared/components/instant-download/instant-download-protein/InstantDownloadProtein';
import { ReactComponent as ChevronDown } from 'static/img/shared/chevron-down.svg';
import {
ExternalSource,
externalSourceLinks
externalSourceLinks,
getProteinXrefs
} from 'src/content/app/entity-viewer/shared/helpers/entity-helpers';
import { fetchProteinDomains } from 'src/content/app/entity-viewer/shared/rest/rest-data-fetchers/transcriptData';
import {
......@@ -42,12 +45,12 @@ import { Product } from 'src/shared/types/thoas/product';
import { ProteinDomain } from 'src/shared/types/thoas/product';
import { ExternalReference as ExternalReferenceType } from 'src/shared/types/thoas/externalReference';
import { SWISSPROT_SOURCE } from '../protein-list-constants';
import { SWISSPROT_SOURCE } from 'src/content/app/entity-viewer/gene-view/components/proteins-list/protein-list-constants';
import styles from './ProteinsListItemInfo.scss';
import settings from 'src/content/app/entity-viewer/gene-view/styles/_constants.scss';
type ProductWithoutDomains = Pick<
export type ProductWithoutDomains = Pick<
Product,
'length' | 'unversioned_stable_id'
> & {
......@@ -111,9 +114,10 @@ const ProteinsListItemInfo = (props: Props) => {
LoadingState.LOADING
);
const [summaryStatsLoadingState, setSummaryStatsLoadingState] = useState<
LoadingState
>(LoadingState.LOADING);
const [
summaryStatsLoadingState,
setSummaryStatsLoadingState
] = useState<LoadingState>(LoadingState.LOADING);
const proteinId =
transcript.product_generating_contexts[0].product.unversioned_stable_id;
......@@ -121,9 +125,8 @@ const ProteinsListItemInfo = (props: Props) => {
const { product: productWithProteinDomains } =
transcriptWithProteinDomains?.product_generating_contexts[0] || {};
const uniprotXref = transcript.product_generating_contexts[0].product.external_references.find(
(xref) => xref.source.id === SWISSPROT_SOURCE
);
const proteinXrefs = getProteinXrefs(transcript);
const displayXref = proteinXrefs[0];
useEffect(() => {
const abortController = new AbortController();
......@@ -150,14 +153,14 @@ const ProteinsListItemInfo = (props: Props) => {
useEffect(() => {
const abortController = new AbortController();
if (summaryStatsLoadingState === LoadingState.LOADING && !uniprotXref) {
// if uniprotXref is absent, we cannot fetch relevant data from PDBe; so pretend that we've successfully completed the request
if (summaryStatsLoadingState === LoadingState.LOADING && !displayXref) {
// if displayXref is absent, we cannot fetch relevant data from PDBe; so pretend that we've successfully completed the request
setSummaryStatsLoadingState(LoadingState.SUCCESS);
return;
}
if (summaryStatsLoadingState === LoadingState.LOADING && uniprotXref) {
fetchProteinSummaryStats(uniprotXref.accession_id, abortController.signal)
if (summaryStatsLoadingState === LoadingState.LOADING && displayXref) {
fetchProteinSummaryStats(displayXref.accession_id, abortController.signal)
.then((response) => {
if (!abortController.signal.aborted) {
response
......@@ -174,7 +177,7 @@ const ProteinsListItemInfo = (props: Props) => {
return function cleanup() {
abortController.abort();
};
}, [summaryStatsLoadingState, uniprotXref]);
}, [summaryStatsLoadingState, displayXref]);
const showLoadingIndicator =
domainsLoadingState === LoadingState.LOADING ||
......@@ -200,20 +203,27 @@ const ProteinsListItemInfo = (props: Props) => {
<div className={styles.proteinSummary}>
<>
<div className={styles.proteinSummaryTop}>
{uniprotXref && domainsLoadingState === LoadingState.SUCCESS && (
<div className={styles.interproUniprotWrapper}>
<ProteinExternalReference
source={ExternalSource.INTERPRO}
accessionId={uniprotXref.accession_id}
name={uniprotXref.name}
/>
<ProteinExternalReference
source={ExternalSource.UNIPROT}
accessionId={uniprotXref.accession_id}
name={uniprotXref.name}
/>
</div>
)}
{proteinXrefs.length > 0 &&
domainsLoadingState === LoadingState.SUCCESS && (
<div>
<div className={styles.xrefsWrapper}>
<ProteinExternalReferenceGroup
source={
proteinXrefs[0].source.id === SWISSPROT_SOURCE
? ExternalSource.UNIPROT_SWISSPROT
: ExternalSource.UNIPROT_TREMBL
}
xrefs={proteinXrefs}
/>
</div>
<div className={styles.xrefsWrapper}>
<ProteinExternalReferenceGroup
source={ExternalSource.INTERPRO}
xrefs={proteinXrefs}
/>
</div>
</div>
)}
{domainsLoadingState === LoadingState.SUCCESS && (
<div className={styles.downloadWrapper}>
<InstantDownloadProtein
......@@ -223,22 +233,18 @@ const ProteinsListItemInfo = (props: Props) => {
</div>
)}
</div>
{proteinSummaryStats &&
uniprotXref &&
domainsLoadingState === LoadingState.SUCCESS && (
<div>
<ProteinExternalReference
source={ExternalSource.PDBE}
accessionId={uniprotXref.accession_id}
name={uniprotXref.name}
/>
{proteinSummaryStats && (
<div className={styles.proteinFeaturesCountWrapper}>
<ProteinFeaturesCount proteinStats={proteinSummaryStats} />
</div>
)}
{proteinSummaryStats && domainsLoadingState === LoadingState.SUCCESS && (
<div className={styles.proteinStatsWrapper}>
<ProteinExternalReference
source={ExternalSource.PDBE}
accessionId={displayXref.accession_id}
name={displayXref.name}
/>
<div className={styles.proteinFeaturesCountWrapper}>
<ProteinFeaturesCount proteinStats={proteinSummaryStats} />
</div>
)}
</div>
)}
</>
{showLoadingIndicator && (
<div className={styles.statusContainer}>
......@@ -262,9 +268,89 @@ const ProteinExternalReference = (props: ProteinExternalReferenceProps) => {
return (
<div className={styles.proteinExternalReference}>
<ExternalReference label={props.source} to={url} linkText={props.name} />
<ExternalReference
label={props.source}
to={url}
linkText={props.accessionId}
/>
</div>
);
};
type ProteinExternalReferenceGroupProps = {
source: ExternalSource;
xrefs: Array<
Pick<ExternalReferenceType, 'accession_id' | 'name'> &
Pick2<ExternalReferenceType, 'source', 'id'>
>;
};
export const ProteinExternalReferenceGroup = (
props: ProteinExternalReferenceGroupProps
) => {
const { source, xrefs } = props;
const [isXrefGroupOpen, setXrefGroupOpen] = useState(false);
const toggleXrefGroup = () => {
setXrefGroupOpen(!isXrefGroupOpen);
};
const chevronClasses = classNames(styles.chevron, {
[styles.chevronUp]: isXrefGroupOpen
});
if (xrefs.length > 3) {
const displayXref = xrefs[0];
return (
<>
<div className={styles.xrefWithChevron}>
<ProteinExternalReference
key={displayXref.accession_id}
source={source}
accessionId={displayXref.accession_id}
name={displayXref.name}
/>
<div className={styles.xrefGroupChevron} onClick={toggleXrefGroup}>
<span
className={
isXrefGroupOpen ? styles.xrefCountChevronOpen : undefined
}
>
+ {xrefs.length - 1}
<ChevronDown className={chevronClasses} />
</span>
</div>
</div>
{isXrefGroupOpen &&
xrefs.slice(1).map((xref) => {
return (
<ProteinExternalReference
key={xref.accession_id}
source={source}
accessionId={xref.accession_id}
name={xref.name}
/>
);
})}
</>
);
} else {
return (
<>
{xrefs.map((xref) => {
return (
<ProteinExternalReference
key={xref.accession_id}
source={source}
accessionId={xref.accession_id}
name={xref.name}
/>
);
})}
</>
);
}
};
export default ProteinsListItemInfo;
......@@ -19,6 +19,12 @@ import { Pick2, Pick3 } from 'ts-multipick';
import { Slice } from 'src/shared/types/thoas/slice';
import { PhasedExon, Exon } from 'src/shared/types/thoas/exon';
import { Product, ProductType } from 'src/shared/types/thoas/product';
import { ExternalReference } from 'src/shared/types/thoas/externalReference';
import {
SWISSPROT_SOURCE,
SPTREMBL_SOURCE
} from 'src/content/app/entity-viewer/gene-view/components/proteins-list/protein-list-constants';
type GetFeatureCoordinatesParams = {
slice: Pick2<Slice, 'location', 'start' | 'end'>;
......@@ -152,12 +158,36 @@ export const getLongestProteinLength = (gene: GetLongestProteinLengthParam) => {
export enum ExternalSource {
INTERPRO = 'Interpro',
UNIPROT = 'UniProt',
UNIPROT_TREMBL = 'UniProtKB/TrEMBL',
UNIPROT_SWISSPROT = 'UniProtKB/Swiss-Prot',
PDBE = 'PDBe-KB'
}
export const externalSourceLinks = {
[ExternalSource.INTERPRO]: 'https://www.ebi.ac.uk/interpro/protein/UniProt/',
[ExternalSource.UNIPROT]: 'https://www.uniprot.org/uniprot/',
[ExternalSource.UNIPROT_TREMBL]: 'https://www.uniprot.org/uniprot/',
[ExternalSource.UNIPROT_SWISSPROT]: 'https://www.uniprot.org/uniprot/',
[ExternalSource.PDBE]: 'https://www.ebi.ac.uk/pdbe/pdbe-kb/proteins/'
};
export const getProteinXrefs = <
T extends Pick2<ExternalReference, 'source', 'id'>
>(transcript: {
product_generating_contexts: Array<{
product: {
external_references: T[];
};
}>;
}) => {
const xrefs =
transcript.product_generating_contexts[0].product.external_references;
let proteinXrefs = xrefs.filter(
(xref) => xref.source.id === SWISSPROT_SOURCE
);
if (!proteinXrefs.length) {
proteinXrefs = xrefs.filter((xref) => xref.source.id === SPTREMBL_SOURCE);
}
return proteinXrefs;
};
/**
* 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 { filterTranscriptsBySOTerm } from '../transcripts-filter';
import { createTranscript } from 'tests/fixtures/entity-viewer/transcript';
......
/**
* 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 { Filters } from 'src/content/app/entity-viewer/state/gene-view/transcripts/geneViewTranscriptsSlice';
import { FullTranscript } from 'src/shared/types/thoas/transcript';
......
......@@ -16,6 +16,7 @@
import faker from 'faker';
import times from 'lodash/times';
import merge from 'lodash/merge';