diff --git a/apps/portals/elportal/src/config/resources.ts b/apps/portals/elportal/src/config/resources.ts
index 6a10fbf3301..fc3c7913bc4 100644
--- a/apps/portals/elportal/src/config/resources.ts
+++ b/apps/portals/elportal/src/config/resources.ts
@@ -16,6 +16,8 @@ export const cohortBuilderFilesSql = 'SELECT * FROM syn52234677'
export const partnersSql =
'SELECT * FROM syn62661043 order by organizationName desc'
export const whatWeDoSql = 'SELECT * FROM syn64130706'
+export const featuredResearchSql =
+ 'SELECT * FROM syn64542019 ORDER BY order ASC'
export const defaultSearchConfiguration = {
fullTextSearchHelpURL: 'https://help.eliteportal.org/help/search-tips',
diff --git a/apps/portals/elportal/src/pages/HomePageV2.tsx b/apps/portals/elportal/src/pages/HomePageV2.tsx
index 098b3bf1b87..24a621c8aca 100644
--- a/apps/portals/elportal/src/pages/HomePageV2.tsx
+++ b/apps/portals/elportal/src/pages/HomePageV2.tsx
@@ -3,10 +3,15 @@ import {
RecentPublicationsGrid,
ImageCardGridWithLinks,
PortalFeatureHighlights,
+ FeaturedResearch,
} from 'synapse-react-client'
import ELContributeYourData from '@sage-bionetworks/synapse-portal-framework/components/elportal/ELContributeYourData'
import ELGettingStarted from '@sage-bionetworks/synapse-portal-framework/components/elportal/ELGettingStarted'
-import { topPublicationsSql, whatWeDoSql } from '../config/resources'
+import {
+ topPublicationsSql,
+ whatWeDoSql,
+ featuredResearchSql,
+} from '../config/resources'
import { Link, Typography } from '@mui/material'
import analyzetheclouds from '../assets/analyzetheclouds.png'
import computationaltools from '../assets/computationaltools.png'
@@ -45,6 +50,7 @@ export default function HomePage() {
title="What We Do"
summaryText="We provide multi-omic datasets, software tools, and publications that empower researchers to discover the latest health-promoting therapeutics."
/>
+
{/* Commented out for release (see EC-485) */}
{/*
*/}
{/*
Note - this script is run automatically as part of the build command.
+
+## Testing Synapse React Client (SRC) in Synapse Web Client (SWC)
+
+This guide explains how to test changes made in the Synapse React Client (SRC) on the Synapse Web Client (SWC) using **Chrome DevTools**.
+
+1. Make Your Changes in SRC
+
+Edit the code in the `synapse-react-client` package as needed.
+
+2. Build the UMD Files
+
+Run the following command to build the UMD files for SRC:
+
+```bash
+pnpm build:umd
+```
+
+3. Go to https://www.synapse.org in Chrome and open the **Developer Tools**.
+
+4. Locate the synapse-react-client.production.min.js file in the Network tab and right click on the file and select **Override Content**.
+
+5. Go to the **Sources** tab in Developer Tools and go to the **Overrides** section. Make sure **Enabled Local Overrides** is checked.
+
+6. Right-click cdn-www.synapse.org/generated or synapse-react-client.production.min.js and select **Open Folder** or **Open Containing Folder** respectively
+
+7. In your file explore, navigate to packages/synapse-react-client/dist/umd/ and find the updated synapse-react-client.production.min.js file. Drag and drop this file to the generated folder to replace the old synapse-react-client.production.min.js file.
+
+8. Refresh https://www.synapse.org and the site should now be using your override file.
+
+9. Repeat steps above for any SRC changes you want to see in https://www.synapse.org.
diff --git a/packages/synapse-react-client/src/components/FeaturedResearch/FeaturedResearch.test.tsx b/packages/synapse-react-client/src/components/FeaturedResearch/FeaturedResearch.test.tsx
new file mode 100644
index 00000000000..5a504bb3e65
--- /dev/null
+++ b/packages/synapse-react-client/src/components/FeaturedResearch/FeaturedResearch.test.tsx
@@ -0,0 +1,185 @@
+import FeaturedResearch, { FeaturedResearchProps } from './FeaturedResearch'
+import { screen, render, waitFor } from '@testing-library/react'
+import { createWrapper } from '../../testutils/TestingLibraryUtils'
+import useGetQueryResultBundle from '../../synapse-queries/entity/useGetQueryResultBundle'
+import {
+ BatchFileResult,
+ ColumnTypeEnum,
+ QueryResultBundle,
+} from '@sage-bionetworks/synapse-types'
+import { getUseQuerySuccessMock } from '../../testutils/ReactQueryMockUtils'
+import { SynapseClient } from '../../index'
+
+jest.mock('../../synapse-queries/entity/useGetQueryResultBundle')
+const mockUseGetQueryResultBundle = jest.mocked(useGetQueryResultBundle)
+
+describe('FeaturedResearch Tests', () => {
+ const mockProps: FeaturedResearchProps = {
+ sql: 'SELECT * FROM syn64542019',
+ }
+
+ const mockQueryResult: QueryResultBundle = {
+ concreteType: 'org.sagebionetworks.repo.model.table.QueryResultBundle',
+ queryResult: {
+ concreteType: 'org.sagebionetworks.repo.model.table.QueryResult',
+ queryResults: {
+ concreteType: 'org.sagebionetworks.repo.model.table.RowSet',
+ tableId: 'syn64542019',
+ etag: 'DEFAULT',
+ headers: [
+ {
+ name: 'title',
+ columnType: ColumnTypeEnum.STRING,
+ id: '1',
+ },
+ {
+ name: 'description',
+ columnType: ColumnTypeEnum.STRING,
+ id: '2',
+ },
+ {
+ name: 'publicationDate',
+ columnType: ColumnTypeEnum.DATE,
+ id: '3',
+ },
+ {
+ name: 'tags',
+ columnType: ColumnTypeEnum.STRING_LIST,
+ id: '4',
+ },
+ {
+ name: 'affiliation',
+ columnType: ColumnTypeEnum.STRING,
+ id: '5',
+ },
+ {
+ name: 'image',
+ columnType: ColumnTypeEnum.FILEHANDLEID,
+ id: '6',
+ },
+ { name: 'link', columnType: ColumnTypeEnum.LINK, id: '7' },
+ { name: 'order', columnType: ColumnTypeEnum.INTEGER, id: '8' },
+ ],
+ rows: [
+ {
+ rowId: 1,
+ values: [
+ 'Title 1',
+ 'Description 1',
+ '1726164997000',
+ '["tag1_1", "tag1_2"]',
+ 'affiliation 1',
+ '151525812',
+ 'https://mockurl.com/data-release-1',
+ '2',
+ ],
+ },
+ {
+ rowId: 2,
+ values: [
+ 'Title 2',
+ 'Description 2',
+ '1726164997000',
+ '["tag2_1"]',
+ 'affiliation 2',
+ '151468828',
+ 'https://mockurl.com/data-release-2',
+ '2',
+ ],
+ },
+ ],
+ },
+ },
+ selectColumns: [
+ {
+ name: 'title',
+ columnType: ColumnTypeEnum.STRING,
+ id: '1',
+ },
+ {
+ name: 'description',
+ columnType: ColumnTypeEnum.STRING,
+ id: '2',
+ },
+ {
+ name: 'publicationDate',
+ columnType: ColumnTypeEnum.DATE,
+ id: '3',
+ },
+ {
+ name: 'tags',
+ columnType: ColumnTypeEnum.STRING_LIST,
+ id: '4',
+ },
+ {
+ name: 'affiliation',
+ columnType: ColumnTypeEnum.STRING,
+ id: '5',
+ },
+ {
+ name: 'image',
+ columnType: ColumnTypeEnum.FILEHANDLEID,
+ id: '6',
+ },
+ { name: 'link', columnType: ColumnTypeEnum.LINK, id: '7' },
+ { name: 'order', columnType: ColumnTypeEnum.INTEGER, id: '8' },
+ ],
+ }
+
+ const mockFileResult = [
+ {
+ fileHandleId: '151525812',
+ preSignedURL: 'https://mockurl.com/cat1.jpeg',
+ },
+ {
+ fileHandleId: '151468828',
+ preSignedURL: 'https://mockurl.com/cat2.jpeg',
+ },
+ ]
+
+ const mockBatchFileResult: BatchFileResult = {
+ requestedFiles: mockFileResult,
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.spyOn(SynapseClient, 'getFiles').mockResolvedValue(mockBatchFileResult)
+ mockUseGetQueryResultBundle.mockReturnValue(
+ getUseQuerySuccessMock(mockQueryResult),
+ )
+ })
+
+ function renderComponent(props: FeaturedResearchProps) {
+ return render(, {
+ wrapper: createWrapper(),
+ })
+ }
+
+ it('fetches and displays research cards', async () => {
+ mockUseGetQueryResultBundle.mockReturnValue(
+ getUseQuerySuccessMock(mockQueryResult),
+ )
+ renderComponent(mockProps)
+
+ await waitFor(() =>
+ expect(mockUseGetQueryResultBundle).toHaveBeenCalledTimes(1),
+ )
+
+ expect(screen.getByText('Featured Research')).toBeInTheDocument()
+ expect(screen.getByText('Read more')).toBeInTheDocument()
+ expect(screen.getByText('Title 1')).toBeInTheDocument()
+ expect(screen.getByText('Description 1')).toBeInTheDocument()
+ expect(screen.getByText('tag1_1')).toBeInTheDocument()
+ expect(screen.getByText('affiliation 1')).toBeInTheDocument()
+
+ expect(screen.getByText('Title 2')).toBeInTheDocument()
+ expect(screen.getByText('tag2_1')).toBeInTheDocument()
+ expect(screen.getByText('affiliation 2')).toBeInTheDocument()
+ expect(screen.getByText('September, 2024')).toBeInTheDocument()
+
+ await waitFor(() => {
+ const images = document.querySelectorAll('.MuiCardMedia-root')
+ expect(images).toHaveLength(2)
+ })
+ })
+})
diff --git a/packages/synapse-react-client/src/components/FeaturedResearch/FeaturedResearch.tsx b/packages/synapse-react-client/src/components/FeaturedResearch/FeaturedResearch.tsx
new file mode 100644
index 00000000000..c8f9174a29c
--- /dev/null
+++ b/packages/synapse-react-client/src/components/FeaturedResearch/FeaturedResearch.tsx
@@ -0,0 +1,364 @@
+import {
+ Box,
+ CardMedia,
+ Link,
+ Stack,
+ Typography,
+ Skeleton,
+} from '@mui/material'
+import {
+ FileHandleAssociateType,
+ FileHandleAssociation,
+ QueryBundleRequest,
+ Row,
+} from '@sage-bionetworks/synapse-types'
+import useGetQueryResultBundle from '../../synapse-queries/entity/useGetQueryResultBundle'
+import { useGetStablePresignedUrl } from '../../synapse-queries'
+import { getFieldIndex } from '../../utils/functions/queryUtils'
+import * as SynapseConstants from '../../utils/SynapseConstants'
+import { parseEntityIdFromSqlStatement } from '../../utils/functions/SqlFunctions'
+import { formatDate } from '../../utils/functions/DateFormatter'
+import dayjs from 'dayjs'
+
+export type FeaturedResearchProps = {
+ sql: string
+}
+
+export type FeaturedResearchCardProps = {
+ research: Row
+ entityId: string
+ isLoading: boolean
+ affiliationColIndex: number
+ publicationDateColIndex: number
+ titleColIndex: number
+ descriptionColIndex: number
+ tagsColIndex: number
+ linkColIndex: number
+ imageColIndex: number
+}
+
+const useImageUrl = (fileId: string, entityId: string) => {
+ const fha: FileHandleAssociation = {
+ associateObjectId: entityId,
+ associateObjectType: FileHandleAssociateType.TableEntity,
+ fileHandleId: fileId || '',
+ }
+ const stablePresignedUrl = useGetStablePresignedUrl(fha, false, {
+ enabled: !!fileId,
+ })
+ const dataUrl = stablePresignedUrl?.dataUrl
+
+ return dataUrl
+}
+
+const parseTags = (
+ tagsColIndex: number,
+ research: { values: (string | null)[] },
+): string[] => {
+ try {
+ const tags = (
+ research.values[tagsColIndex]
+ ? JSON.parse(research.values[tagsColIndex] || '')
+ : []
+ ) as string[]
+ return tags
+ } catch (e) {
+ console.error(e)
+ return []
+ }
+}
+
+const FeaturedResearchCard = ({
+ research,
+ entityId,
+ affiliationColIndex,
+ publicationDateColIndex,
+ titleColIndex,
+ tagsColIndex,
+ linkColIndex,
+ imageColIndex,
+ isLoading,
+}: FeaturedResearchCardProps) => {
+ const fileId = research.values[imageColIndex] ?? ''
+ const url = useImageUrl(fileId ?? '', entityId)
+ if (isLoading) {
+ return
+ }
+ return (
+
+
+
+ {research.values[affiliationColIndex]}
+
+
+
+ {research.values[titleColIndex]}
+
+
+
+ {parseTags(tagsColIndex, research)[0] && (
+
+ {parseTags(tagsColIndex, research)[0] || ''}
+
+ )}
+
+ {research.values[publicationDateColIndex] &&
+ formatDate(
+ dayjs(Number(research.values[publicationDateColIndex])),
+ 'MMMM, YYYY',
+ )}
+
+
+
+
+
+ )
+}
+
+const FeaturedResearchTopCard = ({
+ research,
+ entityId,
+ affiliationColIndex,
+ titleColIndex,
+ descriptionColIndex,
+ tagsColIndex,
+ linkColIndex,
+ imageColIndex,
+ isLoading,
+}: FeaturedResearchCardProps) => {
+ const fileId = research.values[imageColIndex] ?? ''
+ const url = useImageUrl(fileId || '', entityId)
+ if (isLoading) {
+ return (
+
+ )
+ }
+ return (
+
+
+
+ {parseTags(tagsColIndex, research)[0] && (
+
+ {parseTags(tagsColIndex, research)[0] || ''}
+
+ )}
+
+ {research.values[affiliationColIndex] ?? ''}
+
+
+
+
+
+ {research.values[titleColIndex]}
+
+
+
+ {research.values[descriptionColIndex] ?? ''}
+
+
+ Read more
+
+
+
+ )
+}
+
+function FeaturedResearch(props: FeaturedResearchProps) {
+ const { sql } = props
+
+ const entityId = parseEntityIdFromSqlStatement(sql)
+
+ const queryBundleRequest: QueryBundleRequest = {
+ partMask:
+ SynapseConstants.BUNDLE_MASK_QUERY_SELECT_COLUMNS |
+ SynapseConstants.BUNDLE_MASK_QUERY_RESULTS,
+ concreteType: 'org.sagebionetworks.repo.model.table.QueryBundleRequest',
+ entityId,
+ query: {
+ sql,
+ },
+ }
+ const { data: queryResultBundle, isLoading } =
+ useGetQueryResultBundle(queryBundleRequest)
+
+ const dataRows = queryResultBundle?.queryResult!.queryResults.rows ?? []
+
+ enum ExpectedColumns {
+ TITLE = 'title',
+ DESCRIPTION = 'description',
+ PUBLICATION_DATE = 'publicationDate',
+ TAGS = 'tags',
+ AFFILIATION = 'affiliation',
+ IMAGE = 'image',
+ LINK = 'link',
+ }
+
+ const titleColIndex = getFieldIndex(ExpectedColumns.TITLE, queryResultBundle)
+ const descriptionColIndex = getFieldIndex(
+ ExpectedColumns.DESCRIPTION,
+ queryResultBundle,
+ )
+ const publicationDateColIndex = getFieldIndex(
+ ExpectedColumns.PUBLICATION_DATE,
+ queryResultBundle,
+ )
+ const tagsColIndex = getFieldIndex(ExpectedColumns.TAGS, queryResultBundle)
+ const affiliationColIndex = getFieldIndex(
+ ExpectedColumns.AFFILIATION,
+ queryResultBundle,
+ )
+ const imageColIndex = getFieldIndex(ExpectedColumns.IMAGE, queryResultBundle)
+ const linkColIndex = getFieldIndex(ExpectedColumns.LINK, queryResultBundle)
+
+ const topCard = dataRows[0]
+ const remainingCards = dataRows.slice(1)
+
+ return (
+
+
+ {topCard && (
+
+ )}
+
+
+
+ Featured Research
+
+ {remainingCards.map((research, index) => (
+
+ ))}
+
+
+ )
+}
+
+export default FeaturedResearch
diff --git a/packages/synapse-react-client/src/components/FeaturedResearch/index.ts b/packages/synapse-react-client/src/components/FeaturedResearch/index.ts
new file mode 100644
index 00000000000..b79455877e5
--- /dev/null
+++ b/packages/synapse-react-client/src/components/FeaturedResearch/index.ts
@@ -0,0 +1,4 @@
+import FeaturedResearch from './FeaturedResearch'
+import type { FeaturedResearchProps } from './FeaturedResearch'
+export { FeaturedResearch, FeaturedResearchProps }
+export default FeaturedResearch
diff --git a/packages/synapse-react-client/src/components/index.ts b/packages/synapse-react-client/src/components/index.ts
index 6ed6945a7a7..55fe60c2f7f 100644
--- a/packages/synapse-react-client/src/components/index.ts
+++ b/packages/synapse-react-client/src/components/index.ts
@@ -86,6 +86,7 @@ export * from './SageResourcesPopover'
export * from './RecentPublicationsGrid'
export * from './ImageCardGridWithLinks'
export * from './PortalFeatureHighlights'
+export * from './FeaturedResearch'
// TODO: Find a better way to expose Icon components
export { Project as ProjectIcon } from '../assets/themed_icons/Project'