From f5a63483fcecf3b3879c0a3ca9cedad4f08cb640 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:10:12 -0500 Subject: [PATCH] SCIX-818 feat(AbstractSources): add access-level badges to full text sources Show "Available" (green) or "Login required" (yellow) pill badges next to each source group name in accordion view, and per-item tags in menu view. Institution links are excluded from access labeling. - Extract access label logic into accessLabel.ts helper - Add group-level Badge in accordion, per-item Tag in menu - Normalize AcademicCapIcon size to match PDF/HTML icons (24x24) - Style institution icon as subtle dark gray - Suppress redundant "INSTITUTION" type label from institution links - Add unit tests for access label helpers - Add integration tests for badge rendering in both views --- .../AbstractSources/AbstractSourceItems.tsx | 143 ++++++++++++------ .../__tests__/accessLabel.test.ts | 59 ++++++++ src/components/AbstractSources/accessLabel.ts | 29 ++++ .../__tests__/AbstractSources.test.tsx | 76 ++++++++-- 4 files changed, 248 insertions(+), 59 deletions(-) create mode 100644 src/components/AbstractSources/__tests__/accessLabel.test.ts create mode 100644 src/components/AbstractSources/accessLabel.ts diff --git a/src/components/AbstractSources/AbstractSourceItems.tsx b/src/components/AbstractSources/AbstractSourceItems.tsx index 2fe7b74b1..a4953fad1 100644 --- a/src/components/AbstractSources/AbstractSourceItems.tsx +++ b/src/components/AbstractSources/AbstractSourceItems.tsx @@ -1,5 +1,6 @@ import { ChevronDownIcon, Icon, LockIcon, UnlockIcon } from '@chakra-ui/icons'; import { + Badge, Box, BoxProps, Button, @@ -12,6 +13,7 @@ import { MenuButton, MenuItem, MenuList, + Tag, Text, Tooltip, } from '@chakra-ui/react'; @@ -21,6 +23,7 @@ import { HtmlFileIcon } from '@/components/icons/HtmlFileIcon'; import { PdfFileIcon } from '@/components/icons/PdfFileIcon'; import { SimpleLink } from '@/components/SimpleLink'; import { AcademicCapIcon } from '@heroicons/react/24/solid'; +import { getAccessLabel, getGroupAccessLabel } from './accessLabel'; import { IFullTextSource } from './types'; import { Fragment } from 'react'; import { Esources } from '@/api/search/types'; @@ -39,12 +42,12 @@ export type FullTextResourceType = { export interface IAbstractSourceItemsProps extends BoxProps { resources: AbstractResourceType[]; - type: 'list' | 'menu'; // List is used in an accordion, menu is a button and dropdown style + type: 'list' | 'menu'; } export interface IFullTextSourceItemsProps extends BoxProps { resources: FullTextResourceType[]; - type: 'list' | 'menu'; // List is used in an accordion, menu is a button and dropdown style + type: 'list' | 'menu'; } export const AbstractSourceItems = ({ resources, type, ...boxProps }: IAbstractSourceItemsProps) => { @@ -92,18 +95,32 @@ export const FullTextSourceItems = ({ resources, type, ...boxProps }: IFullTextS {resources.map((group) => ( - {group.links.map((link) => ( - - {link.rawType === Esources.INSTITUTION ? ( - - ) : link.open ? ( - - ) : ( - - )} - {`${group.label} ${link.type.toLocaleUpperCase()}`} - - ))} + {group.links.map((link) => { + const access = getAccessLabel(link.open, link.rawType); + const typeLabel = link.type.toLocaleUpperCase(); + + return ( + + {link.rawType === Esources.INSTITUTION ? ( + + ) : link.open ? ( + + ) : ( + + )} + + + {link.rawType === Esources.INSTITUTION ? group.label : `${group.label} ${typeLabel}`} + + {access && ( + + {access.badge} + + )} + + + ); + })} ))} @@ -111,41 +128,69 @@ export const FullTextSourceItems = ({ resources, type, ...boxProps }: IFullTextS ) : ( - {resources.map((group) => ( - - - {group.label} - - {group.links.map((link) => ( - - - ) : link.type === 'pdf' ? ( - - ) : link.type === 'html' ? ( - - ) : ( - - ) - } - variant="unstyled" - as={SimpleLink} - href={link.path} - newTab - /> - - ))} - - - - ))} + {resources.map((group) => { + const access = getGroupAccessLabel(group.links); + + return ( + + + + {group.label} + {access && ( + + {access.badge} + + )} + + + {group.links.map((link) => { + const linkAccess = getAccessLabel(link.open, link.rawType); + const isInstitution = link.rawType === Esources.INSTITUTION; + const accessText = isInstitution + ? '' + : linkAccess + ? `${link.type.toLocaleUpperCase()} — ${linkAccess.badge}` + : link.type.toLocaleUpperCase(); + const label = isInstitution ? group.label : `${group.label} ${accessText}`; + + return ( + + + ) : link.type === 'pdf' ? ( + + ) : link.type === 'html' ? ( + + ) : ( + + ) + } + variant="unstyled" + as={SimpleLink} + href={link.path} + newTab + size="xs" + /> + + ); + })} + + + + ); + })} )} diff --git a/src/components/AbstractSources/__tests__/accessLabel.test.ts b/src/components/AbstractSources/__tests__/accessLabel.test.ts new file mode 100644 index 000000000..c75e701db --- /dev/null +++ b/src/components/AbstractSources/__tests__/accessLabel.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'vitest'; +import { getAccessLabel, getGroupAccessLabel } from '../accessLabel'; +import { Esources } from '@/api/search/types'; + +describe('getAccessLabel', () => { + test('returns available label when open is true', () => { + const result = getAccessLabel(true, Esources.EPRINT_PDF); + expect(result).toEqual({ + badge: 'Available', + colorScheme: 'green', + }); + }); + + test('returns login required label when open is false', () => { + const result = getAccessLabel(false, Esources.PUB_HTML); + expect(result).toEqual({ + badge: 'Login required', + colorScheme: 'yellow', + }); + }); + + test('returns null for institution links', () => { + const result = getAccessLabel(false, Esources.INSTITUTION); + expect(result).toBeNull(); + }); +}); + +describe('getGroupAccessLabel', () => { + test('returns label from first non-institution link', () => { + const links = [ + { open: true, rawType: Esources.EPRINT_PDF }, + { open: true, rawType: Esources.EPRINT_HTML }, + ]; + expect(getGroupAccessLabel(links)).toEqual({ + badge: 'Available', + colorScheme: 'green', + }); + }); + + test('skips institution links', () => { + const links = [ + { open: false, rawType: Esources.INSTITUTION }, + { open: false, rawType: Esources.PUB_PDF }, + ]; + expect(getGroupAccessLabel(links)).toEqual({ + badge: 'Login required', + colorScheme: 'yellow', + }); + }); + + test('returns null for institution-only group', () => { + const links = [{ open: false, rawType: Esources.INSTITUTION }]; + expect(getGroupAccessLabel(links)).toBeNull(); + }); + + test('returns null for empty group', () => { + expect(getGroupAccessLabel([])).toBeNull(); + }); +}); diff --git a/src/components/AbstractSources/accessLabel.ts b/src/components/AbstractSources/accessLabel.ts new file mode 100644 index 000000000..8951b84eb --- /dev/null +++ b/src/components/AbstractSources/accessLabel.ts @@ -0,0 +1,29 @@ +import { Esources } from '@/api/search/types'; + +export type AccessLabel = { + badge: string; + colorScheme: string; +}; + +export const getAccessLabel = (open: boolean, rawType: keyof typeof Esources): AccessLabel | null => { + if (rawType === Esources.INSTITUTION) { + return null; + } + return open ? { badge: 'Available', colorScheme: 'green' } : { badge: 'Login required', colorScheme: 'yellow' }; +}; + +/** + * Derive a single access label for a group of links. + * All links in a group share the same access level + * (derived from the same PREFIX_OPENACCESS property). + * Falls back to the first non-institution link. + */ +export const getGroupAccessLabel = (links: { open: boolean; rawType: keyof typeof Esources }[]): AccessLabel | null => { + for (const link of links) { + const label = getAccessLabel(link.open, link.rawType); + if (label) { + return label; + } + } + return null; +}; diff --git a/src/components/__tests__/AbstractSources.test.tsx b/src/components/__tests__/AbstractSources.test.tsx index bea88c3f8..472323471 100644 --- a/src/components/__tests__/AbstractSources.test.tsx +++ b/src/components/__tests__/AbstractSources.test.tsx @@ -54,10 +54,8 @@ const docWithoutProperty = { describe('AbstractSources', () => { beforeEach(() => { - // Reset all handlers before each test server.resetHandlers(); - // Add a default mock for export endpoint to prevent errors server.use( rest.get('/export/manifest', (req, res, ctx) => { return res(ctx.json([])); @@ -74,7 +72,6 @@ describe('AbstractSources', () => { }); test('makes associated request when property contains ASSOCIATED', async () => { - // Set up a spy on the resolver endpoint that only captures associated requests const resolverSpy = vi.fn(); server.use( rest.get(apiHandlerRoute(ApiTargets.RESOLVER, '/:bibcode/associated'), (req, res, ctx) => { @@ -92,7 +89,6 @@ describe('AbstractSources', () => { render(); - // Wait for the component to make the API call await waitFor(() => { expect(resolverSpy).toHaveBeenCalledWith({ bibcode: '2004AdM....16.2049S', @@ -102,7 +98,6 @@ describe('AbstractSources', () => { }); test('does NOT make associated request when property does not contain ASSOCIATED', async () => { - // Set up a spy on the resolver endpoint that only captures associated requests const resolverSpy = vi.fn(); server.use( rest.get(apiHandlerRoute(ApiTargets.RESOLVER, '/:bibcode/associated'), (req, res, ctx) => { @@ -120,15 +115,12 @@ describe('AbstractSources', () => { render(); - // Wait a bit to ensure no API call is made await new Promise((resolve) => setTimeout(resolve, 100)); - // Verify that no call to the associated endpoint was made expect(resolverSpy).not.toHaveBeenCalled(); }); test('does NOT make associated request when property field is undefined', async () => { - // Set up a spy on the resolver endpoint that only captures associated requests const resolverSpy = vi.fn(); server.use( rest.get(apiHandlerRoute(ApiTargets.RESOLVER, '/:bibcode/associated'), (req, res, ctx) => { @@ -146,10 +138,74 @@ describe('AbstractSources', () => { render(); - // Wait a bit to ensure no API call is made await new Promise((resolve) => setTimeout(resolve, 100)); - // Verify that no call to the associated endpoint was made expect(resolverSpy).not.toHaveBeenCalled(); }); + + test('accordion shows "Available" badge for open sources', () => { + const doc = { + ...baseDoc, + esources: ['EPRINT_PDF'], + property: ['EPRINT_OPENACCESS'], + bibcode: '2004AdM....16.2049S', + } as IDocsEntity; + + const { getByText, getByLabelText } = render(); + + expect(getByText('Available')).toBeDefined(); + expect(getByLabelText(/Preprint.*PDF.*Available/i)).toBeDefined(); + }); + + test('accordion shows "Login required" badge for closed sources', () => { + const doc = { + ...baseDoc, + esources: ['PUB_HTML'], + property: [], + bibcode: '2004AdM....16.2049S', + } as IDocsEntity; + + const { getByText, getByLabelText } = render(); + + expect(getByText('Login required')).toBeDefined(); + expect(getByLabelText(/Publisher.*HTML.*Login required/i)).toBeDefined(); + }); + + test('menu shows "Available" tag for open sources', async () => { + const doc = { + ...baseDoc, + esources: ['EPRINT_PDF'], + property: ['EPRINT_OPENACCESS'], + bibcode: '2004AdM....16.2049S', + } as IDocsEntity; + + const { getByRole, getByText } = render(); + + const button = getByRole('button', { name: /Full Text Sources/i }); + button.click(); + + await waitFor(() => { + expect(getByText(/Preprint PDF/)).toBeDefined(); + expect(getByText('Available')).toBeDefined(); + }); + }); + + test('menu shows "Login required" tag for closed sources', async () => { + const doc = { + ...baseDoc, + esources: ['PUB_HTML'], + property: [], + bibcode: '2004AdM....16.2049S', + } as IDocsEntity; + + const { getByRole, getByText } = render(); + + const button = getByRole('button', { name: /Full Text Sources/i }); + button.click(); + + await waitFor(() => { + expect(getByText(/Publisher HTML/)).toBeDefined(); + expect(getByText('Login required')).toBeDefined(); + }); + }); });