Skip to content

Commit

Permalink
NickAkhmetov/HMP-500 - Biomarkers landing page (#3330)
Browse files Browse the repository at this point in the history
Co-authored-by: John Conroy <[email protected]>
  • Loading branch information
NickAkhmetov and john-conroy authored Dec 1, 2023
1 parent 11b9723 commit c6c116f
Show file tree
Hide file tree
Showing 30 changed files with 632 additions and 124 deletions.
1 change: 1 addition & 0 deletions CHANGELOG-hmp-500.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added biomarkers landing page.
9 changes: 9 additions & 0 deletions context/app/routes_cells.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ def cells_ui():
)


@blueprint.route('/biomarkers')
def biomarkers_ui():
return render_template(
'base-pages/react-content.html',
title='Biomarkers',
flask_data={**get_default_flask_data()}
)


def _get_client(app):
return Client(app.config['XMODALITY_ENDPOINT'] + '/api/')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ const otherGroups = {
href: '/cells',
label: 'Molecular Data Queries - BETA',
},
{
href: '/biomarkers',
label: 'Biomarkers - BETA',
},
],
};

Expand Down
14 changes: 11 additions & 3 deletions context/app/static/js/components/Routes/Routes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ const DatasetSearch = lazy(() => import('js/pages/entity-search/DatasetSearch'))
const Workspaces = lazy(() => import('js/pages/Workspaces'));
const Workspace = lazy(() => import('js/pages/Workspace'));
const WorkspacePleaseWait = lazy(() => import('js/pages/WorkspacePleaseWait'));
const Genes = lazy(() => import('js/pages/Genes'));

