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) => (
-
- ))}
+ {group.links.map((link) => {
+ const access = getAccessLabel(link.open, link.rawType);
+ const typeLabel = link.type.toLocaleUpperCase();
+
+ return (
+
+ );
+ })}
))}
@@ -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();
+ });
+ });
});