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 @@ ...@@ -15,3 +15,4 @@
*/ */
export const SWISSPROT_SOURCE = 'Uniprot/SWISSPROT'; export const SWISSPROT_SOURCE = 'Uniprot/SWISSPROT';
export const SPTREMBL_SOURCE = 'Uniprot/SPTREMBL';
...@@ -6,10 +6,7 @@ $content-width: $gene_image_width; ...@@ -6,10 +6,7 @@ $content-width: $gene_image_width;
.proteinSummary { .proteinSummary {
margin-left: 178px; margin-left: 178px;
width: $content-width; width: $content-width;
padding-top: 10px;
> div {
padding: 10px 0 10px 0;
}
} }
.keyline { .keyline {
...@@ -21,9 +18,9 @@ $content-width: $gene_image_width; ...@@ -21,9 +18,9 @@ $content-width: $gene_image_width;
} }
.proteinSummaryTop { .proteinSummaryTop {
align-items: center;
display: grid; display: grid;
grid-template-columns: [references] 300px [download] 395px; grid-template-columns: [references] 350px [download] 345px;
align-items: start;
} }
.downloadWrapper { .downloadWrapper {
...@@ -33,10 +30,8 @@ $content-width: $gene_image_width; ...@@ -33,10 +30,8 @@ $content-width: $gene_image_width;
} }
.proteinExternalReference { .proteinExternalReference {
display: inline-block;
&:first-child { &:first-child {
margin-right: 40px; margin-right: 16px;
} }
} }
...@@ -44,6 +39,9 @@ $content-width: $gene_image_width; ...@@ -44,6 +39,9 @@ $content-width: $gene_image_width;
display: inline-block; display: inline-block;
} }
.proteinStatsWrapper {
display: flex;
}
.imageLoadingContainer { .imageLoadingContainer {
width: $content-width; width: $content-width;
...@@ -60,3 +58,38 @@ $content-width: $gene_image_width; ...@@ -60,3 +58,38 @@ $content-width: $gene_image_width;
justify-content: center; justify-content: center;
margin-top: 40px; 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'; ...@@ -18,6 +18,7 @@ import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import set from 'lodash/fp/set'; import set from 'lodash/fp/set';
import { Pick2 } from 'ts-multipick'; import { Pick2 } from 'ts-multipick';
import classNames from 'classnames';
import { CircleLoader } from 'src/shared/components/loader/Loader'; import { CircleLoader } from 'src/shared/components/loader/Loader';
import ProteinDomainImage from 'src/content/app/entity-viewer/gene-view/components/protein-domain-image/ProteinDomainImage'; 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 ...@@ -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 ProteinFeaturesCount from 'src/content/app/entity-viewer/gene-view/components/protein-features-count/ProteinFeaturesCount';
import ExternalReference from 'src/shared/components/external-reference/ExternalReference'; import ExternalReference from 'src/shared/components/external-reference/ExternalReference';
import InstantDownloadProtein from 'src/shared/components/instant-download/instant-download-protein/InstantDownloadProtein'; import InstantDownloadProtein from 'src/shared/components/instant-download/instant-download-protein/InstantDownloadProtein';
import { ReactComponent as ChevronDown } from 'static/img/shared/chevron-down.svg';
import { import {
ExternalSource, ExternalSource,
externalSourceLinks externalSourceLinks,
getProteinXrefs
} from 'src/content/app/entity-viewer/shared/helpers/entity-helpers'; } 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 { fetchProteinDomains } from 'src/content/app/entity-viewer/shared/rest/rest-data-fetchers/transcriptData';
import { import {
...@@ -42,12 +45,12 @@ import { Product } from 'src/shared/types/thoas/product'; ...@@ -42,12 +45,12 @@ import { Product } from 'src/shared/types/thoas/product';
import { ProteinDomain } 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 { 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 styles from './ProteinsListItemInfo.scss';
import settings from 'src/content/app/entity-viewer/gene-view/styles/_constants.scss'; import settings from 'src/content/app/entity-viewer/gene-view/styles/_constants.scss';
type ProductWithoutDomains = Pick< export type ProductWithoutDomains = Pick<
Product, Product,
'length' | 'unversioned_stable_id' 'length' | 'unversioned_stable_id'
> & { > & {
...@@ -111,9 +114,10 @@ const ProteinsListItemInfo = (props: Props) => { ...@@ -111,9 +114,10 @@ const ProteinsListItemInfo = (props: Props) => {
LoadingState.LOADING LoadingState.LOADING
); );
const [summaryStatsLoadingState, setSummaryStatsLoadingState] = useState< const [
LoadingState summaryStatsLoadingState,
>(LoadingState.LOADING); setSummaryStatsLoadingState
] = useState<LoadingState>(LoadingState.LOADING);
const proteinId = const proteinId =
transcript.product_generating_contexts[0].product.unversioned_stable_id; transcript.product_generating_contexts[0].product.unversioned_stable_id;
...@@ -121,9 +125,8 @@ const ProteinsListItemInfo = (props: Props) => { ...@@ -121,9 +125,8 @@ const ProteinsListItemInfo = (props: Props) => {
const { product: productWithProteinDomains } = const { product: productWithProteinDomains } =
transcriptWithProteinDomains?.product_generating_contexts[0] || {}; transcriptWithProteinDomains?.product_generating_contexts[0] || {};
const uniprotXref = transcript.product_generating_contexts[0].product.external_references.find( const proteinXrefs = getProteinXrefs(transcript);
(xref) => xref.source.id === SWISSPROT_SOURCE const displayXref = proteinXrefs[0];
);
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
...@@ -150,14 +153,14 @@ const ProteinsListItemInfo = (props: Props) => { ...@@ -150,14 +153,14 @@ const ProteinsListItemInfo = (props: Props) => {
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
if (summaryStatsLoadingState === LoadingState.LOADING && !uniprotXref) { if (summaryStatsLoadingState === LoadingState.LOADING && !displayXref) {
// if uniprotXref is absent, we cannot fetch relevant data from PDBe; so pretend that we've successfully completed the request // if displayXref is absent, we cannot fetch relevant data from PDBe; so pretend that we've successfully completed the request
setSummaryStatsLoadingState(LoadingState.SUCCESS); setSummaryStatsLoadingState(LoadingState.SUCCESS);
return; return;
} }
if (summaryStatsLoadingState === LoadingState.LOADING && uniprotXref) { if (summaryStatsLoadingState === LoadingState.LOADING && displayXref) {
fetchProteinSummaryStats(uniprotXref.accession_id, abortController.signal) fetchProteinSummaryStats(displayXref.accession_id, abortController.signal)
.then((response) => { .then((response) => {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
response response
...@@ -174,7 +177,7 @@ const ProteinsListItemInfo = (props: Props) => { ...@@ -174,7 +177,7 @@ const ProteinsListItemInfo = (props: Props) => {
return function cleanup() { return function cleanup() {
abortController.abort(); abortController.abort();
}; };
}, [summaryStatsLoadingState, uniprotXref]); }, [summaryStatsLoadingState, displayXref]);
const showLoadingIndicator = const showLoadingIndicator =
domainsLoadingState === LoadingState.LOADING || domainsLoadingState === LoadingState.LOADING ||
...@@ -200,20 +203,27 @@ const ProteinsListItemInfo = (props: Props) => { ...@@ -200,20 +203,27 @@ const ProteinsListItemInfo = (props: Props) => {
<div className={styles.proteinSummary}> <div className={styles.proteinSummary}>
<> <>
<div className={styles.proteinSummaryTop}> <div className={styles.proteinSummaryTop}>
{uniprotXref && domainsLoadingState === LoadingState.SUCCESS && ( {proteinXrefs.length > 0 &&
<div className={styles.interproUniprotWrapper}> domainsLoadingState === LoadingState.SUCCESS && (
<ProteinExternalReference <div>
source={ExternalSource.INTERPRO} <div className={styles.xrefsWrapper}>
accessionId={uniprotXref.accession_id} <ProteinExternalReferenceGroup
name={uniprotXref.name} source={
/> proteinXrefs[0].source.id === SWISSPROT_SOURCE
<ProteinExternalReference ? ExternalSource.UNIPROT_SWISSPROT
source={ExternalSource.UNIPROT} : ExternalSource.UNIPROT_TREMBL
accessionId={uniprotXref.accession_id} }
name={uniprotXref.name} xrefs={proteinXrefs}
/> />
</div> </div>
)} <div className={styles.xrefsWrapper}>
<ProteinExternalReferenceGroup
source={ExternalSource.INTERPRO}
xrefs={proteinXrefs}
/>
</div>
</div>
)}
{domainsLoadingState === LoadingState.SUCCESS && ( {domainsLoadingState === LoadingState.SUCCESS && (
<div className={styles.downloadWrapper}> <div className={styles.downloadWrapper}>
<InstantDownloadProtein <InstantDownloadProtein
...@@ -223,22 +233,18 @@ const ProteinsListItemInfo = (props: Props) => { ...@@ -223,22 +233,18 @@ const ProteinsListItemInfo = (props: Props) => {
</div> </div>
)} )}
</div> </div>
{proteinSummaryStats && {proteinSummaryStats && domainsLoadingState === LoadingState.SUCCESS && (
uniprotXref && <div className={styles.proteinStatsWrapper}>
domainsLoadingState === LoadingState.SUCCESS && ( <ProteinExternalReference
<div> source={ExternalSource.PDBE}
<ProteinExternalReference accessionId={displayXref.accession_id}
source={ExternalSource.PDBE} name={displayXref.name}
accessionId={uniprotXref.accession_id} />
name={uniprotXref.name} <div className={styles.proteinFeaturesCountWrapper}>
/> <ProteinFeaturesCount proteinStats={proteinSummaryStats} />
{proteinSummaryStats && (
<div className={styles.proteinFeaturesCountWrapper}>
<ProteinFeaturesCount proteinStats={proteinSummaryStats} />
</div>
)}
</div> </div>
)} </div>
)}
</> </>
{showLoadingIndicator && ( {showLoadingIndicator && (
<div className={styles.statusContainer}> <div className={styles.statusContainer}>
...@@ -262,9 +268,89 @@ const ProteinExternalReference = (props: ProteinExternalReferenceProps) => { ...@@ -262,9 +268,89 @@ const ProteinExternalReference = (props: ProteinExternalReferenceProps) => {
return ( return (
<div className={styles.proteinExternalReference}> <div className={styles.proteinExternalReference}>
<ExternalReference label={props.source} to={url} linkText={props.name} /> <ExternalReference
label={props.source}
to={url}
linkText={props.accessionId}
/>
</div> </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; export default ProteinsListItemInfo;
...@@ -19,6 +19,12 @@ import { Pick2, Pick3 } from 'ts-multipick'; ...@@ -19,6 +19,12 @@ import { Pick2, Pick3 } from 'ts-multipick';
import { Slice } from 'src/shared/types/thoas/slice'; import { Slice } from 'src/shared/types/thoas/slice';
import { PhasedExon, Exon } from 'src/shared/types/thoas/exon'; import { PhasedExon, Exon } from 'src/shared/types/thoas/exon';
import { Product, ProductType } from 'src/shared/types/thoas/product'; 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';