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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/Layout/menuItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ export const menuItems = [
},
],
},
{
test: /\/fresh-finds/,
path: 'fresh-finds',
icon: 'fair',
label: 'Fresh Finds',
},
{
test: /\/fair-certification/,
path: 'fair-certification',
Expand Down
19 changes: 12 additions & 7 deletions src/components/common/CrDrawer/CrDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
outputsUrl?: string;
isChangingVisibility?: boolean;
Expand All @@ -66,6 +68,7 @@ export const CrDrawer: React.FC<OrcidDrawerContentProps> = ({
article,
error,
removeLiveActions,
hideRepositoryButton = false,
isLoading,
onVisibilityChange,
outputsUrl,
Expand Down Expand Up @@ -176,13 +179,15 @@ export const CrDrawer: React.FC<OrcidDrawerContentProps> = ({
>
Open in CORE
</Button>
<Button
type="default"
target="_blank"
href={`https://api.core.ac.uk/oai/${article?.oai}`}
>
Open in the Repository
</Button>
{!hideRepositoryButton && (
<Button
type="default"
target="_blank"
href={`https://api.core.ac.uk/oai/${article?.oai}`}
>
Open in the Repository
</Button>
)}
</div>
) : (
<div className="actions">
Expand Down
39 changes: 39 additions & 0 deletions src/features/FreshFinds/FreshFindsFeature.css
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions src/features/FreshFinds/FreshFindsFeature.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CrFeatureLayout>
<CrHeader
identifier="Demo"
title={articleTemplateData.title}
showMore={
<CrShowMore text={articleTemplateData.description} maxLetters={320} />
}
/>
<main className="page fresh-finds-page">
<FreshFindsTable
records={records}
isLoading={isLoading}
error={error}
dataProviderName={selectedDataProvider?.name ?? 'your institution'}
/>
</main>
</CrFeatureLayout>
);
};
84 changes: 84 additions & 0 deletions src/features/FreshFinds/components/FreshFindsColumns.tsx
Original file line number Diff line number Diff line change
@@ -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<FreshFindsRecord>[] => [
{
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 (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="fresh-finds__doi-link"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
aria-label={`Open DOI ${doi} in a new tab`}
>
<LinkOutlined className="fresh-finds__doi-icon" aria-hidden />
{doi}
</a>
);
},
},
];
140 changes: 140 additions & 0 deletions src/features/FreshFinds/components/FreshFindsTable.tsx
Original file line number Diff line number Diff line change
@@ -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<FreshFindsRecord>({
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<FreshFindsTableRow> = useMemo(
() => ({
enabled: true,
content: (record: FreshFindsTableRow) => (
<div className="drawer-wrapper fresh-finds-drawer-wrapper">
<CrDrawer
article={mapFreshFindsRecordToArticle(record)}
isLoading={false}
removeLiveActions
hideRepositoryButton
outputsUrl={buildFreshFindsOutputsUrl(
record.DOI != null ? String(record.DOI) : undefined,
)}
/>
</div>
),
}),
[],
);

const handleSearch = useCallback((term: string) => {
setSearchTerm(term);
}, []);

return (
<CrPaper>
<div className="fresh-finds-table-header">
<h2 className="fresh-finds-table-header__title">
{articleTemplateData.table.title}
</h2>
<p className="fresh-finds-table-header__subtitle">
Papers we discovered elsewhere authored by <strong>{dataProviderName}</strong> you might
consider adding to your repository.
</p>
</div>
<div id="freshFindsTable">
<CrTable<FreshFindsTableRow>
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 && (
<AccessPlaceholder
customWidth
description="To see and download the full list of fresh finds, become our Supporting or Sustaining member."
/>
)}
</div>
</CrPaper>
);
};
5 changes: 5 additions & 0 deletions src/features/FreshFinds/constants/freshFindsProdApi.ts
Original file line number Diff line number Diff line change
@@ -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`;
14 changes: 14 additions & 0 deletions src/features/FreshFinds/hooks/useDownloadFreshFindsCsv.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading