Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG-cat-1503.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Implement Research Powered by HuBMAP section with publication cards and pinning functionality.
3 changes: 3 additions & 0 deletions context/app/default_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ class DefaultConfig(object):
PROTOCOLS_IO_CLIENT_AUTH_TOKEN = 'should-be-overridden'

SENTRY_ENV = 'should-be-overridden'

# Comma-separated UUIDs of publications to pin on the homepage
PINNED_PUBLICATION_UUIDS = ''
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React, { PropsWithChildren, Ref } from 'react';
import Box from '@mui/material/Box';

import { MUIIcon } from 'js/shared-styles/icons/entityIconMap';
import { SectionHeader, OffsetDatasetsHeader } from 'js/pages/Home/style';

interface HomepageSectionProps extends PropsWithChildren {
title: string;
icon: MUIIcon;
gridArea: string;
useOffset?: boolean;
id?: string;
headerRef?: Ref<HTMLElement>;
}

function HomepageSection({ title, icon, gridArea, useOffset = false, id, headerRef, children }: HomepageSectionProps) {
const Header = useOffset ? OffsetDatasetsHeader : SectionHeader;

return (
<Box gridArea={gridArea}>
<Header variant="h2" component="h3" icon={icon} id={id} ref={headerRef}>
{title}
</Header>
{children}
</Box>
);
}

export default HomepageSection;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import HomepageSection from './HomepageSection';

export default HomepageSection;
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ import Paper from '@mui/material/Paper';

import HomepageRelatedLink from 'js/components/home/HomepageRelatedLink';
import Stack from '@mui/material/Stack';
import { SectionHeader } from 'js/pages/Home/style';
import { externalIconMap } from 'js/shared-styles/icons/externalImageIcons';
import { DataProductsIcon } from 'js/shared-styles/icons';
import { INTEGRATED_MAPS_DESCRIPTION_SHORT } from 'js/global-constants';
import { BuildRounded } from '@mui/icons-material';

const { avr, azimuth, fusion, googleScholar, hra, hubmapConsortium, nih, protocols } = externalIconMap;

Expand Down Expand Up @@ -119,10 +117,6 @@ function LinkSectionContainer({ links, title }: LinkSectionContainerProps) {
function RelatedToolsAndResources() {
return (
<Stack>
<SectionHeader variant="h2" component="h3" icon={BuildRounded}>
Related Tools & Resources
</SectionHeader>

<Stack
direction={{
xs: 'column',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import PushPinRounded from '@mui/icons-material/PushPinRounded';
import { useEventCallback } from '@mui/material/utils';

import { InternalLink } from 'js/shared-styles/Links';
import { trackEvent } from 'js/helpers/trackers';
import { buildSecondaryText } from 'js/components/publications/utils';
import { ContributorAPIResponse, normalizeContributor } from 'js/components/detailPage/ContributorsTable/utils';
import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips';

interface PublicationCardProps {
publication: {
uuid: string;
title: string;
contributors: ContributorAPIResponse[];
publication_venue: string;
publication_date: string;
};
isPinned: boolean;
}

function PublicationCard({ publication, isPinned }: PublicationCardProps) {
const { uuid, title, contributors = [], publication_venue } = publication;

const secondaryText = buildSecondaryText(publication_venue, contributors.map(normalizeContributor));

const handleClick = useEventCallback(() => {
trackEvent({
category: 'Homepage',
action: 'Research Powered by HuBMAP / View Publication',
label: title,
});
});

return (
<Paper variant="outlined" sx={{ p: 2, height: '100%' }}>
<Box display="flex" height="100%">
<Stack spacing={2} flex={1} minWidth={0}>
<Typography
variant="h4"
component="h3"
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{title}
</Typography>
<Stack direction="row" justifyContent="space-between">
<Stack direction="column" spacing={2}>
<Typography variant="body2" color="text.secondary">
{secondaryText}
</Typography>
<InternalLink href={`/browse/publication/${uuid}`} onClick={handleClick}>
View Publication &rarr;
</InternalLink>
</Stack>
{isPinned && (
<Box display="flex" alignItems="flex-end">
<SecondaryBackgroundTooltip title="Highlighted Publication" placement="top">
<PushPinRounded color="success" sx={{ transform: 'rotate(-45deg)' }} />
</SecondaryBackgroundTooltip>
</Box>
)}
</Stack>
</Stack>
</Box>
</Paper>
);
}

export default PublicationCard;
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React from 'react';
import { render, screen, appProviderEndpoints } from 'test-utils/functions';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

import ResearchPoweredByHuBMAP from './ResearchPoweredByHuBMAP';
import { parsePinnedUUIDs } from './hooks';

const mockPublications = Array.from({ length: 10 }, (_, i) => ({
uuid: `uuid-${i}`,
title: `Publication Title ${i}`,
contributors: [
{
first_name: 'John',
last_name: 'Doe',
name: 'John Doe',
orcid_id: '',
version: '0' as const,
affiliation: 'Test University',
middle_name_or_initial: '',
},
],
publication_venue: `Journal ${i}`,
publication_date: '2024-01-01',
publication_status: true,
}));

const server = setupServer(
http.post(`/${appProviderEndpoints.elasticsearchEndpoint}`, () => {
return HttpResponse.json({
hits: {
total: { value: mockPublications.length, relation: 'eq' },
hits: mockPublications.map((pub) => ({ _source: pub })),
},
});
}),
);

beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});

describe('ResearchPoweredByHuBMAP', () => {
test('renders 6 publication cards', async () => {
render(<ResearchPoweredByHuBMAP />);
const links = await screen.findAllByText(/View Publication/);
expect(links).toHaveLength(6);
});

test('renders introductory text', async () => {
render(<ResearchPoweredByHuBMAP />);
await screen.findAllByText(/View Publication/);
expect(screen.getByText(/Trusted by top institutions/)).toBeInTheDocument();
expect(screen.getByText(/HuBMAP data powers high-impact discoveries/)).toBeInTheDocument();
});

test('renders "View All Publications" button linking to /publications', async () => {
render(<ResearchPoweredByHuBMAP />);
const button = await screen.findByRole('link', { name: /View All Publications/ });
expect(button).toHaveAttribute('href', '/publications');
});

test('shows pin icon for pinned publications', async () => {
render(<ResearchPoweredByHuBMAP />, {
flaskData: { pinnedPublicationUUIDs: 'uuid-0' },
});
const pins = await screen.findAllByTestId('PushPinRoundedIcon');
expect(pins).toHaveLength(1);
});

test('shows no pin icons when no publications are pinned', async () => {
render(<ResearchPoweredByHuBMAP />);
await screen.findAllByText(/View Publication/);
expect(screen.queryAllByTestId('PushPinRoundedIcon')).toHaveLength(0);
});

test('renders publication titles and attribution', async () => {
render(<ResearchPoweredByHuBMAP />);
// Wait for publications to load
const titles = await screen.findAllByText(/Publication Title/);
expect(titles.length).toBeGreaterThan(0);
// Attribution uses buildSecondaryText which produces "Name | Venue"
expect(screen.getAllByText(/John Doe/).length).toBeGreaterThan(0);
});
});

describe('parsePinnedUUIDs', () => {
test('returns empty array for empty string', () => {
expect(parsePinnedUUIDs('')).toEqual([]);
});

test('returns empty array for undefined', () => {
expect(parsePinnedUUIDs(undefined)).toEqual([]);
});

test('returns empty array for non-string values', () => {
expect(parsePinnedUUIDs(42)).toEqual([]);
expect(parsePinnedUUIDs(null)).toEqual([]);
});

test('parses comma-separated UUIDs', () => {
expect(parsePinnedUUIDs('uuid1,uuid2,uuid3')).toEqual(['uuid1', 'uuid2', 'uuid3']);
});

test('trims whitespace from UUIDs', () => {
expect(parsePinnedUUIDs(' uuid1 , uuid2 ')).toEqual(['uuid1', 'uuid2']);
});

test('filters out empty strings from trailing commas', () => {
expect(parsePinnedUUIDs('uuid1,,uuid2,')).toEqual(['uuid1', 'uuid2']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import Grid from '@mui/material/Grid2';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import { useEventCallback } from '@mui/material/utils';

import { trackEvent } from 'js/helpers/trackers';
import { useFlaskDataContext } from 'js/components/Contexts';
import PublicationCard from './PublicationCard';
import { usePublicationsForHomepage, parsePinnedUUIDs } from './hooks';

function ResearchPoweredByHuBMAP() {
const flaskData = useFlaskDataContext();
const pinnedUUIDs = parsePinnedUUIDs(flaskData.pinnedPublicationUUIDs);
const { publications, isLoading } = usePublicationsForHomepage(pinnedUUIDs);

const handleViewAll = useEventCallback(() => {
trackEvent({
category: 'Homepage',
action: 'Research Powered by HuBMAP / View All Publications Button',
});
});

if (isLoading) return null;

return (
<Stack spacing={2}>
<Typography variant="subtitle1">
Trusted by top institutions to advance spatial and single-cell biomedical research.
</Typography>
<Typography variant="body1">
HuBMAP data powers high-impact discoveries in organ atlases, molecular mapping, and multimodal analysis. Shown
below is a sample of peer-reviewed publications enabled by HuBMAP data. Additional publications can be explored
on the Publications page.
</Typography>
<div>
<Button variant="contained" href="/publications" onClick={handleViewAll}>
View All Publications
</Button>
</div>
<Grid container spacing={2}>
{publications.map((pub) => (
<Grid key={pub.uuid} size={{ xs: 12, md: 6 }}>
<PublicationCard publication={pub} isPinned={pinnedUUIDs.includes(pub.uuid)} />
</Grid>
))}
</Grid>
</Stack>
);
}

export default ResearchPoweredByHuBMAP;
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useMemo } from 'react';
import { useSearchHits } from 'js/hooks/useSearchData';
import { ContributorAPIResponse } from 'js/components/detailPage/ContributorsTable/utils';
import { homepagePublicationsQuery } from './queries';

export interface HomepagePublication {
uuid: string;
title: string;
contributors: ContributorAPIResponse[];
publication_venue: string;
publication_date: string;
publication_status: boolean;
}

function shuffleArray<T>(arr: T[]): T[] {
const shuffled = [...arr];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}

export function parsePinnedUUIDs(raw: unknown): string[] {
if (typeof raw !== 'string' || !raw.trim()) return [];
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}

const DISPLAY_COUNT = 6;

export function usePublicationsForHomepage(pinnedUUIDs: string[]) {
const { searchHits, isLoading } = useSearchHits<HomepagePublication>(homepagePublicationsQuery);

const publications = useMemo(() => {
if (!searchHits.length) return [];

const allPubs = searchHits.map((hit) => hit._source);
const pinned = pinnedUUIDs.length > 0 ? allPubs.filter((pub) => pinnedUUIDs.includes(pub.uuid)) : [];
const nonPinned = pinnedUUIDs.length > 0 ? allPubs.filter((pub) => !pinnedUUIDs.includes(pub.uuid)) : allPubs;
const shuffledNonPinned = shuffleArray(nonPinned);
const slotsForNonPinned = DISPLAY_COUNT - pinned.length;
return [...pinned, ...shuffledNonPinned.slice(0, Math.max(0, slotsForNonPinned))];
}, [searchHits, pinnedUUIDs]);

return { publications, isLoading };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ResearchPoweredByHuBMAP';
Loading