From c9c0481d6c4142362d4efdc73f9e9277faa265ee Mon Sep 17 00:00:00 2001 From: ekachxaidze98 Date: Wed, 13 May 2026 15:51:20 +0400 Subject: [PATCH 1/3] CORE: fresh finds --- src/components/Layout/menuItems.ts | 6 + src/components/common/CrDrawer/CrDrawer.tsx | 19 ++- src/features/FreshFinds/FreshFindsFeature.css | 62 ++++++++ src/features/FreshFinds/FreshFindsFeature.tsx | 45 ++++++ .../components/FreshFindsColumns.tsx | 92 ++++++++++++ .../components/FreshFindsDrawerContent.tsx | 60 ++++++++ .../FreshFinds/components/FreshFindsTable.tsx | 138 ++++++++++++++++++ .../FreshFinds/constants/freshFindsProdApi.ts | 5 + .../hooks/useDownloadFreshFindsCsv.ts | 14 ++ .../FreshFinds/hooks/useFreshFindsData.ts | 71 +++++++++ .../FreshFinds/store/freshFindsStore.ts | 29 ++++ src/features/FreshFinds/texts/fresh.json | 21 +++ src/features/FreshFinds/texts/index.ts | 5 + src/features/FreshFinds/types/data.types.ts | 19 +++ .../utils/filterFreshFindsRecords.ts | 19 +++ .../utils/freshFindsAffiliations.ts | 65 +++++++++ .../FreshFinds/utils/freshFindsAuthors.ts | 13 ++ .../utils/mapFreshFindsRecordToArticle.ts | 23 +++ .../utils/normalizeFreshFindsResponse.ts | 47 ++++++ src/pages/FreshFinds.tsx | 9 ++ src/pages/index.ts | 1 + src/routes/index.tsx | 5 + 22 files changed, 761 insertions(+), 7 deletions(-) create mode 100644 src/features/FreshFinds/FreshFindsFeature.css create mode 100644 src/features/FreshFinds/FreshFindsFeature.tsx create mode 100644 src/features/FreshFinds/components/FreshFindsColumns.tsx create mode 100644 src/features/FreshFinds/components/FreshFindsDrawerContent.tsx create mode 100644 src/features/FreshFinds/components/FreshFindsTable.tsx create mode 100644 src/features/FreshFinds/constants/freshFindsProdApi.ts create mode 100644 src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts create mode 100644 src/features/FreshFinds/hooks/useFreshFindsData.ts create mode 100644 src/features/FreshFinds/store/freshFindsStore.ts create mode 100644 src/features/FreshFinds/texts/fresh.json create mode 100644 src/features/FreshFinds/texts/index.ts create mode 100644 src/features/FreshFinds/types/data.types.ts create mode 100644 src/features/FreshFinds/utils/filterFreshFindsRecords.ts create mode 100644 src/features/FreshFinds/utils/freshFindsAffiliations.ts create mode 100644 src/features/FreshFinds/utils/freshFindsAuthors.ts create mode 100644 src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts create mode 100644 src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts create mode 100644 src/pages/FreshFinds.tsx 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..0e794cee --- /dev/null +++ b/src/features/FreshFinds/FreshFindsFeature.css @@ -0,0 +1,62 @@ +.fresh-finds-page { + margin-block-start: 1rem; +} + +.fresh-finds-table-header__title { + margin: 0; + font-size: 1.25rem; +} + +.fresh-finds-table-header__subtitle { + margin-block: 0.75rem 1rem; + margin-inline: 0; + color: var(--cr-color-text-secondary, rgba(0, 0, 0, 0.65)); +} + +.fresh-finds__doi-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.fresh-finds__doi-icon { + flex-shrink: 0; +} + +.fresh-finds-drawer-wrapper .fresh-finds-drawer__institution { + margin: 0 0 0.75rem; + font-weight: 600; +} + +.fresh-finds-drawer-affiliations { + margin-block-start: 1.5rem; + padding-block-start: 1rem; + border-block-start: 1px solid rgba(0, 0, 0, 0.06); +} + +.fresh-finds-drawer-affiliations__title { + margin: 0 0 0.75rem; + font-size: 1rem; + font-weight: 600; +} + +.fresh-finds-drawer__author-list { + margin: 0; + padding-inline-start: 1.25rem; +} + +.fresh-finds-drawer__author-item { + margin-block-end: 0.5rem; +} + +.fresh-finds-drawer__author-name { + display: block; + font-weight: 600; +} + +.fresh-finds-drawer__author-aff { + display: block; + margin-block-start: 0.15rem; + font-size: 0.9em; + color: var(--cr-color-text-secondary, rgba(0, 0, 0, 0.65)); +} diff --git a/src/features/FreshFinds/FreshFindsFeature.tsx b/src/features/FreshFinds/FreshFindsFeature.tsx new file mode 100644 index 00000000..7ebad59f --- /dev/null +++ b/src/features/FreshFinds/FreshFindsFeature.tsx @@ -0,0 +1,45 @@ +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 { useFreshFindsStore } from './store/freshFindsStore'; +import { articleTemplateData } from './texts'; + +import './FreshFindsFeature.css'; + +export const FreshFindsFeature = () => { + const { selectedDataProvider } = useDataProviderStore(); + const { error, isLoading } = useFreshFindsData(selectedDataProvider?.id ?? null); + const { freshFindsData } = useFreshFindsStore(); + + console.log(freshFindsData, "freshFindsData"); + + return ( + + + } + /> +
+ +
+
+ ); +}; diff --git a/src/features/FreshFinds/components/FreshFindsColumns.tsx b/src/features/FreshFinds/components/FreshFindsColumns.tsx new file mode 100644 index 00000000..cda456e3 --- /dev/null +++ b/src/features/FreshFinds/components/FreshFindsColumns.tsx @@ -0,0 +1,92 @@ +import { LinkOutlined } from '@ant-design/icons'; + +import type { ReusableTableColumn } from '@components/common/CrTable/types.ts'; + +import type { FreshFindsRecord } from '../types/data.types'; +import { formatFreshFindsAuthors } from '../utils/freshFindsAuthors'; + +import '../FreshFindsFeature.css'; + +const buildDoiHref = (doi: string): string => { + const trimmed = doi.trim(); + if (!trimmed) { + return ''; + } + return `https://doi.org/${encodeURIComponent(trimmed.replace(/^\s*https?:\/\/doi\.org\//i, ''))}`; +}; + +export const createColumns = (): ReusableTableColumn[] => [ + { + key: 'authors', + title: 'Authors', + dataIndex: 'affiliation_info', + align: 'left', + className: 'fresh-finds-column fresh-finds-column--authors', + sortable: true, + showSortIcon: true, + sorter: (a: FreshFindsRecord, b: FreshFindsRecord) => { + const sa = formatFreshFindsAuthors(a.affiliation_info).toLowerCase(); + const sb = formatFreshFindsAuthors(b.affiliation_info).toLowerCase(); + return sa.localeCompare(sb); + }, + 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: (a: FreshFindsRecord, b: FreshFindsRecord) => { + const sa = a.DOI != null ? String(a.DOI).toLowerCase() : ''; + const sb = b.DOI != null ? String(b.DOI).toLowerCase() : ''; + return sa.localeCompare(sb); + }, + render: (value: unknown, record: FreshFindsRecord) => { + const doi = value != null ? String(value) : record.DOI != null ? String(record.DOI) : ''; + if (!doi) { + return '-'; + } + const href = buildDoiHref(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} + + ); + }, + }, +]; + +export const getCustomSorters = (): Record number> => { + const columns = createColumns(); + const sorters: Record number> = {}; + + for (const column of columns) { + if (column.sorter && typeof column.sorter === 'function') { + sorters[column.key] = column.sorter as (a: FreshFindsRecord, b: FreshFindsRecord) => number; + } + } + + return sorters; +}; \ No newline at end of file diff --git a/src/features/FreshFinds/components/FreshFindsDrawerContent.tsx b/src/features/FreshFinds/components/FreshFindsDrawerContent.tsx new file mode 100644 index 00000000..d243a01c --- /dev/null +++ b/src/features/FreshFinds/components/FreshFindsDrawerContent.tsx @@ -0,0 +1,60 @@ +import { CrDrawer } from '@components/common/CrDrawer/CrDrawer.tsx'; + +import type { FreshFindsRecord } from '../types/data.types'; +import { formatAuthorAffiliationSummary } from '../utils/freshFindsAffiliations'; +import { mapFreshFindsRecordToArticle } from '../utils/mapFreshFindsRecordToArticle'; + +import '../FreshFindsFeature.css'; + +const buildOutputsUrl = (doi: string | undefined): string => { + const trimmed = doi?.trim() ?? ''; + if (trimmed === '') { + return 'https://core.ac.uk/'; + } + const slug = trimmed.replace(/^\s*https?:\/\/doi\.org\//i, ''); + return `https://doi.org/${encodeURIComponent(slug)}`; +}; + +type FreshFindsDrawerContentProps = { + record: FreshFindsRecord; + institutionLabel: string; +}; + +export const FreshFindsDrawerContent = ({ record, institutionLabel }: FreshFindsDrawerContentProps) => { + const article = mapFreshFindsRecordToArticle(record); + const outputsUrl = buildOutputsUrl(record.DOI != null ? String(record.DOI) : undefined); + + return ( +
+ {institutionLabel !== '' && ( +

{institutionLabel}

+ )} + + {Array.isArray(record.affiliation_info) && record.affiliation_info.length > 0 && ( +
+

Affiliations by author

+
    + {record.affiliation_info.map((info, index) => { + const name = info?.author_name != null ? String(info.author_name) : '—'; + const affSummary = formatAuthorAffiliationSummary(info); + return ( +
  • + {name} + {affSummary !== '' ? ( + {affSummary} + ) : null} +
  • + ); + })} +
+
+ )} +
+ ); +}; diff --git a/src/features/FreshFinds/components/FreshFindsTable.tsx b/src/features/FreshFinds/components/FreshFindsTable.tsx new file mode 100644 index 00000000..4cbd3c9a --- /dev/null +++ b/src/features/FreshFinds/components/FreshFindsTable.tsx @@ -0,0 +1,138 @@ +import { useMemo, useState } from 'react'; +import { AccessPlaceholder, CrPaper } from '@oacore/core-ui'; + +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, getCustomSorters } from './FreshFindsColumns.tsx'; +import { FreshFindsDrawerContent } from './FreshFindsDrawerContent.tsx'; +import { useDownloadFreshFindsCsv } from '../hooks/useDownloadFreshFindsCsv'; +import type { FreshFindsRecord } from '../types/data.types'; +import { filterFreshFindsRecords } from '../utils/filterFreshFindsRecords'; +import { articleTemplateData } from '../texts'; + +import '../FreshFindsFeature.css'; + +type FreshFindsTableRow = FreshFindsRecord & { __rowKey: string }; + +type FreshFindsTableProps = { + records: FreshFindsRecord[]; + isLoading: boolean; + error: unknown; + dataProviderName: string; + institutionLabel: string; +}; + +export const FreshFindsTable = ({ + records, + isLoading, + error, + dataProviderName, + institutionLabel, +}: 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 customSorters = useMemo(() => getCustomSorters(), []); + + const { + visibleData, + hasMore, + handleSort, + handleLoadMore, + totalLength, + } = useTablePaginationAndSort({ + data: billingPlanData, + itemsPerPage: 10, + customSorters, + }); + + const columns = useMemo(() => createColumns(), []); + + const dataWithUniqueKeys = useMemo( + () => + visibleData.map((record, index) => ({ + ...record, + __rowKey: `${String(record.DOI ?? '')}-${String(record.title ?? '')}-${index}`, + })), + [visibleData], + ); + + const drawerConfig: DrawerConfig = { + enabled: true, + content: (record) => ( + + ), + onRowClick: () => {}, + }; + + const handleSearch = (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={true} + 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..6324f201 --- /dev/null +++ b/src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts @@ -0,0 +1,14 @@ +import { useDownloadDataProviderCsv } from '@/hooks/useDownloadDataProviderCsv'; + +import { buildFreshFindsInternalUrl } from '../constants/freshFindsProdApi'; +// TODO REMOVE +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..ee3acc2b --- /dev/null +++ b/src/features/FreshFinds/hooks/useFreshFindsData.ts @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import useSWR from 'swr'; + +import { captureHandledError } from '@/utils/captureHandledError'; +import { createSWRKey, fetcher, swrDefaultConfig } from '@/config/swr'; +import { useDataProviderStore } from '@/store/dataProviderStore'; +// TODO REMOVE +import { buildFreshFindsInternalUrl } from '../constants/freshFindsProdApi'; +import { useFreshFindsStore } from '../store/freshFindsStore'; +import { normalizeFreshFindsResponse } from '../utils/normalizeFreshFindsResponse'; + +/** + * Fetches fresh-finds for the current data provider and syncs into {@link useFreshFindsStore} + * (same idea as MobX observable + action, using Zustand + SWR like USRN). + */ +export const useFreshFindsData = (dataProviderId?: number | null) => { + const { isLoaded, selectedSetSpec } = useDataProviderStore(); + const { setFreshFindsData, setFreshFindsDataLoading } = useFreshFindsStore(); + + 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 }, + }); + }, + }); + + useEffect(() => { + if (dataProviderId == null) { + setFreshFindsData([]); + } + }, [dataProviderId, setFreshFindsData]); + + useEffect(() => { + setFreshFindsDataLoading(isLoading || !isLoaded); + }, [isLoading, isLoaded, setFreshFindsDataLoading]); + + useEffect(() => { + if (error != null) { + setFreshFindsData([]); + return; + } + if (data == null) { + return; + } + setFreshFindsData(normalizeFreshFindsResponse(data)); + }, [data, error, setFreshFindsData]); + + return { + data: data ?? null, + freshFindsData: normalizeFreshFindsResponse(data ?? null), + error, + isLoading: isLoading || !isLoaded, + mutate, + }; +}; diff --git a/src/features/FreshFinds/store/freshFindsStore.ts b/src/features/FreshFinds/store/freshFindsStore.ts new file mode 100644 index 00000000..67997328 --- /dev/null +++ b/src/features/FreshFinds/store/freshFindsStore.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +import type { FreshFindsRecord } from '../types/data.types'; + +interface FreshFindsStoreState { + freshFindsData: FreshFindsRecord[]; + freshFindsDataLoading: boolean; +} + +interface FreshFindsStoreActions { + setFreshFindsData: (data: FreshFindsRecord[]) => void; + setFreshFindsDataLoading: (loading: boolean) => void; +} + +type FreshFindsStore = FreshFindsStoreState & FreshFindsStoreActions; + +export const useFreshFindsStore = create()( + devtools( + (set) => ({ + freshFindsData: [], + freshFindsDataLoading: false, + + setFreshFindsData: (freshFindsData) => set({ freshFindsData }), + setFreshFindsDataLoading: (freshFindsDataLoading) => set({ freshFindsDataLoading }), + }), + { name: 'fresh-finds-store' }, + ), +); diff --git a/src/features/FreshFinds/texts/fresh.json b/src/features/FreshFinds/texts/fresh.json new file mode 100644 index 00000000..cc101d7c --- /dev/null +++ b/src/features/FreshFinds/texts/fresh.json @@ -0,0 +1,21 @@ +{ + "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" + }, + "actions": [ + { + "title": "Not relevant", + "key": "coreUrl" + }, + { + "title": "Not from my institution", + "key": "outputUrl" + }, + { + "title": "Remove from suggestions", + "key": "outputUrl" + } + ] +} 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/filterFreshFindsRecords.ts b/src/features/FreshFinds/utils/filterFreshFindsRecords.ts new file mode 100644 index 00000000..8bd07561 --- /dev/null +++ b/src/features/FreshFinds/utils/filterFreshFindsRecords.ts @@ -0,0 +1,19 @@ +import type { FreshFindsRecord } from '../types/data.types'; + +import { formatFreshFindsAuthors } from './freshFindsAuthors'; + +export const filterFreshFindsRecords = ( + records: FreshFindsRecord[], + searchTerm: string, +): FreshFindsRecord[] => { + const lowerSearchTerm = searchTerm.trim().toLowerCase(); + if (lowerSearchTerm === '') { + 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(lowerSearchTerm) || doi.includes(lowerSearchTerm); + }); +}; diff --git a/src/features/FreshFinds/utils/freshFindsAffiliations.ts b/src/features/FreshFinds/utils/freshFindsAffiliations.ts new file mode 100644 index 00000000..cf43780c --- /dev/null +++ b/src/features/FreshFinds/utils/freshFindsAffiliations.ts @@ -0,0 +1,65 @@ +import type { + FreshFindsAffiliationBlock, + FreshFindsAffiliationInfo, + FreshFindsRecord, +} from '../types/data.types'; + +const blocksFromAffiliation = ( + affiliation: FreshFindsAffiliationBlock | FreshFindsAffiliationBlock[] | undefined, +): FreshFindsAffiliationBlock[] => { + if (affiliation == null) { + return []; + } + return Array.isArray(affiliation) ? affiliation : [affiliation]; +}; + +const collectNamesFromBlock = (block: FreshFindsAffiliationBlock, into: Set) => { + const nameList = block.name; + if (!Array.isArray(nameList)) { + return; + } + for (const n of nameList) { + const trimmed = String(n).trim(); + if (trimmed !== '') { + into.add(trimmed); + } + } +}; + +/** Unique institution names across all authors on the record (for table + search). */ +export const formatFreshFindsAffiliationNames = (record: FreshFindsRecord): string => { + const infos = record.affiliation_info; + if (!Array.isArray(infos) || infos.length === 0) { + return ''; + } + const names = new Set(); + for (const info of infos) { + for (const block of blocksFromAffiliation(info?.affiliation)) { + collectNamesFromBlock(block, names); + } + } + return [...names].join('; '); +}; + +/** Readable affiliation line for one author (names + ROR ids when present). */ +export const formatAuthorAffiliationSummary = (info: FreshFindsAffiliationInfo): string => { + const parts: string[] = []; + for (const block of blocksFromAffiliation(info.affiliation)) { + const nameStr = + Array.isArray(block.name) && block.name.length > 0 + ? block.name.map((n) => String(n).trim()).filter(Boolean).join(', ') + : ''; + const rorStr = + Array.isArray(block.ror_id) && block.ror_id.length > 0 + ? block.ror_id.map((r) => String(r).trim()).filter(Boolean).join(', ') + : ''; + if (nameStr !== '' && rorStr !== '') { + parts.push(`${nameStr} (${rorStr})`); + } else if (nameStr !== '') { + parts.push(nameStr); + } else if (rorStr !== '') { + parts.push(rorStr); + } + } + return parts.join(' · '); +}; diff --git a/src/features/FreshFinds/utils/freshFindsAuthors.ts b/src/features/FreshFinds/utils/freshFindsAuthors.ts new file mode 100644 index 00000000..bf950c1c --- /dev/null +++ b/src/features/FreshFinds/utils/freshFindsAuthors.ts @@ -0,0 +1,13 @@ +import type { FreshFindsAffiliationInfo } 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(', '); +}; diff --git a/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts b/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts new file mode 100644 index 00000000..41731b2c --- /dev/null +++ b/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts @@ -0,0 +1,23 @@ +import type { ArticleAdditionalData } from '@/hooks/useArticleData'; + +import type { FreshFindsRecord } from '../types/data.types'; +import { formatFreshFindsAuthors } from './freshFindsAuthors'; + +/** + * Maps API row shape into {@link ArticleAdditionalData} so {@link CrDrawer} can render known fields (title, DOI, authors). + */ +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..24b60af8 --- /dev/null +++ b/src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts @@ -0,0 +1,47 @@ +import type { FreshFindsRecord } from '../types/data.types'; + +export const 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; +}; + +/** + * API may nest works under arbitrary keys (e.g. `{ "0": { "0": { DOI, affiliation_info }, "1": { ... }, "status": "..." }, "since": "..." }`). + * Recursively collect leaf objects that look like a fresh-finds row. + */ +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..07d235ed --- /dev/null +++ b/src/pages/FreshFinds.tsx @@ -0,0 +1,9 @@ +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: , From d07fa01af7e442f8d86f9ffb658ebd566158e61d Mon Sep 17 00:00:00 2001 From: ekachxaidze98 Date: Wed, 13 May 2026 16:09:45 +0400 Subject: [PATCH 2/3] CORE: fresh finds drawer --- src/features/FreshFinds/FreshFindsFeature.css | 55 ++--- src/features/FreshFinds/FreshFindsFeature.tsx | 55 ++--- .../components/FreshFindsColumns.tsx | 147 ++++++------ .../components/FreshFindsDrawerContent.tsx | 60 ----- .../FreshFinds/components/FreshFindsTable.tsx | 214 +++++++++--------- .../hooks/useDownloadFreshFindsCsv.ts | 2 +- .../FreshFinds/hooks/useFreshFindsData.ts | 104 ++++----- .../FreshFinds/store/freshFindsStore.ts | 29 --- src/features/FreshFinds/texts/fresh.json | 24 +- .../utils/filterFreshFindsRecords.ts | 22 +- .../utils/freshFindsAffiliations.ts | 65 ------ .../FreshFinds/utils/freshFindsAuthors.ts | 16 +- .../FreshFinds/utils/freshFindsDoi.ts | 21 ++ .../utils/mapFreshFindsRecordToArticle.ts | 36 +-- .../utils/normalizeFreshFindsResponse.ts | 69 +++--- src/pages/FreshFinds.tsx | 4 +- 16 files changed, 358 insertions(+), 565 deletions(-) delete mode 100644 src/features/FreshFinds/components/FreshFindsDrawerContent.tsx delete mode 100644 src/features/FreshFinds/store/freshFindsStore.ts delete mode 100644 src/features/FreshFinds/utils/freshFindsAffiliations.ts create mode 100644 src/features/FreshFinds/utils/freshFindsDoi.ts diff --git a/src/features/FreshFinds/FreshFindsFeature.css b/src/features/FreshFinds/FreshFindsFeature.css index 0e794cee..1b258c18 100644 --- a/src/features/FreshFinds/FreshFindsFeature.css +++ b/src/features/FreshFinds/FreshFindsFeature.css @@ -5,58 +5,35 @@ .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: var(--cr-color-text-secondary, rgba(0, 0, 0, 0.65)); + 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-icon { - flex-shrink: 0; -} - -.fresh-finds-drawer-wrapper .fresh-finds-drawer__institution { - margin: 0 0 0.75rem; - font-weight: 600; -} - -.fresh-finds-drawer-affiliations { - margin-block-start: 1.5rem; - padding-block-start: 1rem; - border-block-start: 1px solid rgba(0, 0, 0, 0.06); -} - -.fresh-finds-drawer-affiliations__title { - margin: 0 0 0.75rem; - font-size: 1rem; - font-weight: 600; +.fresh-finds__doi-link:hover, +.fresh-finds__doi-link:focus-visible { + color: #9a4600; + text-decoration: underline; } -.fresh-finds-drawer__author-list { - margin: 0; - padding-inline-start: 1.25rem; -} - -.fresh-finds-drawer__author-item { - margin-block-end: 0.5rem; -} - -.fresh-finds-drawer__author-name { - display: block; - font-weight: 600; -} - -.fresh-finds-drawer__author-aff { - display: block; - margin-block-start: 0.15rem; - font-size: 0.9em; - color: var(--cr-color-text-secondary, rgba(0, 0, 0, 0.65)); +.fresh-finds__doi-icon { + flex-shrink: 0; + color: inherit; } diff --git a/src/features/FreshFinds/FreshFindsFeature.tsx b/src/features/FreshFinds/FreshFindsFeature.tsx index 7ebad59f..d8d3eaaa 100644 --- a/src/features/FreshFinds/FreshFindsFeature.tsx +++ b/src/features/FreshFinds/FreshFindsFeature.tsx @@ -4,42 +4,33 @@ import { useDataProviderStore } from '@/store/dataProviderStore'; import { FreshFindsTable } from './components/FreshFindsTable.tsx'; import { useFreshFindsData } from './hooks/useFreshFindsData'; -import { useFreshFindsStore } from './store/freshFindsStore'; import { articleTemplateData } from './texts'; import './FreshFindsFeature.css'; export const FreshFindsFeature = () => { - const { selectedDataProvider } = useDataProviderStore(); - const { error, isLoading } = useFreshFindsData(selectedDataProvider?.id ?? null); - const { freshFindsData } = useFreshFindsStore(); + const { selectedDataProvider } = useDataProviderStore(); + const { data: records, error, isLoading } = useFreshFindsData( + selectedDataProvider?.id ?? null, + ); - console.log(freshFindsData, "freshFindsData"); - - return ( - - - } - /> -
- -
-
- ); + return ( + + + } + /> +
+ +
+
+ ); }; diff --git a/src/features/FreshFinds/components/FreshFindsColumns.tsx b/src/features/FreshFinds/components/FreshFindsColumns.tsx index cda456e3..0ec9d1a2 100644 --- a/src/features/FreshFinds/components/FreshFindsColumns.tsx +++ b/src/features/FreshFinds/components/FreshFindsColumns.tsx @@ -3,90 +3,83 @@ import { LinkOutlined } from '@ant-design/icons'; import type { ReusableTableColumn } from '@components/common/CrTable/types.ts'; import type { FreshFindsRecord } from '../types/data.types'; +import { buildFreshFindsDoiHref } from '../utils/freshFindsDoi'; import { formatFreshFindsAuthors } from '../utils/freshFindsAuthors'; -import '../FreshFindsFeature.css'; +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); +}; -const buildDoiHref = (doi: string): string => { - const trimmed = doi.trim(); - if (!trimmed) { - return ''; - } - return `https://doi.org/${encodeURIComponent(trimmed.replace(/^\s*https?:\/\/doi\.org\//i, ''))}`; +/** 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', - align: 'left', - className: 'fresh-finds-column fresh-finds-column--authors', - sortable: true, - showSortIcon: true, - sorter: (a: FreshFindsRecord, b: FreshFindsRecord) => { - const sa = formatFreshFindsAuthors(a.affiliation_info).toLowerCase(); - const sb = formatFreshFindsAuthors(b.affiliation_info).toLowerCase(); - return sa.localeCompare(sb); - }, - render: (_value: unknown, record: FreshFindsRecord) => { - const text = formatFreshFindsAuthors(record.affiliation_info); - return text !== '' ? text : '-'; - }, + { + 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: (a: FreshFindsRecord, b: FreshFindsRecord) => { - const sa = a.DOI != null ? String(a.DOI).toLowerCase() : ''; - const sb = b.DOI != null ? String(b.DOI).toLowerCase() : ''; - return sa.localeCompare(sb); - }, - render: (value: unknown, record: FreshFindsRecord) => { - const doi = value != null ? String(value) : record.DOI != null ? String(record.DOI) : ''; - if (!doi) { - return '-'; - } - const href = buildDoiHref(doi); - if (!href) { - return '-'; + }, + { + 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(); } - return ( - e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - } - }} - aria-label={`Open DOI ${doi} in a new tab`} - > - - {doi} - - ); - }, + }} + aria-label={`Open DOI ${doi} in a new tab`} + > + + {doi} + + ); }, + }, ]; - -export const getCustomSorters = (): Record number> => { - const columns = createColumns(); - const sorters: Record number> = {}; - - for (const column of columns) { - if (column.sorter && typeof column.sorter === 'function') { - sorters[column.key] = column.sorter as (a: FreshFindsRecord, b: FreshFindsRecord) => number; - } - } - - return sorters; -}; \ No newline at end of file diff --git a/src/features/FreshFinds/components/FreshFindsDrawerContent.tsx b/src/features/FreshFinds/components/FreshFindsDrawerContent.tsx deleted file mode 100644 index d243a01c..00000000 --- a/src/features/FreshFinds/components/FreshFindsDrawerContent.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { CrDrawer } from '@components/common/CrDrawer/CrDrawer.tsx'; - -import type { FreshFindsRecord } from '../types/data.types'; -import { formatAuthorAffiliationSummary } from '../utils/freshFindsAffiliations'; -import { mapFreshFindsRecordToArticle } from '../utils/mapFreshFindsRecordToArticle'; - -import '../FreshFindsFeature.css'; - -const buildOutputsUrl = (doi: string | undefined): string => { - const trimmed = doi?.trim() ?? ''; - if (trimmed === '') { - return 'https://core.ac.uk/'; - } - const slug = trimmed.replace(/^\s*https?:\/\/doi\.org\//i, ''); - return `https://doi.org/${encodeURIComponent(slug)}`; -}; - -type FreshFindsDrawerContentProps = { - record: FreshFindsRecord; - institutionLabel: string; -}; - -export const FreshFindsDrawerContent = ({ record, institutionLabel }: FreshFindsDrawerContentProps) => { - const article = mapFreshFindsRecordToArticle(record); - const outputsUrl = buildOutputsUrl(record.DOI != null ? String(record.DOI) : undefined); - - return ( -
- {institutionLabel !== '' && ( -

{institutionLabel}

- )} - - {Array.isArray(record.affiliation_info) && record.affiliation_info.length > 0 && ( -
-

Affiliations by author

-
    - {record.affiliation_info.map((info, index) => { - const name = info?.author_name != null ? String(info.author_name) : '—'; - const affSummary = formatAuthorAffiliationSummary(info); - return ( -
  • - {name} - {affSummary !== '' ? ( - {affSummary} - ) : null} -
  • - ); - })} -
-
- )} -
- ); -}; diff --git a/src/features/FreshFinds/components/FreshFindsTable.tsx b/src/features/FreshFinds/components/FreshFindsTable.tsx index 4cbd3c9a..18f66441 100644 --- a/src/features/FreshFinds/components/FreshFindsTable.tsx +++ b/src/features/FreshFinds/components/FreshFindsTable.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react'; +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'; @@ -8,131 +9,130 @@ 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, getCustomSorters } from './FreshFindsColumns.tsx'; -import { FreshFindsDrawerContent } from './FreshFindsDrawerContent.tsx'; +import { createColumns, freshFindsCustomSorters } from './FreshFindsColumns.tsx'; import { useDownloadFreshFindsCsv } from '../hooks/useDownloadFreshFindsCsv'; import type { FreshFindsRecord } from '../types/data.types'; import { filterFreshFindsRecords } from '../utils/filterFreshFindsRecords'; +import { buildFreshFindsOutputsUrl } from '../utils/freshFindsDoi'; +import { mapFreshFindsRecordToArticle } from '../utils/mapFreshFindsRecordToArticle'; import { articleTemplateData } from '../texts'; - -import '../FreshFindsFeature.css'; +import { formatFreshFindsAuthors } from '../utils/freshFindsAuthors'; type FreshFindsTableRow = FreshFindsRecord & { __rowKey: string }; type FreshFindsTableProps = { - records: FreshFindsRecord[]; - isLoading: boolean; - error: unknown; - dataProviderName: string; - institutionLabel: string; + records: FreshFindsRecord[]; + isLoading: boolean; + error: unknown; + dataProviderName: string; }; export const FreshFindsTable = ({ - records, - isLoading, - error, - dataProviderName, - institutionLabel, + 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 [searchTerm, setSearchTerm] = useState(''); + const { organisation } = useOrganisation(); + const { downloadCsv, isLoading: downloadCsvLoading } = useDownloadFreshFindsCsv(); - const { isStartingPlan, displayData: billingPlanData } = useBillingPlanData( - filteredRecords, - organisation, - ); + const filteredRecords = useMemo( + () => filterFreshFindsRecords(records, searchTerm), + [records, searchTerm], + ); - const customSorters = useMemo(() => getCustomSorters(), []); + const { isStartingPlan, displayData: billingPlanData } = useBillingPlanData( + filteredRecords, + organisation, + ); - const { - visibleData, - hasMore, - handleSort, - handleLoadMore, - totalLength, - } = useTablePaginationAndSort({ - data: billingPlanData, - itemsPerPage: 10, - customSorters, + const { visibleData, hasMore, handleSort, handleLoadMore, totalLength } = + useTablePaginationAndSort({ + data: billingPlanData, + itemsPerPage: 10, + customSorters: freshFindsCustomSorters, }); - const columns = useMemo(() => createColumns(), []); + const columns = useMemo(() => createColumns(), []); - const dataWithUniqueKeys = useMemo( - () => - visibleData.map((record, index) => ({ - ...record, - __rowKey: `${String(record.DOI ?? '')}-${String(record.title ?? '')}-${index}`, - })), - [visibleData], - ); + const dataWithUniqueKeys = useMemo( + () => + visibleData.map((record, index) => ({ + ...record, + __rowKey: `${String(record.DOI ?? '')}-${formatFreshFindsAuthors(record.affiliation_info)}-${index}`, + })), + [visibleData], + ); - const drawerConfig: DrawerConfig = { - enabled: true, - content: (record) => ( - - ), - onRowClick: () => {}, - }; + const drawerConfig: DrawerConfig = useMemo( + () => ({ + enabled: true, + content: (record: FreshFindsTableRow) => ( +
+ +
+ ), + }), + [], + ); - const handleSearch = (term: string) => { - setSearchTerm(term); - }; + 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={true} - searchPlaceholder="Authors or DOI…" - onSearch={handleSearch} - searchValue={searchTerm} - scroll={getScrollConfig()} - drawer={drawerConfig} - /> - {isStartingPlan && ( - - )} -
-
- ); + 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/hooks/useDownloadFreshFindsCsv.ts b/src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts index 6324f201..bed58943 100644 --- a/src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts +++ b/src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts @@ -1,7 +1,7 @@ import { useDownloadDataProviderCsv } from '@/hooks/useDownloadDataProviderCsv'; import { buildFreshFindsInternalUrl } from '../constants/freshFindsProdApi'; -// TODO REMOVE + const FRESH_FINDS_CSV_CONFIG = { endpoint: 'fresh-finds', filenamePrefix: 'fresh-finds-csv', diff --git a/src/features/FreshFinds/hooks/useFreshFindsData.ts b/src/features/FreshFinds/hooks/useFreshFindsData.ts index ee3acc2b..73716eab 100644 --- a/src/features/FreshFinds/hooks/useFreshFindsData.ts +++ b/src/features/FreshFinds/hooks/useFreshFindsData.ts @@ -1,71 +1,53 @@ -import { useEffect } from 'react'; +import { useMemo } from 'react'; import useSWR from 'swr'; -import { captureHandledError } from '@/utils/captureHandledError'; import { createSWRKey, fetcher, swrDefaultConfig } from '@/config/swr'; +import { captureHandledError } from '@/utils/captureHandledError'; import { useDataProviderStore } from '@/store/dataProviderStore'; -// TODO REMOVE import { buildFreshFindsInternalUrl } from '../constants/freshFindsProdApi'; -import { useFreshFindsStore } from '../store/freshFindsStore'; +import type { FreshFindsRecord } from '../types/data.types'; import { normalizeFreshFindsResponse } from '../utils/normalizeFreshFindsResponse'; -/** - * Fetches fresh-finds for the current data provider and syncs into {@link useFreshFindsStore} - * (same idea as MobX observable + action, using Zustand + SWR like USRN). - */ export const useFreshFindsData = (dataProviderId?: number | null) => { - const { isLoaded, selectedSetSpec } = useDataProviderStore(); - const { setFreshFindsData, setFreshFindsDataLoading } = useFreshFindsStore(); - - const params: Record = {}; - if (selectedSetSpec) { - params.set = selectedSetSpec; + 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 []; } - - 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 }, - }); - }, - }); - - useEffect(() => { - if (dataProviderId == null) { - setFreshFindsData([]); - } - }, [dataProviderId, setFreshFindsData]); - - useEffect(() => { - setFreshFindsDataLoading(isLoading || !isLoaded); - }, [isLoading, isLoaded, setFreshFindsDataLoading]); - - useEffect(() => { - if (error != null) { - setFreshFindsData([]); - return; - } - if (data == null) { - return; - } - setFreshFindsData(normalizeFreshFindsResponse(data)); - }, [data, error, setFreshFindsData]); - - return { - data: data ?? null, - freshFindsData: normalizeFreshFindsResponse(data ?? null), - error, - isLoading: isLoading || !isLoaded, - mutate, - }; + if (data == null) { + return []; + } + return normalizeFreshFindsResponse(data); + }, [data, dataProviderId, error]); + + return { + data: records, + error, + isLoading: isLoading || !isLoaded, + mutate, + }; }; diff --git a/src/features/FreshFinds/store/freshFindsStore.ts b/src/features/FreshFinds/store/freshFindsStore.ts deleted file mode 100644 index 67997328..00000000 --- a/src/features/FreshFinds/store/freshFindsStore.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; - -import type { FreshFindsRecord } from '../types/data.types'; - -interface FreshFindsStoreState { - freshFindsData: FreshFindsRecord[]; - freshFindsDataLoading: boolean; -} - -interface FreshFindsStoreActions { - setFreshFindsData: (data: FreshFindsRecord[]) => void; - setFreshFindsDataLoading: (loading: boolean) => void; -} - -type FreshFindsStore = FreshFindsStoreState & FreshFindsStoreActions; - -export const useFreshFindsStore = create()( - devtools( - (set) => ({ - freshFindsData: [], - freshFindsDataLoading: false, - - setFreshFindsData: (freshFindsData) => set({ freshFindsData }), - setFreshFindsDataLoading: (freshFindsDataLoading) => set({ freshFindsDataLoading }), - }), - { name: 'fresh-finds-store' }, - ), -); diff --git a/src/features/FreshFinds/texts/fresh.json b/src/features/FreshFinds/texts/fresh.json index cc101d7c..74c0db56 100644 --- a/src/features/FreshFinds/texts/fresh.json +++ b/src/features/FreshFinds/texts/fresh.json @@ -1,21 +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" - }, - "actions": [ - { - "title": "Not relevant", - "key": "coreUrl" - }, - { - "title": "Not from my institution", - "key": "outputUrl" - }, - { - "title": "Remove from suggestions", - "key": "outputUrl" - } - ] + "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/utils/filterFreshFindsRecords.ts b/src/features/FreshFinds/utils/filterFreshFindsRecords.ts index 8bd07561..5f8931f0 100644 --- a/src/features/FreshFinds/utils/filterFreshFindsRecords.ts +++ b/src/features/FreshFinds/utils/filterFreshFindsRecords.ts @@ -3,17 +3,17 @@ import type { FreshFindsRecord } from '../types/data.types'; import { formatFreshFindsAuthors } from './freshFindsAuthors'; export const filterFreshFindsRecords = ( - records: FreshFindsRecord[], - searchTerm: string, + records: FreshFindsRecord[], + searchTerm: string, ): FreshFindsRecord[] => { - const lowerSearchTerm = searchTerm.trim().toLowerCase(); - if (lowerSearchTerm === '') { - return records; - } + const lowerSearchTerm = searchTerm.trim().toLowerCase(); + if (lowerSearchTerm === '') { + 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(lowerSearchTerm) || doi.includes(lowerSearchTerm); - }); + return records.filter((item) => { + const authors = formatFreshFindsAuthors(item.affiliation_info).toLowerCase(); + const doi = item.DOI != null ? String(item.DOI).toLowerCase() : ''; + return authors.includes(lowerSearchTerm) || doi.includes(lowerSearchTerm); + }); }; diff --git a/src/features/FreshFinds/utils/freshFindsAffiliations.ts b/src/features/FreshFinds/utils/freshFindsAffiliations.ts deleted file mode 100644 index cf43780c..00000000 --- a/src/features/FreshFinds/utils/freshFindsAffiliations.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { - FreshFindsAffiliationBlock, - FreshFindsAffiliationInfo, - FreshFindsRecord, -} from '../types/data.types'; - -const blocksFromAffiliation = ( - affiliation: FreshFindsAffiliationBlock | FreshFindsAffiliationBlock[] | undefined, -): FreshFindsAffiliationBlock[] => { - if (affiliation == null) { - return []; - } - return Array.isArray(affiliation) ? affiliation : [affiliation]; -}; - -const collectNamesFromBlock = (block: FreshFindsAffiliationBlock, into: Set) => { - const nameList = block.name; - if (!Array.isArray(nameList)) { - return; - } - for (const n of nameList) { - const trimmed = String(n).trim(); - if (trimmed !== '') { - into.add(trimmed); - } - } -}; - -/** Unique institution names across all authors on the record (for table + search). */ -export const formatFreshFindsAffiliationNames = (record: FreshFindsRecord): string => { - const infos = record.affiliation_info; - if (!Array.isArray(infos) || infos.length === 0) { - return ''; - } - const names = new Set(); - for (const info of infos) { - for (const block of blocksFromAffiliation(info?.affiliation)) { - collectNamesFromBlock(block, names); - } - } - return [...names].join('; '); -}; - -/** Readable affiliation line for one author (names + ROR ids when present). */ -export const formatAuthorAffiliationSummary = (info: FreshFindsAffiliationInfo): string => { - const parts: string[] = []; - for (const block of blocksFromAffiliation(info.affiliation)) { - const nameStr = - Array.isArray(block.name) && block.name.length > 0 - ? block.name.map((n) => String(n).trim()).filter(Boolean).join(', ') - : ''; - const rorStr = - Array.isArray(block.ror_id) && block.ror_id.length > 0 - ? block.ror_id.map((r) => String(r).trim()).filter(Boolean).join(', ') - : ''; - if (nameStr !== '' && rorStr !== '') { - parts.push(`${nameStr} (${rorStr})`); - } else if (nameStr !== '') { - parts.push(nameStr); - } else if (rorStr !== '') { - parts.push(rorStr); - } - } - return parts.join(' · '); -}; diff --git a/src/features/FreshFinds/utils/freshFindsAuthors.ts b/src/features/FreshFinds/utils/freshFindsAuthors.ts index bf950c1c..306e7d9e 100644 --- a/src/features/FreshFinds/utils/freshFindsAuthors.ts +++ b/src/features/FreshFinds/utils/freshFindsAuthors.ts @@ -1,13 +1,13 @@ import type { FreshFindsAffiliationInfo } from '../types/data.types'; export const formatFreshFindsAuthors = ( - affiliationInfo: FreshFindsAffiliationInfo[] | undefined, + affiliationInfo: FreshFindsAffiliationInfo[] | undefined, ): string => { - if (!Array.isArray(affiliationInfo) || affiliationInfo.length === 0) { - return ''; - } - return affiliationInfo - .map((info) => info?.author_name) - .filter(Boolean) - .join(', '); + if (!Array.isArray(affiliationInfo) || affiliationInfo.length === 0) { + return ''; + } + return affiliationInfo + .map((info) => info?.author_name) + .filter(Boolean) + .join(', '); }; diff --git a/src/features/FreshFinds/utils/freshFindsDoi.ts b/src/features/FreshFinds/utils/freshFindsDoi.ts new file mode 100644 index 00000000..64c9fe92 --- /dev/null +++ b/src/features/FreshFinds/utils/freshFindsDoi.ts @@ -0,0 +1,21 @@ +const stripDoiOrgPrefix = (doi: string): string => + doi.trim().replace(/^\s*https?:\/\/doi\.org\//i, ''); + +/** Canonical https DOI URL, or empty if there is nothing to link. */ +export const buildFreshFindsDoiHref = (doi: string): string => { + const slug = stripDoiOrgPrefix(doi); + if (slug === '') { + return ''; + } + return `https://doi.org/${encodeURIComponent(slug)}`; +}; + +/** Drawer outputs target: DOI resolver, or CORE home when DOI is missing. */ +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/'; +}; diff --git a/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts b/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts index 41731b2c..d6473313 100644 --- a/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts +++ b/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts @@ -1,23 +1,25 @@ import type { ArticleAdditionalData } from '@/hooks/useArticleData'; import type { FreshFindsRecord } from '../types/data.types'; + import { formatFreshFindsAuthors } from './freshFindsAuthors'; -/** - * Maps API row shape into {@link ArticleAdditionalData} so {@link CrDrawer} can render known fields (title, DOI, authors). - */ -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, - }; +/** Maps API row shape into CrDrawer-compatible article fields (title used as headline when API has no paper title). */ +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 index 24b60af8..098079db 100644 --- a/src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts +++ b/src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts @@ -1,47 +1,44 @@ import type { FreshFindsRecord } from '../types/data.types'; export const 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; + 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; }; -/** - * API may nest works under arbitrary keys (e.g. `{ "0": { "0": { DOI, affiliation_info }, "1": { ... }, "status": "..." }, "since": "..." }`). - * Recursively collect leaf objects that look like a fresh-finds row. - */ +/** Flattens nested API payloads into a list of usable table rows. */ export const normalizeFreshFindsResponse = (raw: unknown): FreshFindsRecord[] => { - const rows: 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; - } + 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; - } + if (isFreshFindsDataRow(node)) { + rows.push(node); + return; + } - for (const value of Object.values(node as Record)) { - visit(value); - } - }; + for (const value of Object.values(node as Record)) { + visit(value); + } + }; - visit(raw); - return rows; + visit(raw); + return rows; }; diff --git a/src/pages/FreshFinds.tsx b/src/pages/FreshFinds.tsx index 07d235ed..f1a57b23 100644 --- a/src/pages/FreshFinds.tsx +++ b/src/pages/FreshFinds.tsx @@ -3,7 +3,5 @@ import {FreshFindsFeature} from '@features/FreshFinds/FreshFindsFeature.tsx'; export function FreshFindsPage() { useDocumentTitle('Fresh finds'); - return ( - - ); + return ; } From 7b48f10c4dda68969edcce98d8cb1fb98156e694 Mon Sep 17 00:00:00 2001 From: ekachxaidze98 Date: Wed, 13 May 2026 17:06:41 +0400 Subject: [PATCH 3/3] CORE: utils --- .../components/FreshFindsColumns.tsx | 3 +- .../FreshFinds/components/FreshFindsTable.tsx | 10 +-- .../utils/filterFreshFindsRecords.ts | 19 ----- .../FreshFinds/utils/freshFindsAuthors.ts | 13 ---- .../FreshFinds/utils/freshFindsDisplay.ts | 70 +++++++++++++++++++ .../FreshFinds/utils/freshFindsDoi.ts | 21 ------ .../utils/mapFreshFindsRecordToArticle.ts | 25 ------- .../utils/normalizeFreshFindsResponse.ts | 5 +- 8 files changed, 79 insertions(+), 87 deletions(-) delete mode 100644 src/features/FreshFinds/utils/filterFreshFindsRecords.ts delete mode 100644 src/features/FreshFinds/utils/freshFindsAuthors.ts create mode 100644 src/features/FreshFinds/utils/freshFindsDisplay.ts delete mode 100644 src/features/FreshFinds/utils/freshFindsDoi.ts delete mode 100644 src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts diff --git a/src/features/FreshFinds/components/FreshFindsColumns.tsx b/src/features/FreshFinds/components/FreshFindsColumns.tsx index 0ec9d1a2..45e95fe9 100644 --- a/src/features/FreshFinds/components/FreshFindsColumns.tsx +++ b/src/features/FreshFinds/components/FreshFindsColumns.tsx @@ -3,8 +3,7 @@ import { LinkOutlined } from '@ant-design/icons'; import type { ReusableTableColumn } from '@components/common/CrTable/types.ts'; import type { FreshFindsRecord } from '../types/data.types'; -import { buildFreshFindsDoiHref } from '../utils/freshFindsDoi'; -import { formatFreshFindsAuthors } from '../utils/freshFindsAuthors'; +import { buildFreshFindsDoiHref, formatFreshFindsAuthors } from '../utils/freshFindsDisplay'; const sortAuthors = (a: FreshFindsRecord, b: FreshFindsRecord): number => { const sa = formatFreshFindsAuthors(a.affiliation_info).toLowerCase(); diff --git a/src/features/FreshFinds/components/FreshFindsTable.tsx b/src/features/FreshFinds/components/FreshFindsTable.tsx index 18f66441..fb548a91 100644 --- a/src/features/FreshFinds/components/FreshFindsTable.tsx +++ b/src/features/FreshFinds/components/FreshFindsTable.tsx @@ -12,11 +12,13 @@ import { useOrganisation } from '@features/Settings/OrganisationalSettings/hooks import { createColumns, freshFindsCustomSorters } from './FreshFindsColumns.tsx'; import { useDownloadFreshFindsCsv } from '../hooks/useDownloadFreshFindsCsv'; import type { FreshFindsRecord } from '../types/data.types'; -import { filterFreshFindsRecords } from '../utils/filterFreshFindsRecords'; -import { buildFreshFindsOutputsUrl } from '../utils/freshFindsDoi'; -import { mapFreshFindsRecordToArticle } from '../utils/mapFreshFindsRecordToArticle'; +import { + buildFreshFindsOutputsUrl, + filterFreshFindsRecords, + formatFreshFindsAuthors, + mapFreshFindsRecordToArticle, +} from '../utils/freshFindsDisplay'; import { articleTemplateData } from '../texts'; -import { formatFreshFindsAuthors } from '../utils/freshFindsAuthors'; type FreshFindsTableRow = FreshFindsRecord & { __rowKey: string }; diff --git a/src/features/FreshFinds/utils/filterFreshFindsRecords.ts b/src/features/FreshFinds/utils/filterFreshFindsRecords.ts deleted file mode 100644 index 5f8931f0..00000000 --- a/src/features/FreshFinds/utils/filterFreshFindsRecords.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { FreshFindsRecord } from '../types/data.types'; - -import { formatFreshFindsAuthors } from './freshFindsAuthors'; - -export const filterFreshFindsRecords = ( - records: FreshFindsRecord[], - searchTerm: string, -): FreshFindsRecord[] => { - const lowerSearchTerm = searchTerm.trim().toLowerCase(); - if (lowerSearchTerm === '') { - 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(lowerSearchTerm) || doi.includes(lowerSearchTerm); - }); -}; diff --git a/src/features/FreshFinds/utils/freshFindsAuthors.ts b/src/features/FreshFinds/utils/freshFindsAuthors.ts deleted file mode 100644 index 306e7d9e..00000000 --- a/src/features/FreshFinds/utils/freshFindsAuthors.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { FreshFindsAffiliationInfo } 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(', '); -}; 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/freshFindsDoi.ts b/src/features/FreshFinds/utils/freshFindsDoi.ts deleted file mode 100644 index 64c9fe92..00000000 --- a/src/features/FreshFinds/utils/freshFindsDoi.ts +++ /dev/null @@ -1,21 +0,0 @@ -const stripDoiOrgPrefix = (doi: string): string => - doi.trim().replace(/^\s*https?:\/\/doi\.org\//i, ''); - -/** Canonical https DOI URL, or empty if there is nothing to link. */ -export const buildFreshFindsDoiHref = (doi: string): string => { - const slug = stripDoiOrgPrefix(doi); - if (slug === '') { - return ''; - } - return `https://doi.org/${encodeURIComponent(slug)}`; -}; - -/** Drawer outputs target: DOI resolver, or CORE home when DOI is missing. */ -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/'; -}; diff --git a/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts b/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts deleted file mode 100644 index d6473313..00000000 --- a/src/features/FreshFinds/utils/mapFreshFindsRecordToArticle.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ArticleAdditionalData } from '@/hooks/useArticleData'; - -import type { FreshFindsRecord } from '../types/data.types'; - -import { formatFreshFindsAuthors } from './freshFindsAuthors'; - -/** Maps API row shape into CrDrawer-compatible article fields (title used as headline when API has no paper title). */ -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 index 098079db..b9ead8da 100644 --- a/src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts +++ b/src/features/FreshFinds/utils/normalizeFreshFindsResponse.ts @@ -1,6 +1,6 @@ import type { FreshFindsRecord } from '../types/data.types'; -export const isFreshFindsDataRow = (item: unknown): item is FreshFindsRecord => { +function isFreshFindsDataRow(item: unknown): item is FreshFindsRecord { if (item == null || typeof item !== 'object' || Array.isArray(item)) { return false; } @@ -9,9 +9,8 @@ export const isFreshFindsDataRow = (item: unknown): item is FreshFindsRecord => const hasAuthors = Array.isArray(row.affiliation_info) && row.affiliation_info.length > 0; return hasDoi || hasAuthors; -}; +} -/** Flattens nested API payloads into a list of usable table rows. */ export const normalizeFreshFindsResponse = (raw: unknown): FreshFindsRecord[] => { const rows: FreshFindsRecord[] = [];