const GeneDetails = lazy(() => import('js/pages/Genes'));
const Biomarkers = lazy(() => import('js/pages/Biomarkers'));
function Routes({ flaskData }) {
const {
entity,
Expand Down Expand Up @@ -285,7 +285,15 @@ function Routes({ flaskData }) {
if (urlPath.startsWith('/genes/')) {
return (
<Route>
<Genes geneSymbol={geneSymbol} />
<GeneDetails geneSymbol={geneSymbol} />
</Route>
);
}

if (urlPath.startsWith('/biomarkers')) {
return (
<Route>
<Biomarkers />
</Route>
);
}
Expand Down
159 changes: 159 additions & 0 deletions context/app/static/js/components/biomarkers/BiomarkersPanelItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import React from 'react';

import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Stack, { StackProps } from '@mui/material/Stack';
import Chip from '@mui/material/Chip';
import { styled } from '@mui/material/styles';

import LineClamp from 'js/shared-styles/text/LineClamp';
import { InternalLink } from 'js/shared-styles/Links';
import { useIsMobile } from 'js/hooks/media-queries';
import SelectableChip from 'js/shared-styles/chips/SelectableChip';
import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips';
import { useBiomarkersSearchActions, useBiomarkersSearchState } from './BiomarkersSearchContext';

const mobileStackProps: Partial<StackProps> = {
height: 'unset',
direction: 'column',
spacing: 2,
py: 2,
};

const desktopStackProps: Partial<StackProps> = {
height: 52,
direction: 'row',
spacing: 4,
py: 0,
};

function StackTemplate(props: React.ComponentProps<typeof Stack>) {
const isMobile = useIsMobile();
const responsiveProps = isMobile ? mobileStackProps : desktopStackProps;
return <Stack px={2} useFlexGap width="100%" {...responsiveProps} {...props} />;
}

function MobileLabel({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();
if (!isMobile) {
return null;
}
return (
<Typography component="label" width="33%" flexShrink={0} pr={2}>
{children}
</Typography>
);
}

function BodyCell({ children, ...props }: React.ComponentProps<typeof Box>) {
const ariaLabel = props['aria-label'];
return (
<Box display="flex" alignItems="center" {...props}>
<MobileLabel>{ariaLabel}</MobileLabel>
{children}
</Box>
);
}

function HeaderCell({ children, ...props }: React.ComponentProps<typeof Box>) {
return (
<BodyCell {...props}>
<Typography variant="subtitle2">{children}</Typography>
</BodyCell>
);
}

const desktopConfig = {
name: {
flexBasis: '30%',
flexGrow: 1,
},
description: {
flexBasis: '40%',
flexGrow: 1,
},
type: {
flexBasis: 'fit-content',
flexShrink: 0,
flexGrow: 1,
},
};

function BiomarkerHeaderPanel() {
const isMobile = useIsMobile();
if (isMobile) {
return null;
}
return (
<StackTemplate spacing={1}>
<HeaderCell {...desktopConfig.name}>Name</HeaderCell>
<HeaderCell {...desktopConfig.description}>Description</HeaderCell>
<HeaderCell {...desktopConfig.type}>Type</HeaderCell>
</StackTemplate>
);
}

interface BiomarkerPanelItemProps {
name: string;
href?: string;
description: string;
type: string;
}

const UnroundedChip = styled(Chip)(({ theme }) => ({
borderRadius: theme.spacing(1),
'&.MuiChip-outlined': {
borderRadius: theme.spacing(1),
},
}));

function BiomarkerPanelItem({ name, href, description, type }: BiomarkerPanelItemProps) {
return (
<StackTemplate>
<BodyCell {...desktopConfig.name} aria-label="Name">
<InternalLink href={href}>{name}</InternalLink>
</BodyCell>
<BodyCell {...desktopConfig.description} aria-label="Description">
<LineClamp lines={2}>{description}</LineClamp>
</BodyCell>
<BodyCell {...desktopConfig.type} aria-label="Type">
<UnroundedChip variant="outlined" label={type} />
</BodyCell>
</StackTemplate>
);
}

const UnroundedFilterChip = styled(SelectableChip)(({ theme }) => ({
borderRadius: theme.spacing(1),
'&.MuiChip-outlined': {
borderRadius: theme.spacing(1),
},
}));

function BiomarkerPanelFilters() {
const { toggleFilterByGenes, toggleFilterByProteins } = useBiomarkersSearchActions();
const { filterType } = useBiomarkersSearchState();
return (
<Stack direction="row" spacing={1}>
<UnroundedFilterChip label="Filter by Genes" isSelected={filterType === 'gene'} onClick={toggleFilterByGenes} />
<SecondaryBackgroundTooltip title="Coming soon">
<span>
<UnroundedFilterChip
label="Filter by Proteins"
isSelected={filterType === 'protein'}
onClick={toggleFilterByProteins}
disabled
/>
</span>
</SecondaryBackgroundTooltip>
</Stack>
);
}

const BiomarkerPanel = {
Header: BiomarkerHeaderPanel,
Item: BiomarkerPanelItem,
Filters: BiomarkerPanelFilters,
};

export default BiomarkerPanel;
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React, { useCallback, useMemo } from 'react';
import PanelList from 'js/shared-styles/panels/PanelList';

import LoadingButton from '@mui/lab/LoadingButton';
import Skeleton from '@mui/material/Skeleton';

import { PanelProps } from 'js/shared-styles/panels/Panel';
import { useSnackbarActions } from 'js/shared-styles/snackbars';
import { DownIcon } from 'js/shared-styles/icons';
import { useResultsList, useViewMore } from './hooks';
import BiomarkerPanel from './BiomarkersPanelItem';

const skeletons: PanelProps[] = Array.from({ length: 10 }).map((_, index) => ({
key: `skeleton-${index}`,
children: <Skeleton width="100%" height={32} variant="rounded" key={Math.random()} />,
}));

function ViewMoreButton() {
const { isLoading, isValidating, hasMore } = useResultsList();
const viewMore = useViewMore();
const { toastError } = useSnackbarActions();

const handleViewMore = useCallback(() => {
viewMore().catch((error: unknown) => {
console.error(error);
toastError('Failed to load more biomarkers.');
});
}, [toastError, viewMore]);

return (
<LoadingButton
color="primary"
variant="contained"
fullWidth
loading={isLoading || isValidating}
onClick={handleViewMore}
disabled={!hasMore}
endIcon={<DownIcon />}
>
View More
</LoadingButton>
);
}

export default function BiomarkersPanelList() {
const { genesList, isLoading } = useResultsList();

const panelsProps: PanelProps[] = useMemo(() => {
if (!genesList.length) {
if (isLoading) return skeletons;
return [
{
children: <>No results found. Try searching for a different biomarker.</>,
key: 'no-results',
},
];
}
const propsList: PanelProps[] = [
{
key: 'filters',
noHover: true,
children: <BiomarkerPanel.Filters />,
},
{
key: 'header',
noPadding: true,
children: <BiomarkerPanel.Header />,
},
...genesList.map(({ approved_name, approved_symbol, summary }) => ({
key: approved_symbol,
noPadding: true,
noHover: false,
children: (
<BiomarkerPanel.Item
name={`${approved_name} (${approved_symbol})`}
description={summary || 'No description available.'}
href={`/genes/${approved_symbol}`}
type="Gene"
/>
),
})),
{
key: 'view-more',
noPadding: true,
children: <ViewMoreButton />,
},
];
return propsList;
}, [genesList, isLoading]);

return <PanelList panelsProps={panelsProps} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import SearchBar from 'js/shared-styles/inputs/SearchBar';
import { useBiomarkersSearchActions, useBiomarkersSearchState } from './BiomarkersSearchContext';

// Once protein search is implemented, this should be changed to:
// const searchbarPlaceholder =
// 'Search for biomarkers by gene symbol, gene name or protein name. Example: CD4, Cytokeratin';

const searchbarPlaceholder =
'Search for biomarkers by gene symbol. Note that searches are case-sensitive. Example: CD4, MMRN1';

export default function BiomarkersSearchBar() {
const { search } = useBiomarkersSearchState();
const { setSearch } = useBiomarkersSearchActions();
return (
<SearchBar
sx={{ mb: 2, width: '100%' }}
placeholder={searchbarPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { Dispatch, PropsWithChildren, useMemo, useState } from 'react';
import { createContext, useContext } from 'js/helpers/context';

type FilterType = 'gene' | 'protein';

interface BiomarkersSearchStateContext {
search: string;
filterType?: FilterType;
}

interface BiomarkersSearchActionsContext {
setSearch: Dispatch<React.SetStateAction<string>>;
toggleFilterByGenes: () => void;
toggleFilterByProteins: () => void;
}

const BiomarkersSearchStateContext = createContext<BiomarkersSearchStateContext>('BiomarkersSearchContext');
const BiomarkersSearchActionsContext = createContext<BiomarkersSearchActionsContext>('BiomarkersSearchActionsContext');

function BiomarkersSearchProvider({ children }: PropsWithChildren) {
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<FilterType>();

const searchState = useMemo(
() => ({
search,
filterType,
}),
[search, filterType],
);

const searchActions = useMemo(
() => ({
setSearch,
toggleFilterByGenes: () => setFilterType((c) => (c === 'gene' ? undefined : 'gene')),
toggleFilterByProteins: () => setFilterType((c) => (c === 'protein' ? undefined : 'protein')),
}),
[],
);

return (
<BiomarkersSearchStateContext.Provider value={searchState}>
<BiomarkersSearchActionsContext.Provider value={searchActions}>
{children}
</BiomarkersSearchActionsContext.Provider>
</BiomarkersSearchStateContext.Provider>
);
}

function useBiomarkersSearchState() {
return useContext(BiomarkersSearchStateContext);
}

function useBiomarkersSearchActions() {
return useContext(BiomarkersSearchActionsContext);
}

export default BiomarkersSearchProvider;
export { useBiomarkersSearchState, useBiomarkersSearchActions };
Loading

0 comments on commit c6c116f

Please sign in to comment.