diff --git a/frontend/packages/data-portal/.gitignore b/frontend/packages/data-portal/.gitignore
index 68c5d18f0..516a31143 100644
--- a/frontend/packages/data-portal/.gitignore
+++ b/frontend/packages/data-portal/.gitignore
@@ -1,3 +1,4 @@
+coverage/
node_modules/
/test-results/
/playwright-report/
diff --git a/frontend/packages/data-portal/app/components/AuthorLegend.tsx b/frontend/packages/data-portal/app/components/AuthorLegend.tsx
index e07c6b3a0..dd2c7d3ed 100644
--- a/frontend/packages/data-portal/app/components/AuthorLegend.tsx
+++ b/frontend/packages/data-portal/app/components/AuthorLegend.tsx
@@ -42,7 +42,9 @@ export function AuthorLegend({ inline = false }: { inline?: boolean }) {
}
classes={{
- tooltip: '!p-sds-m !min-w-fit border-solid border border-sds-gray-300',
+ tooltip:
+ // need to specify background color because it's not visible locally
+ '!p-sds-m !min-w-fit border-solid border border-sds-gray-300 !bg-white',
}}
placement="top-start"
>
diff --git a/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.mock.tsx b/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.mock.tsx
new file mode 100644
index 000000000..f503b2baf
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.mock.tsx
@@ -0,0 +1,18 @@
+import { ComponentProps } from 'react'
+
+import { MockLinkComponent } from 'app/components/Link'
+
+import { AuthorLink } from './AuthorLink'
+
+export function MockAuthorLink({
+ author,
+ large,
+}: ComponentProps) {
+ return (
+
+ )
+}
diff --git a/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.test.tsx b/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.test.tsx
new file mode 100644
index 000000000..29f81f3e1
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.test.tsx
@@ -0,0 +1,62 @@
+import { it } from '@jest/globals'
+import { render, screen } from '@testing-library/react'
+import { pick } from 'lodash-es'
+
+import { MockLinkComponent } from 'app/components/Link'
+import { TestIds } from 'app/constants/testIds'
+
+import { AuthorLink } from './AuthorLink'
+import { ORC_ID_URL } from './constants'
+import { AuthorInfo } from './types'
+
+const DEFAULT_AUTHOR: AuthorInfo = {
+ corresponding_author_status: true,
+ email: 'actin.filament@gmail.com',
+ name: 'Actin Filament',
+ orcid: '0000-0000-0000-0000',
+ primary_author_status: false,
+}
+
+it('should not be link if orc ID is not provided', () => {
+ render()
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+})
+
+it('should be a link if orc ID is provided', () => {
+ render(
+ ,
+ )
+
+ const link = screen.getByRole('link')
+ expect(link).toBeInTheDocument()
+ expect(link).toHaveProperty('href', `${ORC_ID_URL}/${DEFAULT_AUTHOR.orcid}`)
+ expect(screen.getByTestId(TestIds.OrcIdIcon)).toBeInTheDocument()
+})
+
+it('should have icon if user is corresponding author', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId(TestIds.EnvelopeIcon)).toBeInTheDocument()
+})
+
+it('should use regular icon size', () => {
+ render()
+
+ const text = screen.getByText(DEFAULT_AUTHOR.name)
+ expect(text).toHaveClass('text-xs')
+})
+
+it('should use large icon size', () => {
+ render()
+
+ const text = screen.getByText(DEFAULT_AUTHOR.name)
+ expect(text).toHaveClass('text-sm')
+})
diff --git a/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.tsx b/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.tsx
new file mode 100644
index 000000000..b9256da4b
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/AuthorLink/AuthorLink.tsx
@@ -0,0 +1,70 @@
+import { LinkProps } from '@remix-run/react'
+import { ComponentType } from 'react'
+
+import { EnvelopeIcon, ORCIDIcon } from 'app/components/icons'
+import { Link } from 'app/components/Link'
+import { cns } from 'app/utils/cns'
+
+import { ORC_ID_URL } from './constants'
+import { AuthorInfo } from './types'
+
+const BASE_ICON_SIZE_PX = 10
+const LARGE_ICON_SIZE_PX = 14
+
+export function AuthorLink({
+ author,
+ large,
+ LinkComponent = Link,
+}: {
+ author: AuthorInfo
+ large?: boolean
+ LinkComponent?: ComponentType
+}) {
+ const iconSize = large ? LARGE_ICON_SIZE_PX : BASE_ICON_SIZE_PX
+ const content = (
+
+
+ {author.orcid && (
+
+ )}
+
+
+ {author.name}
+
+
+
+ {author.corresponding_author_status && (
+
+ )}
+
+ )
+
+ if (author.orcid) {
+ return (
+
+ {content}
+
+ )
+ }
+
+ return content
+}
diff --git a/frontend/packages/data-portal/app/components/AuthorLink/constants.ts b/frontend/packages/data-portal/app/components/AuthorLink/constants.ts
new file mode 100644
index 000000000..7d80a89de
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/AuthorLink/constants.ts
@@ -0,0 +1 @@
+export const ORC_ID_URL = 'https://orcid.org'
diff --git a/frontend/packages/data-portal/app/components/AuthorLink/index.ts b/frontend/packages/data-portal/app/components/AuthorLink/index.ts
new file mode 100644
index 000000000..c07b6d24b
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/AuthorLink/index.ts
@@ -0,0 +1,3 @@
+export * from './AuthorLink'
+export * from './AuthorLink.mock'
+export * from './types'
diff --git a/frontend/packages/data-portal/app/components/AuthorLink/types.ts b/frontend/packages/data-portal/app/components/AuthorLink/types.ts
new file mode 100644
index 000000000..a2fc4dc65
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/AuthorLink/types.ts
@@ -0,0 +1,10 @@
+import { Dataset_Authors } from 'app/__generated__/graphql'
+
+export type AuthorInfo = Pick<
+ Dataset_Authors,
+ | 'corresponding_author_status'
+ | 'email'
+ | 'name'
+ | 'orcid'
+ | 'primary_author_status'
+>
diff --git a/frontend/packages/data-portal/app/components/BrowseData/DatasetTable.tsx b/frontend/packages/data-portal/app/components/BrowseData/DatasetTable.tsx
index de6a8afa1..494934afa 100644
--- a/frontend/packages/data-portal/app/components/BrowseData/DatasetTable.tsx
+++ b/frontend/packages/data-portal/app/components/BrowseData/DatasetTable.tsx
@@ -156,11 +156,7 @@ export function DatasetTable() {
/>
>
) : (
-
+
)}
diff --git a/frontend/packages/data-portal/app/components/Dataset/DatasetAuthors.test.tsx b/frontend/packages/data-portal/app/components/Dataset/DatasetAuthors.test.tsx
new file mode 100644
index 000000000..8e5b019b2
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/Dataset/DatasetAuthors.test.tsx
@@ -0,0 +1,127 @@
+import { render, screen } from '@testing-library/react'
+
+import { AuthorInfo, MockAuthorLink } from 'app/components/AuthorLink'
+
+import { DatasetAuthors } from './DatasetAuthors'
+
+const DEFAULT_AUTHORS: AuthorInfo[] = [
+ { name: 'Foo', corresponding_author_status: true },
+ { name: 'Bar' },
+ { name: 'Foo Bar', primary_author_status: true },
+ { name: 'Foobar Foo' },
+ { name: 'Foo Bar 2', primary_author_status: true },
+ { name: 'Foo 2', corresponding_author_status: true },
+ { name: 'Bar 2' },
+]
+
+const AUTHOR_MAP = Object.fromEntries(
+ DEFAULT_AUTHORS.map((author) => [author.name, author]),
+)
+
+it('should render authors', () => {
+ render()
+
+ DEFAULT_AUTHORS.forEach((author) =>
+ expect(screen.getByText(author.name)).toBeInTheDocument(),
+ )
+})
+
+it('should sort primary authors', () => {
+ render()
+ const authorNode = screen.getByRole('paragraph')
+ const authors = (authorNode.textContent ?? '').split(', ')
+
+ expect(AUTHOR_MAP[authors[0]].primary_author_status).toBe(true)
+ expect(AUTHOR_MAP[authors[1]].primary_author_status).toBe(true)
+})
+
+it('should sort other authors', () => {
+ render()
+ const authorNode = screen.getByRole('paragraph')
+ const authors = (authorNode.textContent ?? '').split(', ')
+ const otherAuthors = authors.slice(2, -2)
+
+ otherAuthors.forEach((author) => {
+ expect(AUTHOR_MAP[author].primary_author_status).toBeUndefined()
+ expect(AUTHOR_MAP[author].corresponding_author_status).toBeUndefined()
+ })
+})
+
+it('should sort corresponding authors', () => {
+ render()
+ const authorNode = screen.getByRole('paragraph')
+ const authors = (authorNode.textContent ?? '').split(', ')
+
+ expect(AUTHOR_MAP[authors.at(-1) ?? ''].corresponding_author_status).toBe(
+ true,
+ )
+ expect(AUTHOR_MAP[authors.at(-2) ?? ''].corresponding_author_status).toBe(
+ true,
+ )
+})
+
+it('should render author links', () => {
+ const authors = DEFAULT_AUTHORS.map((author, idx) => ({
+ ...author,
+ orcid: `0000-0000-0000-000${idx}`,
+ }))
+
+ render(
+ ,
+ )
+
+ authors.forEach((author) =>
+ expect(
+ screen.getByRole('link', { name: `${author.name}` }),
+ ).toBeInTheDocument(),
+ )
+})
+
+it('should not render author links when compact', () => {
+ const authors = DEFAULT_AUTHORS.map((author, idx) => ({
+ ...author,
+ orcid: `0000-0000-0000-000${idx}`,
+ }))
+
+ render(
+ ,
+ )
+
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+})
+
+it('should not render other authors when compact', () => {
+ render()
+ const authorNode = screen.getByRole('paragraph')
+ const authors = (authorNode.textContent ?? '').split(', ')
+ const otherAuthors = authors.slice(2, -2)
+
+ otherAuthors.forEach((author) =>
+ expect(screen.queryByText(author)).not.toBeInTheDocument(),
+ )
+})
+
+it('should render comma if compact and has corresponding authors', () => {
+ render()
+ expect(screen.getByText((text) => text.includes('... ,'))).toBeInTheDocument()
+})
+
+it('should not render comma for others if compact and no corresponding authors', () => {
+ render(
+ !author.corresponding_author_status,
+ )}
+ compact
+ />,
+ )
+
+ expect(screen.getByText((text) => text.includes('...'))).toBeInTheDocument()
+ expect(
+ screen.queryByText((text) => text.includes('... ,')),
+ ).not.toBeInTheDocument()
+})
diff --git a/frontend/packages/data-portal/app/components/Dataset/DatasetAuthors.tsx b/frontend/packages/data-portal/app/components/Dataset/DatasetAuthors.tsx
index 76c22d022..9eb2e29f2 100644
--- a/frontend/packages/data-portal/app/components/Dataset/DatasetAuthors.tsx
+++ b/frontend/packages/data-portal/app/components/Dataset/DatasetAuthors.tsx
@@ -1,30 +1,31 @@
-import { Fragment, useMemo } from 'react'
+import { ComponentProps, ComponentType, Fragment, useMemo } from 'react'
-import { Dataset_Authors } from 'app/__generated__/graphql'
-import { EnvelopeIcon } from 'app/components/icons'
-import { Link } from 'app/components/Link'
+import { AuthorInfo, AuthorLink } from 'app/components/AuthorLink'
import { cns } from 'app/utils/cns'
-export type AuthorInfo = Pick<
- Dataset_Authors,
- 'name' | 'primary_author_status' | 'corresponding_author_status' | 'email'
->
-
function getAuthorKey(author: AuthorInfo) {
return author.name + author.email
}
+const SEPARATOR = `, `
+
+function getAuthorIds(authors: AuthorInfo[]) {
+ return authors.map((author) => author.name + author.email + author.orcid)
+}
+
export function DatasetAuthors({
+ AuthorLinkComponent = AuthorLink,
authors,
className,
- separator = ';',
compact = false,
+ large,
subtle = false,
}: {
+ AuthorLinkComponent?: ComponentType>
authors: AuthorInfo[]
className?: string
- separator?: string
compact?: boolean
+ large?: boolean
subtle?: boolean
}) {
// TODO: make the below grouping more efficient and/or use GraphQL ordering
@@ -39,10 +40,6 @@ export function DatasetAuthors({
!(author.primary_author_status || author.corresponding_author_status),
)
- const envelopeIcon = (
-
- )
-
const otherCollapsed = useMemo(() => {
const ellipsis = '...'
@@ -50,10 +47,18 @@ export function DatasetAuthors({
if (authorsCorresponding.length === 0) {
return ellipsis
}
- return `${ellipsis} ${separator} `
+ return `${ellipsis} ${SEPARATOR}`
}
return null
- }, [authorsOther, authorsCorresponding, compact, separator])
+ }, [
+ authorsCorresponding.length,
+ authorsOther.length,
+ compact,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ getAuthorIds(authorsCorresponding),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ getAuthorIds(authorsOther),
+ ])
// TODO: let's find a better way of doing this
return (
@@ -61,34 +66,38 @@ export function DatasetAuthors({
{authorsPrimary.map((author, i, arr) => (
- {author.name}
+ {compact ? (
+ author.name
+ ) : (
+
+ )}
{!(
authorsOther.length + authorsCorresponding.length === 0 &&
arr.length - 1 === i
- ) && `${separator} `}
+ ) && SEPARATOR}
))}
+
{compact
? otherCollapsed
: authorsOther.map((author, i, arr) => (
- {author.name}
+
{!(authorsCorresponding.length === 0 && arr.length - 1 === i) &&
- `${separator} `}
+ SEPARATOR}
))}
+
{authorsCorresponding.map((author, i, arr) => (
- {author.name}
- {!compact &&
- (author.email ? (
- {envelopeIcon}
- ) : (
- envelopeIcon
- ))}
- {!(arr.length - 1 === i) && `${separator} `}
+ {compact ? (
+ author.name
+ ) : (
+
+ )}
+ {!(arr.length - 1 === i) && SEPARATOR}
))}
diff --git a/frontend/packages/data-portal/app/components/Dataset/DatasetMetadataTable.tsx b/frontend/packages/data-portal/app/components/Dataset/DatasetMetadataTable.tsx
index 3a5d604cd..d67edf354 100644
--- a/frontend/packages/data-portal/app/components/Dataset/DatasetMetadataTable.tsx
+++ b/frontend/packages/data-portal/app/components/Dataset/DatasetMetadataTable.tsx
@@ -3,12 +3,13 @@ import { isString } from 'lodash-es'
import { AccordionMetadataTable } from 'app/components/AccordionMetadataTable'
import { AuthorLegend } from 'app/components/AuthorLegend'
+import { AuthorInfo } from 'app/components/AuthorLink'
import { DatabaseEntry } from 'app/components/DatabaseEntry'
import { Link } from 'app/components/Link'
import { useI18n } from 'app/hooks/useI18n'
import { getTableData } from 'app/utils/table'
-import { AuthorInfo, DatasetAuthors } from './DatasetAuthors'
+import { DatasetAuthors } from './DatasetAuthors'
import { DatasetType } from './type'
function DatabaseEntryList({ entries }: { entries: string }) {
@@ -95,14 +96,11 @@ export function DatasetMetadataTable({
labelExtra: ,
renderValue: () => {
return (
-
+
)
},
values: [],
- className: 'leading-sds-body-xs',
+ className: 'leading-sds-body-s',
},
{
diff --git a/frontend/packages/data-portal/app/components/Link/Link.mock.tsx b/frontend/packages/data-portal/app/components/Link/Link.mock.tsx
new file mode 100644
index 000000000..abcfc2f98
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/Link/Link.mock.tsx
@@ -0,0 +1,7 @@
+import { LinkProps } from '@remix-run/react'
+import { isString } from 'lodash-es'
+
+export function MockLinkComponent({ to, ...props }: LinkProps) {
+ // eslint-disable-next-line jsx-a11y/anchor-has-content
+ return
+}
diff --git a/frontend/packages/data-portal/app/components/Link.tsx b/frontend/packages/data-portal/app/components/Link/Link.tsx
similarity index 100%
rename from frontend/packages/data-portal/app/components/Link.tsx
rename to frontend/packages/data-portal/app/components/Link/Link.tsx
diff --git a/frontend/packages/data-portal/app/components/Link/index.ts b/frontend/packages/data-portal/app/components/Link/index.ts
new file mode 100644
index 000000000..f019a126c
--- /dev/null
+++ b/frontend/packages/data-portal/app/components/Link/index.ts
@@ -0,0 +1,2 @@
+export * from './Link'
+export * from './Link.mock'
diff --git a/frontend/packages/data-portal/app/components/Run/AnnotationOveriewTable.tsx b/frontend/packages/data-portal/app/components/Run/AnnotationOveriewTable.tsx
index 1d3472fc4..702849995 100644
--- a/frontend/packages/data-portal/app/components/Run/AnnotationOveriewTable.tsx
+++ b/frontend/packages/data-portal/app/components/Run/AnnotationOveriewTable.tsx
@@ -30,10 +30,10 @@ export function AnnotationOverviewTable() {
: t('annotationAuthors'),
labelExtra: ,
renderValue: () => {
- return
+ return
},
values: [''],
- className: 'leading-sds-body-xs',
+ className: 'leading-sds-body-s',
},
{
label: t('publication'),
diff --git a/frontend/packages/data-portal/app/components/Run/AnnotationTable.tsx b/frontend/packages/data-portal/app/components/Run/AnnotationTable.tsx
index 16a022824..fcd4332f1 100644
--- a/frontend/packages/data-portal/app/components/Run/AnnotationTable.tsx
+++ b/frontend/packages/data-portal/app/components/Run/AnnotationTable.tsx
@@ -185,11 +185,7 @@ export function AnnotationTable() {
-
+
),
diff --git a/frontend/packages/data-portal/app/components/icons/EnvelopeIcon.tsx b/frontend/packages/data-portal/app/components/icons/EnvelopeIcon.tsx
index 5aadea474..2931777ba 100644
--- a/frontend/packages/data-portal/app/components/icons/EnvelopeIcon.tsx
+++ b/frontend/packages/data-portal/app/components/icons/EnvelopeIcon.tsx
@@ -1,8 +1,11 @@
+import { TestIds } from 'app/constants/testIds'
+
import { IconProps } from './icon.types'
export function EnvelopeIcon(props: IconProps) {
return (