diff --git a/src/components/Layout/menuItems.ts b/src/components/Layout/menuItems.ts index f48a94fd..7f3e15cf 100644 --- a/src/components/Layout/menuItems.ts +++ b/src/components/Layout/menuItems.ts @@ -122,6 +122,12 @@ export const menuItems = [ }, ], }, + { + test: /\/fresh-finds/, + path: 'fresh-finds', + icon: 'fair', + label: 'Fresh Finds', + }, { test: /\/fair-certification/, path: 'fair-certification', diff --git a/src/components/common/CrDrawer/CrDrawer.tsx b/src/components/common/CrDrawer/CrDrawer.tsx index fb18de0c..8e06f4e4 100644 --- a/src/components/common/CrDrawer/CrDrawer.tsx +++ b/src/components/common/CrDrawer/CrDrawer.tsx @@ -49,6 +49,8 @@ interface OrcidDrawerContentProps { error?: Error | null; isLoading?: boolean; removeLiveActions?: boolean; + /** When true with removeLiveActions, hides “Open in the Repository” (e.g. no OAI on record). */ + hideRepositoryButton?: boolean; onVisibilityChange?: (article: ArticleAdditionalData) => Promise; outputsUrl?: string; isChangingVisibility?: boolean; @@ -66,6 +68,7 @@ export const CrDrawer: React.FC = ({ article, error, removeLiveActions, + hideRepositoryButton = false, isLoading, onVisibilityChange, outputsUrl, @@ -176,13 +179,15 @@ export const CrDrawer: React.FC = ({ > Open in CORE - + {!hideRepositoryButton && ( + + )} ) : (
diff --git a/src/features/FreshFinds/FreshFindsFeature.css b/src/features/FreshFinds/FreshFindsFeature.css new file mode 100644 index 00000000..1b258c18 --- /dev/null +++ b/src/features/FreshFinds/FreshFindsFeature.css @@ -0,0 +1,39 @@ +.fresh-finds-page { + margin-block-start: 1rem; +} + +.fresh-finds-table-header__title { + margin: 0; + font-size: 1.25rem; + text-align: left; +} + +.fresh-finds-table-header__subtitle { + margin-block: 0.75rem 1rem; + margin-inline: 0; + color: rgba(0, 0, 0, 0.65); +} + +.fresh-finds-column.fresh-finds-column--authors, +.fresh-finds-column.fresh-finds-column--authors.ant-table-cell { + min-width: 22rem; +} + +.fresh-finds__doi-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: #b75400; + text-decoration: none; +} + +.fresh-finds__doi-link:hover, +.fresh-finds__doi-link:focus-visible { + color: #9a4600; + text-decoration: underline; +} + +.fresh-finds__doi-icon { + flex-shrink: 0; + color: inherit; +} diff --git a/src/features/FreshFinds/FreshFindsFeature.tsx b/src/features/FreshFinds/FreshFindsFeature.tsx new file mode 100644 index 00000000..d8d3eaaa --- /dev/null +++ b/src/features/FreshFinds/FreshFindsFeature.tsx @@ -0,0 +1,36 @@ +import { CrFeatureLayout, CrHeader, CrShowMore } from '@oacore/core-ui'; + +import { useDataProviderStore } from '@/store/dataProviderStore'; + +import { FreshFindsTable } from './components/FreshFindsTable.tsx'; +import { useFreshFindsData } from './hooks/useFreshFindsData'; +import { articleTemplateData } from './texts'; + +import './FreshFindsFeature.css'; + +export const FreshFindsFeature = () => { + const { selectedDataProvider } = useDataProviderStore(); + const { data: records, error, isLoading } = useFreshFindsData( + selectedDataProvider?.id ?? null, + ); + + return ( + + + } + /> +
+ +
+
+ ); +}; diff --git a/src/features/FreshFinds/components/FreshFindsColumns.tsx b/src/features/FreshFinds/components/FreshFindsColumns.tsx new file mode 100644 index 00000000..45e95fe9 --- /dev/null +++ b/src/features/FreshFinds/components/FreshFindsColumns.tsx @@ -0,0 +1,84 @@ +import { LinkOutlined } from '@ant-design/icons'; + +import type { ReusableTableColumn } from '@components/common/CrTable/types.ts'; + +import type { FreshFindsRecord } from '../types/data.types'; +import { buildFreshFindsDoiHref, formatFreshFindsAuthors } from '../utils/freshFindsDisplay'; + +const sortAuthors = (a: FreshFindsRecord, b: FreshFindsRecord): number => { + const sa = formatFreshFindsAuthors(a.affiliation_info).toLowerCase(); + const sb = formatFreshFindsAuthors(b.affiliation_info).toLowerCase(); + return sa.localeCompare(sb); +}; + +const sortDoi = (a: FreshFindsRecord, b: FreshFindsRecord): number => { + const sa = a.DOI != null ? String(a.DOI).toLowerCase() : ''; + const sb = b.DOI != null ? String(b.DOI).toLowerCase() : ''; + return sa.localeCompare(sb); +}; + +/** Keys must match column `key` for {@link useTablePaginationAndSort} custom sorting. */ +export const freshFindsCustomSorters: Record< + string, + (a: FreshFindsRecord, b: FreshFindsRecord) => number +> = { + authors: sortAuthors, + DOI: sortDoi, +}; + +export const createColumns = (): ReusableTableColumn[] => [ + { + key: 'authors', + title: 'Authors', + dataIndex: 'affiliation_info', + width: '58%', + align: 'left', + className: 'fresh-finds-column fresh-finds-column--authors', + sortable: true, + showSortIcon: true, + sorter: sortAuthors, + render: (_value: unknown, record: FreshFindsRecord) => { + const text = formatFreshFindsAuthors(record.affiliation_info); + return text !== '' ? text : '-'; + }, + }, + { + key: 'DOI', + title: 'DOI', + dataIndex: 'DOI', + align: 'left', + className: 'fresh-finds-column fresh-finds-column--doi', + sortable: true, + showSortIcon: true, + sorter: sortDoi, + render: (value: unknown, record: FreshFindsRecord) => { + const doi = + value != null ? String(value) : record.DOI != null ? String(record.DOI) : ''; + if (doi === '') { + return '-'; + } + const href = buildFreshFindsDoiHref(doi); + if (href === '') { + return '-'; + } + return ( + e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + } + }} + aria-label={`Open DOI ${doi} in a new tab`} + > + + {doi} + + ); + }, + }, +]; diff --git a/src/features/FreshFinds/components/FreshFindsTable.tsx b/src/features/FreshFinds/components/FreshFindsTable.tsx new file mode 100644 index 00000000..fb548a91 --- /dev/null +++ b/src/features/FreshFinds/components/FreshFindsTable.tsx @@ -0,0 +1,140 @@ +import { useCallback, useMemo, useState } from 'react'; +import { AccessPlaceholder, CrPaper } from '@oacore/core-ui'; + +import { CrDrawer } from '@components/common/CrDrawer/CrDrawer.tsx'; +import { CrTable } from '@components/common/CrTable/CrTable.tsx'; +import type { DrawerConfig } from '@components/common/CrTable/types.ts'; +import { getScrollConfig } from '@hooks/useScrollView.ts'; +import { useTablePaginationAndSort } from '@/hooks/useTablePaginationAndSort.ts'; +import { useBillingPlanData } from '@features/Orcid/hooks/useBillingPlanData.ts'; +import { useOrganisation } from '@features/Settings/OrganisationalSettings/hooks/useOrganisation.ts'; + +import { createColumns, freshFindsCustomSorters } from './FreshFindsColumns.tsx'; +import { useDownloadFreshFindsCsv } from '../hooks/useDownloadFreshFindsCsv'; +import type { FreshFindsRecord } from '../types/data.types'; +import { + buildFreshFindsOutputsUrl, + filterFreshFindsRecords, + formatFreshFindsAuthors, + mapFreshFindsRecordToArticle, +} from '../utils/freshFindsDisplay'; +import { articleTemplateData } from '../texts'; + +type FreshFindsTableRow = FreshFindsRecord & { __rowKey: string }; + +type FreshFindsTableProps = { + records: FreshFindsRecord[]; + isLoading: boolean; + error: unknown; + dataProviderName: string; +}; + +export const FreshFindsTable = ({ + records, + isLoading, + error, + dataProviderName, +}: FreshFindsTableProps) => { + const [searchTerm, setSearchTerm] = useState(''); + const { organisation } = useOrganisation(); + const { downloadCsv, isLoading: downloadCsvLoading } = useDownloadFreshFindsCsv(); + + const filteredRecords = useMemo( + () => filterFreshFindsRecords(records, searchTerm), + [records, searchTerm], + ); + + const { isStartingPlan, displayData: billingPlanData } = useBillingPlanData( + filteredRecords, + organisation, + ); + + const { visibleData, hasMore, handleSort, handleLoadMore, totalLength } = + useTablePaginationAndSort({ + data: billingPlanData, + itemsPerPage: 10, + customSorters: freshFindsCustomSorters, + }); + + const columns = useMemo(() => createColumns(), []); + + const dataWithUniqueKeys = useMemo( + () => + visibleData.map((record, index) => ({ + ...record, + __rowKey: `${String(record.DOI ?? '')}-${formatFreshFindsAuthors(record.affiliation_info)}-${index}`, + })), + [visibleData], + ); + + const drawerConfig: DrawerConfig = useMemo( + () => ({ + enabled: true, + content: (record: FreshFindsTableRow) => ( +
+ +
+ ), + }), + [], + ); + + const handleSearch = useCallback((term: string) => { + setSearchTerm(term); + }, []); + + return ( + +
+

+ {articleTemplateData.table.title} +

+

+ Papers we discovered elsewhere authored by {dataProviderName} you might + consider adding to your repository. +

+
+
+ + rowKey="__rowKey" + data={dataWithUniqueKeys} + columns={columns} + loading={isLoading} + error={error} + actions={[]} + sortable={!isStartingPlan} + onSort={handleSort} + onDownloadCsv={downloadCsv} + downloadCsvLoading={downloadCsvLoading} + showLoadMore={!isStartingPlan && hasMore} + onLoadMore={handleLoadMore} + loadMoreText="Show more" + size="middle" + bordered={false} + showFooter={!isStartingPlan} + totalLength={totalLength} + searchable + searchPlaceholder="Authors or DOI…" + onSearch={handleSearch} + searchValue={searchTerm} + scroll={getScrollConfig()} + drawer={drawerConfig} + /> + {isStartingPlan && ( + + )} +
+
+ ); +}; diff --git a/src/features/FreshFinds/constants/freshFindsProdApi.ts b/src/features/FreshFinds/constants/freshFindsProdApi.ts new file mode 100644 index 00000000..dcca952e --- /dev/null +++ b/src/features/FreshFinds/constants/freshFindsProdApi.ts @@ -0,0 +1,5 @@ +/** Fresh-finds is served from production API while the dashboard may use api-dev elsewhere. */ +export const FRESH_FINDS_PUBLIC_API_ORIGIN = 'https://api.core.ac.uk'; + +export const buildFreshFindsInternalUrl = (dataProviderId: number): string => + `${FRESH_FINDS_PUBLIC_API_ORIGIN}/internal/data-providers/${dataProviderId}/fresh-finds`; diff --git a/src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts b/src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts new file mode 100644 index 00000000..bed58943 --- /dev/null +++ b/src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts @@ -0,0 +1,14 @@ +import { useDownloadDataProviderCsv } from '@/hooks/useDownloadDataProviderCsv'; + +import { buildFreshFindsInternalUrl } from '../constants/freshFindsProdApi'; + +const FRESH_FINDS_CSV_CONFIG = { + endpoint: 'fresh-finds', + filenamePrefix: 'fresh-finds-csv', + mutationKey: 'fresh-finds/download-csv', + pathBuilder: (dataProviderId: number) => + `${buildFreshFindsInternalUrl(dataProviderId)}?accept=text/csv`, +} as const; + +export const useDownloadFreshFindsCsv = () => + useDownloadDataProviderCsv(FRESH_FINDS_CSV_CONFIG); diff --git a/src/features/FreshFinds/hooks/useFreshFindsData.ts b/src/features/FreshFinds/hooks/useFreshFindsData.ts new file mode 100644 index 00000000..73716eab --- /dev/null +++ b/src/features/FreshFinds/hooks/useFreshFindsData.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import useSWR from 'swr'; + +import { createSWRKey, fetcher, swrDefaultConfig } from '@/config/swr'; +import { captureHandledError } from '@/utils/captureHandledError'; +import { useDataProviderStore } from '@/store/dataProviderStore'; +import { buildFreshFindsInternalUrl } from '../constants/freshFindsProdApi'; +import type { FreshFindsRecord } from '../types/data.types'; +import { normalizeFreshFindsResponse } from '../utils/normalizeFreshFindsResponse'; + +export const useFreshFindsData = (dataProviderId?: number | null) => { + const { isLoaded, selectedSetSpec } = useDataProviderStore(); + + const params: Record = {}; + if (selectedSetSpec) { + params.set = selectedSetSpec; + } + + const key = + isLoaded && dataProviderId != null + ? createSWRKey( + buildFreshFindsInternalUrl(dataProviderId), + Object.keys(params).length > 0 ? params : undefined, + ) + : null; + + const { data, error, isLoading, mutate } = useSWR(key, () => fetcher(key!), { + ...swrDefaultConfig, + onError: (err) => { + captureHandledError(err, { + tags: { feature: 'fresh-finds', action: 'fetch' }, + extra: { dataProviderId }, + }); + }, + }); + + const records: FreshFindsRecord[] = useMemo(() => { + if (dataProviderId == null || error != null) { + return []; + } + if (data == null) { + return []; + } + return normalizeFreshFindsResponse(data); + }, [data, dataProviderId, error]); + + return { + data: records, + error, + isLoading: isLoading || !isLoaded, + mutate, + }; +}; diff --git a/src/features/FreshFinds/texts/fresh.json b/src/features/FreshFinds/texts/fresh.json new file mode 100644 index 00000000..74c0db56 --- /dev/null +++ b/src/features/FreshFinds/texts/fresh.json @@ -0,0 +1,7 @@ +{ + "title": "Fresh finds", + "description": "Organisation's footprint refers to the impact and presence of a organisation within the academic community or their field of study. It encompasses various aspects of their research output, collaborations, influence, and contributions.", + "table": { + "title": "Papers authored by your institution not in your repository" + } +} diff --git a/src/features/FreshFinds/texts/index.ts b/src/features/FreshFinds/texts/index.ts new file mode 100644 index 00000000..6fe60c68 --- /dev/null +++ b/src/features/FreshFinds/texts/index.ts @@ -0,0 +1,5 @@ +import freshJson from './fresh.json'; + +export const articleTemplateData = { + ...freshJson, +}; diff --git a/src/features/FreshFinds/types/data.types.ts b/src/features/FreshFinds/types/data.types.ts new file mode 100644 index 00000000..b0494fd6 --- /dev/null +++ b/src/features/FreshFinds/types/data.types.ts @@ -0,0 +1,19 @@ +/** Institution block under each author (API may send one object or an array). */ +export interface FreshFindsAffiliationBlock { + name?: string[]; + ror_id?: string[]; +} + +export interface FreshFindsAffiliationInfo { + author_name?: string; + affiliation?: FreshFindsAffiliationBlock | FreshFindsAffiliationBlock[]; +} + +/** + * Row from `/fresh-finds`: DOI + affiliation_info (authors and nested affiliations). + */ +export type FreshFindsRecord = { + DOI?: string; + affiliation_info?: FreshFindsAffiliationInfo[]; + status?: string; +} & Record; diff --git a/src/features/FreshFinds/utils/freshFindsDisplay.ts b/src/features/FreshFinds/utils/freshFindsDisplay.ts new file mode 100644 index 00000000..5446dc25 --- /dev/null +++ b/src/features/FreshFinds/utils/freshFindsDisplay.ts @@ -0,0 +1,70 @@ +import type { ArticleAdditionalData } from '@/hooks/useArticleData'; + +import type { FreshFindsAffiliationInfo, FreshFindsRecord } from '../types/data.types'; + +export const formatFreshFindsAuthors = ( + affiliationInfo: FreshFindsAffiliationInfo[] | undefined, +): string => { + if (!Array.isArray(affiliationInfo) || affiliationInfo.length === 0) { + return ''; + } + return affiliationInfo + .map((info) => info?.author_name) + .filter(Boolean) + .join(', '); +}; + +export const filterFreshFindsRecords = ( + records: FreshFindsRecord[], + searchTerm: string, +): FreshFindsRecord[] => { + const needle = searchTerm.trim().toLowerCase(); + if (needle === '') { + return records; + } + + return records.filter((item) => { + const authors = formatFreshFindsAuthors(item.affiliation_info).toLowerCase(); + const doi = item.DOI != null ? String(item.DOI).toLowerCase() : ''; + return authors.includes(needle) || doi.includes(needle); + }); +}; + +const stripDoiOrgPrefix = (doi: string): string => + doi.trim().replace(/^\s*https?:\/\/doi\.org\//i, ''); + +export const buildFreshFindsDoiHref = (doi: string): string => { + const slug = stripDoiOrgPrefix(doi); + if (slug === '') { + return ''; + } + return `https://doi.org/${encodeURIComponent(slug)}`; +}; + +export const buildFreshFindsOutputsUrl = (doi: string | undefined): string => { + const trimmed = doi?.trim() ?? ''; + if (trimmed === '') { + return 'https://core.ac.uk/'; + } + const href = buildFreshFindsDoiHref(trimmed); + return href !== '' ? href : 'https://core.ac.uk/'; +}; + +export const mapFreshFindsRecordToArticle = ( + record: FreshFindsRecord, +): ArticleAdditionalData => { + const doiRaw = record.DOI != null ? String(record.DOI).trim() : ''; + const authorKey = formatFreshFindsAuthors(record.affiliation_info).slice(0, 120); + const idBase = + doiRaw !== '' ? doiRaw : authorKey !== '' ? authorKey : 'fresh-finds-row'; + return { + id: `fresh-finds-${idBase}`, + title: doiRaw !== '' ? `Fresh find · ${doiRaw}` : 'Fresh find', + doi: doiRaw !== '' ? doiRaw : undefined, + authors: Array.isArray(record.affiliation_info) + ? record.affiliation_info.map((a) => ({ + name: String(a.author_name ?? '').trim() || '—', + })) + : undefined, + }; +}; diff --git a/src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts b/src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts new file mode 100644 index 00000000..b9ead8da --- /dev/null +++ b/src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts @@ -0,0 +1,43 @@ +import type { FreshFindsRecord } from '../types/data.types'; + +function isFreshFindsDataRow(item: unknown): item is FreshFindsRecord { + if (item == null || typeof item !== 'object' || Array.isArray(item)) { + return false; + } + const row = item as FreshFindsRecord; + const hasDoi = row.DOI != null && String(row.DOI).trim() !== ''; + const hasAuthors = + Array.isArray(row.affiliation_info) && row.affiliation_info.length > 0; + return hasDoi || hasAuthors; +} + +export const normalizeFreshFindsResponse = (raw: unknown): FreshFindsRecord[] => { + const rows: FreshFindsRecord[] = []; + + const visit = (node: unknown) => { + if (node == null) { + return; + } + if (Array.isArray(node)) { + for (const el of node) { + visit(el); + } + return; + } + if (typeof node !== 'object') { + return; + } + + if (isFreshFindsDataRow(node)) { + rows.push(node); + return; + } + + for (const value of Object.values(node as Record)) { + visit(value); + } + }; + + visit(raw); + return rows; +}; diff --git a/src/pages/FreshFinds.tsx b/src/pages/FreshFinds.tsx new file mode 100644 index 00000000..f1a57b23 --- /dev/null +++ b/src/pages/FreshFinds.tsx @@ -0,0 +1,7 @@ +import {useDocumentTitle} from '@hooks/useDocumentTitle.ts'; +import {FreshFindsFeature} from '@features/FreshFinds/FreshFindsFeature.tsx'; + +export function FreshFindsPage() { + useDocumentTitle('Fresh finds'); + return ; +} diff --git a/src/pages/index.ts b/src/pages/index.ts index 5f743f1c..992893f5 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -9,6 +9,7 @@ export { DasPage } from './Das'; export { RightsRetentionStrategyPage } from './RightsRetentionStrategy'; export { ResearchSoftwarePage } from './ResearchSoftware'; export { DoiPage } from './Doi'; +export { FreshFindsPage } from './FreshFinds.tsx'; export { OrcidPage } from './Orcid'; export { UsrnPage } from './Usrn'; export { FAIRCertificationPage } from './FAIRCertification'; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 0ede5eed..3c1b5a33 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -23,6 +23,7 @@ import { RightsRetentionStrategyPage, ResearchSoftwarePage, DoiPage, + FreshFindsPage, UsrnPage, PluginsPage, PluginsDiscoveryPage, @@ -92,6 +93,10 @@ export const router = createAppRouter([ path: 'deduplication', element: , }, + { + path: 'fresh-finds', + element: , + }, { path: 'content', element: ,