From 3225693bb2843a8753b17bc527e451cd86b3b4f1 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:34:04 +0100 Subject: [PATCH 01/10] feature: based on value of metadata field isUrl - decide whether clickable or not --- src/app/item-page/full/full-item-page.component.html | 8 +++++++- src/app/item-page/full/full-item-page.component.ts | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index 0e0db894b1d..eebd9e45d13 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -24,7 +24,13 @@ @for (mdValue of mdEntry.value; track mdValue) { {{mdEntry.key}} - {{mdValue.value}} + + @if (isUrl(mdValue.value)) { + {{mdValue.value}} + } @else { + {{mdValue.value}} + } + {{mdValue.language}} } diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 87b01f21e97..fd76c400ed0 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -119,6 +119,13 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, ); } + /** + * Check if a metadata value is a URL. + */ + isUrl(value: string): boolean { + return !!value && (value.startsWith('http://') || value.startsWith('https://')); + } + /** * Navigate back in browser history. */ From a44142d1c46ed0c32fed301912d16547313156f1 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:34:37 +0100 Subject: [PATCH 02/10] test: add unit test for new method --- .../full/full-item-page.component.spec.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index ac2b4634a83..ce435259b1a 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -65,6 +65,30 @@ const mockItem: Item = Object.assign(new Item(), { }, }); +const mockItemWithUrl: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'test item', + }, + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'https://hdl.handle.net/123456789/1', + }, + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'plain text value', + }, + ], + }, +}); + const mockWithdrawnItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), metadata: [], @@ -265,4 +289,50 @@ describe('FullItemPageComponent', () => { expect(linkHeadService.addTag).toHaveBeenCalledTimes(3); }); }); + + describe('isUrl', () => { + it('should return true for https URLs', () => { + expect(comp.isUrl('https://example.com')).toBeTrue(); + }); + + it('should return true for http URLs', () => { + expect(comp.isUrl('http://example.com')).toBeTrue(); + }); + + it('should return false for plain text', () => { + expect(comp.isUrl('just some text')).toBeFalse(); + }); + + it('should return false for null', () => { + expect(comp.isUrl(null)).toBeFalse(); + }); + + it('should return false for undefined', () => { + expect(comp.isUrl(undefined)).toBeFalse(); + }); + }); + + describe('metadata URL rendering', () => { + beforeEach(() => { + comp.metadata$ = of(mockItemWithUrl.metadata); + fixture.detectChanges(); + }); + + it('should render URL metadata values as clickable links', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const urlLink = links.find(l => l.nativeElement.textContent.includes('https://hdl.handle.net/123456789/1')); + expect(urlLink).toBeTruthy(); + expect(urlLink.nativeElement.getAttribute('href')).toBe('https://hdl.handle.net/123456789/1'); + expect(urlLink.nativeElement.getAttribute('target')).toBe('_blank'); + expect(urlLink.nativeElement.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('should render non-URL metadata values as plain text', () => { + const table = fixture.debugElement.query(By.css('table')); + const links = fixture.debugElement.queryAll(By.css('table a')); + expect(table.nativeElement.innerHTML).toContain('plain text value'); + const plainTextLink = links.find(l => l.nativeElement.textContent.includes('plain text value')); + expect(plainTextLink).toBeFalsy(); + }); + }); }); From 63538f0c0a64fae1034fc62beaafa940bf6aee0d Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:45:10 +0100 Subject: [PATCH 03/10] refactor: rename isUrl to isHttpUrl, accept null/undefined, and normalize input with trim/lowercase --- src/app/item-page/full/full-item-page.component.html | 2 +- .../item-page/full/full-item-page.component.spec.ts | 12 ++++++------ src/app/item-page/full/full-item-page.component.ts | 7 ++++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index eebd9e45d13..d96351648d8 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -25,7 +25,7 @@ {{mdEntry.key}} - @if (isUrl(mdValue.value)) { + @if (isHttpUrl(mdValue.value)) { {{mdValue.value}} } @else { {{mdValue.value}} diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index ce435259b1a..e2c505fdfb3 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -290,25 +290,25 @@ describe('FullItemPageComponent', () => { }); }); - describe('isUrl', () => { + describe('isHttpUrl', () => { it('should return true for https URLs', () => { - expect(comp.isUrl('https://example.com')).toBeTrue(); + expect(comp.isHttpUrl('https://example.com')).toBeTrue(); }); it('should return true for http URLs', () => { - expect(comp.isUrl('http://example.com')).toBeTrue(); + expect(comp.isHttpUrl('http://example.com')).toBeTrue(); }); it('should return false for plain text', () => { - expect(comp.isUrl('just some text')).toBeFalse(); + expect(comp.isHttpUrl('just some text')).toBeFalse(); }); it('should return false for null', () => { - expect(comp.isUrl(null)).toBeFalse(); + expect(comp.isHttpUrl(null)).toBeFalse(); }); it('should return false for undefined', () => { - expect(comp.isUrl(undefined)).toBeFalse(); + expect(comp.isHttpUrl(undefined)).toBeFalse(); }); }); diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index fd76c400ed0..10ccafdc762 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -120,10 +120,11 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, } /** - * Check if a metadata value is a URL. + * Check if a metadata value is an HTTP(S) URL. */ - isUrl(value: string): boolean { - return !!value && (value.startsWith('http://') || value.startsWith('https://')); + isHttpUrl(value: string | null | undefined): boolean { + const v = value?.trim().toLowerCase(); + return !!v && (v.startsWith('http://') || v.startsWith('https://')); } /** From 771e5a6d34a03b7f485dcfa009b118a3bb0efd81 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:54:25 +0100 Subject: [PATCH 04/10] refactor: trimmed values used in consistent way --- src/app/item-page/full/full-item-page.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index d96351648d8..63ff0a2441b 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -26,7 +26,7 @@ {{mdEntry.key}} @if (isHttpUrl(mdValue.value)) { - {{mdValue.value}} + {{mdValue.value.trim()}} } @else { {{mdValue.value}} } From f4a6bc0a639b5478a11599f4ac7e1fa90f56faf0 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:04:02 +0100 Subject: [PATCH 05/10] test: drive metadata URL rendering tests through route data to exercise the real itemRD$ --- src/app/item-page/full/full-item-page.component.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index e2c505fdfb3..cab8dd68ff2 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -314,7 +314,8 @@ describe('FullItemPageComponent', () => { describe('metadata URL rendering', () => { beforeEach(() => { - comp.metadata$ = of(mockItemWithUrl.metadata); + routeData.dso = createSuccessfulRemoteDataObject(mockItemWithUrl); + comp.ngOnInit(); fixture.detectChanges(); }); From 6209f87d480c956b9cfb77db65a815dcd5563158 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 20 Mar 2026 12:59:25 +0100 Subject: [PATCH 06/10] feat: convert full item metadata values to hyperlinks using shared makeLinks utility --- .../full/full-item-page.component.html | 6 +-- .../full/full-item-page.component.spec.ts | 52 ++++++++++++++----- .../full/full-item-page.component.ts | 10 +--- src/app/shared/utils/make-links.ts | 10 ++++ 4 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 src/app/shared/utils/make-links.ts diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index 63ff0a2441b..fad5925c78d 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -25,11 +25,7 @@ {{mdEntry.key}} - @if (isHttpUrl(mdValue.value)) { - {{mdValue.value.trim()}} - } @else { - {{mdValue.value}} - } + {{mdValue.language}} diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index cab8dd68ff2..e54a4913121 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -52,6 +52,7 @@ import { ItemVersionsComponent } from '../versions/item-versions.component'; import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; import { ThemedFullFileSectionComponent } from './field-components/file-section/themed-full-file-section.component'; import { FullItemPageComponent } from './full-item-page.component'; +import { makeLinks } from '../../shared/utils/make-links'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -290,25 +291,50 @@ describe('FullItemPageComponent', () => { }); }); - describe('isHttpUrl', () => { - it('should return true for https URLs', () => { - expect(comp.isHttpUrl('https://example.com')).toBeTrue(); + describe('makeLinks', () => { + it('should convert https URLs to clickable links', () => { + expect(makeLinks('https://example.com')).toContain(' { - expect(comp.isHttpUrl('http://example.com')).toBeTrue(); + it('should convert http URLs to clickable links', () => { + expect(makeLinks('http://example.com')).toContain(' { - expect(comp.isHttpUrl('just some text')).toBeFalse(); + it('should convert ftp URLs to clickable links', () => { + expect(makeLinks('ftp://files.example.com/resource')).toContain(' { - expect(comp.isHttpUrl(null)).toBeFalse(); + it('should convert www. URLs to clickable links', () => { + expect(makeLinks('www.example.com')).toContain(' { - expect(comp.isHttpUrl(undefined)).toBeFalse(); + it('should return plain text unchanged', () => { + expect(makeLinks('just some text')).toBe('just some text'); + }); + + it('should handle null/undefined gracefully', () => { + expect(makeLinks(null)).toBeUndefined(); + expect(makeLinks(undefined)).toBeUndefined(); + }); + + it('should convert URLs embedded in text', () => { + const result = makeLinks('Visit https://example.com for details'); + expect(result).toContain(' { + const result = makeLinks('https://hdl.handle.net/123456789/1'); + expect(result).toContain(' { + const result = makeLinks('javascript:alert(1)'); + expect(result).not.toContain(' { expect(urlLink.nativeElement.getAttribute('rel')).toBe('noopener noreferrer'); }); - it('should render non-URL metadata values as plain text', () => { + it('should render non-URL metadata values as plain text without links', () => { const table = fixture.debugElement.query(By.css('table')); - const links = fixture.debugElement.queryAll(By.css('table a')); expect(table.nativeElement.innerHTML).toContain('plain text value'); + const links = fixture.debugElement.queryAll(By.css('table a')); const plainTextLink = links.find(l => l.nativeElement.textContent.includes('plain text value')); expect(plainTextLink).toBeFalsy(); }); diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 10ccafdc762..75a21c45ddc 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -39,6 +39,7 @@ import { MetadataMap } from '../../core/shared/metadata.models'; import { fadeInOut } from '../../shared/animations/fade'; import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { hasValue } from '../../shared/empty.util'; +import { makeLinks } from '../../shared/utils/make-links'; import { ErrorComponent } from '../../shared/error/error.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { VarDirective } from '../../shared/utils/var.directive'; @@ -78,6 +79,7 @@ import { ThemedFullFileSectionComponent } from './field-components/file-section/ standalone: true, }) export class FullItemPageComponent extends ItemPageComponent implements OnInit, OnDestroy { + protected readonly makeLinks = makeLinks; itemRD$: BehaviorSubject>; @@ -119,14 +121,6 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, ); } - /** - * Check if a metadata value is an HTTP(S) URL. - */ - isHttpUrl(value: string | null | undefined): boolean { - const v = value?.trim().toLowerCase(); - return !!v && (v.startsWith('http://') || v.startsWith('https://')); - } - /** * Navigate back in browser history. */ diff --git a/src/app/shared/utils/make-links.ts b/src/app/shared/utils/make-links.ts new file mode 100644 index 00000000000..5584bf93b6f --- /dev/null +++ b/src/app/shared/utils/make-links.ts @@ -0,0 +1,10 @@ +/** + * Convert raw text URLs into clickable HTML links. + * Detects http, https, ftp URLs and www. addresses. + * + * Ported from dtq-dev clarin-shared-util.ts to be reused across components. + */ +export function makeLinks(text: string): string { + const regex = /(?:https?|ftp):\/\/[^\s)]+|www\.[^\s)]+/g; + return text?.replace(regex, (url) => `${url}`); +} From d4f4ad718acb4d2e3e864737d2e8fa87db1f0fda Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 20 Mar 2026 13:45:26 +0100 Subject: [PATCH 07/10] Fixed linting and added edge case scenarios --- .../full/full-item-page.component.spec.ts | 50 +++++++++++++++++-- .../full/full-item-page.component.ts | 2 +- src/app/shared/utils/make-links.ts | 5 +- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index e54a4913121..3df9bd991f4 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -43,6 +43,7 @@ import { import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { ThemeService } from '../../shared/theme-support/theme.service'; +import { makeLinks } from '../../shared/utils/make-links'; import { TruncatePipe } from '../../shared/utils/truncate.pipe'; import { VarDirective } from '../../shared/utils/var.directive'; import { CollectionsComponent } from '../field-components/collections/collections.component'; @@ -52,7 +53,6 @@ import { ItemVersionsComponent } from '../versions/item-versions.component'; import { ItemVersionsNoticeComponent } from '../versions/notice/item-versions-notice.component'; import { ThemedFullFileSectionComponent } from './field-components/file-section/themed-full-file-section.component'; import { FullItemPageComponent } from './full-item-page.component'; -import { makeLinks } from '../../shared/utils/make-links'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -305,8 +305,10 @@ describe('FullItemPageComponent', () => { expect(makeLinks('ftp://files.example.com/resource')).toContain(' { - expect(makeLinks('www.example.com')).toContain(' { + const result = makeLinks('www.example.com'); + expect(result).toContain('www.example.com'); }); it('should return plain text unchanged', () => { @@ -318,6 +320,10 @@ describe('FullItemPageComponent', () => { expect(makeLinks(undefined)).toBeUndefined(); }); + it('should handle empty string', () => { + expect(makeLinks('')).toBe(''); + }); + it('should convert URLs embedded in text', () => { const result = makeLinks('Visit https://example.com for details'); expect(result).toContain(' { expect(result).toContain('rel="noopener noreferrer"'); }); + it('should handle DOI resolver URLs', () => { + const result = makeLinks('https://doi.org/10.1234/test'); + expect(result).toContain(' { const result = makeLinks('javascript:alert(1)'); expect(result).not.toContain(' { + const result = makeLinks('data:text/html,'); + expect(result).not.toContain(' { + const result = makeLinks('See https://a.com and https://b.com'); + expect(result).toContain(' { + const result = makeLinks('https://example.com/search?q=test&page=1'); + expect(result).toContain(' { + const result = makeLinks('https://example.com/page#section'); + expect(result).toContain(' { + const result = makeLinks('https://example.com/path/to/resource'); + expect(result).toContain(' { + const result = makeLinks('(https://example.com)'); + expect(result).toContain(' { diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 75a21c45ddc..339ebad3617 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -39,9 +39,9 @@ import { MetadataMap } from '../../core/shared/metadata.models'; import { fadeInOut } from '../../shared/animations/fade'; import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { hasValue } from '../../shared/empty.util'; -import { makeLinks } from '../../shared/utils/make-links'; import { ErrorComponent } from '../../shared/error/error.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; +import { makeLinks } from '../../shared/utils/make-links'; import { VarDirective } from '../../shared/utils/var.directive'; import { CollectionsComponent } from '../field-components/collections/collections.component'; import { ThemedItemPageTitleFieldComponent } from '../simple/field-components/specific-field/title/themed-item-page-field.component'; diff --git a/src/app/shared/utils/make-links.ts b/src/app/shared/utils/make-links.ts index 5584bf93b6f..7bbddb8318f 100644 --- a/src/app/shared/utils/make-links.ts +++ b/src/app/shared/utils/make-links.ts @@ -6,5 +6,8 @@ */ export function makeLinks(text: string): string { const regex = /(?:https?|ftp):\/\/[^\s)]+|www\.[^\s)]+/g; - return text?.replace(regex, (url) => `${url}`); + return text?.replace(regex, (url) => { + const href = url.startsWith('www.') ? `https://${url}` : url; + return `${url}`; + }); } From 1bc84a21df664caf2dafc9b5d39722d8296f0c1c Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 20 Mar 2026 14:43:14 +0100 Subject: [PATCH 08/10] feat: add field-specific hyperlinks for metadata values in full item page --- .../full/full-item-page.component.html | 10 +- .../full/full-item-page.component.spec.ts | 157 +++++++++++++++++- .../full/full-item-page.component.ts | 6 +- src/app/shared/utils/make-links.ts | 48 ++++++ 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index fad5925c78d..d667a8f3e9c 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -25,7 +25,15 @@ {{mdEntry.key}} - + @if (getMetadataLink(mdEntry.key, mdValue.value); as link) { + @if (link.external) { + {{mdValue.value}} + } @else { + {{mdValue.value}} + } + } @else { + + } {{mdValue.language}} diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 3df9bd991f4..9e86b70678c 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -43,7 +43,10 @@ import { import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { ThemeService } from '../../shared/theme-support/theme.service'; -import { makeLinks } from '../../shared/utils/make-links'; +import { + getMetadataLink, + makeLinks, +} from '../../shared/utils/make-links'; import { TruncatePipe } from '../../shared/utils/truncate.pipe'; import { VarDirective } from '../../shared/utils/var.directive'; import { CollectionsComponent } from '../field-components/collections/collections.component'; @@ -81,7 +84,7 @@ const mockItemWithUrl: Item = Object.assign(new Item(), { value: 'https://hdl.handle.net/123456789/1', }, ], - 'dc.subject': [ + 'dc.description': [ { language: 'en_US', value: 'plain text value', @@ -90,6 +93,48 @@ const mockItemWithUrl: Item = Object.assign(new Item(), { }, }); +const mockItemWithSpecialFields: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'test item', + }, + ], + 'local.identifier.doi': [ + { + language: null, + value: '10.1234/test', + }, + ], + 'local.identifier.scopus': [ + { + language: null, + value: '2-s2.0-85012345678', + }, + ], + 'local.identifier.wos': [ + { + language: null, + value: 'WOS:000123456789', + }, + ], + 'dc.subject': [ + { + language: 'en_US', + value: 'Mathematics', + }, + ], + 'dc.contributor.author': [ + { + language: null, + value: 'Novák, Jan', + }, + ], + }, +}); + const mockWithdrawnItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), metadata: [], @@ -406,4 +451,112 @@ describe('FullItemPageComponent', () => { expect(plainTextLink).toBeFalsy(); }); }); + + describe('getMetadataLink', () => { + it('should return DOI resolver link for bare DOI', () => { + const link = getMetadataLink('local.identifier.doi', '10.1234/test'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://doi.org/10.1234%2Ftest'); + }); + + it('should return null for DOI that is already a full URL', () => { + expect(getMetadataLink('local.identifier.doi', 'https://doi.org/10.1234/test')).toBeNull(); + }); + + it('should return Scopus link for Scopus ID', () => { + const link = getMetadataLink('local.identifier.scopus', '2-s2.0-85012345678'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://www.scopus.com/record/display.uri?eid=2-s2.0-85012345678'); + }); + + it('should return WOS link for WOS ID', () => { + const link = getMetadataLink('local.identifier.wos', 'WOS:000123456789'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://www.webofscience.com/wos/woscc/full-record/WOS%3A000123456789'); + }); + + it('should return internal search link for dc.subject', () => { + const link = getMetadataLink('dc.subject', 'Mathematics'); + expect(link).toBeTruthy(); + expect(link.external).toBeFalse(); + expect(link.routerLink).toBe('/search'); + expect(link.queryParams).toEqual({ 'f.subject': 'Mathematics,equals' }); + }); + + it('should return internal search link for dc.contributor.author', () => { + const link = getMetadataLink('dc.contributor.author', 'Novák, Jan'); + expect(link).toBeTruthy(); + expect(link.external).toBeFalse(); + expect(link.routerLink).toBe('/search'); + expect(link.queryParams).toEqual({ 'f.author': 'Nov\u00e1k, Jan,equals' }); + }); + + it('should return null for non-special metadata fields', () => { + expect(getMetadataLink('dc.title', 'some title')).toBeNull(); + expect(getMetadataLink('dc.description', 'some description')).toBeNull(); + }); + + it('should return null for empty or null values', () => { + expect(getMetadataLink('local.identifier.doi', '')).toBeNull(); + expect(getMetadataLink('local.identifier.doi', null)).toBeNull(); + expect(getMetadataLink('local.identifier.doi', undefined)).toBeNull(); + }); + + it('should trim whitespace from values', () => { + const link = getMetadataLink('local.identifier.doi', ' 10.1234/test '); + expect(link.href).toBe('https://doi.org/10.1234%2Ftest'); + }); + }); + + describe('field-specific metadata link rendering', () => { + beforeEach(() => { + routeData.dso = createSuccessfulRemoteDataObject(mockItemWithSpecialFields); + comp.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render bare DOI as external link to doi.org', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const doiLink = links.find(l => l.nativeElement.textContent.includes('10.1234/test')); + expect(doiLink).toBeTruthy(); + expect(doiLink.nativeElement.getAttribute('href')).toContain('https://doi.org/'); + expect(doiLink.nativeElement.getAttribute('target')).toBe('_blank'); + expect(doiLink.nativeElement.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('should render Scopus ID as external link', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const scopusLink = links.find(l => l.nativeElement.textContent.includes('2-s2.0-85012345678')); + expect(scopusLink).toBeTruthy(); + expect(scopusLink.nativeElement.getAttribute('href')).toContain('scopus.com'); + expect(scopusLink.nativeElement.getAttribute('target')).toBe('_blank'); + }); + + it('should render WOS ID as external link', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const wosLink = links.find(l => l.nativeElement.textContent.includes('WOS:000123456789')); + expect(wosLink).toBeTruthy(); + expect(wosLink.nativeElement.getAttribute('href')).toContain('webofscience.com'); + expect(wosLink.nativeElement.getAttribute('target')).toBe('_blank'); + }); + + it('should render dc.subject as internal search link', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const subjectLink = links.find(l => l.nativeElement.textContent.includes('Mathematics')); + expect(subjectLink).toBeTruthy(); + expect(subjectLink.nativeElement.getAttribute('href')).toContain('/search'); + expect(subjectLink.nativeElement.getAttribute('target')).toBeNull(); + }); + + it('should render dc.contributor.author as internal search link', () => { + const links = fixture.debugElement.queryAll(By.css('table a')); + const authorLink = links.find(l => l.nativeElement.textContent.trim().includes('Nov')); + expect(authorLink).toBeTruthy(); + expect(authorLink.nativeElement.getAttribute('href')).toContain('/search'); + expect(authorLink.nativeElement.getAttribute('target')).toBeNull(); + }); + }); }); diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 339ebad3617..2b7da8df65a 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -41,7 +41,10 @@ import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-ed import { hasValue } from '../../shared/empty.util'; import { ErrorComponent } from '../../shared/error/error.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; -import { makeLinks } from '../../shared/utils/make-links'; +import { + getMetadataLink, + makeLinks, +} from '../../shared/utils/make-links'; import { VarDirective } from '../../shared/utils/var.directive'; import { CollectionsComponent } from '../field-components/collections/collections.component'; import { ThemedItemPageTitleFieldComponent } from '../simple/field-components/specific-field/title/themed-item-page-field.component'; @@ -80,6 +83,7 @@ import { ThemedFullFileSectionComponent } from './field-components/file-section/ }) export class FullItemPageComponent extends ItemPageComponent implements OnInit, OnDestroy { protected readonly makeLinks = makeLinks; + protected readonly getMetadataLink = getMetadataLink; itemRD$: BehaviorSubject>; diff --git a/src/app/shared/utils/make-links.ts b/src/app/shared/utils/make-links.ts index 7bbddb8318f..e99835a9417 100644 --- a/src/app/shared/utils/make-links.ts +++ b/src/app/shared/utils/make-links.ts @@ -11,3 +11,51 @@ export function makeLinks(text: string): string { return `${url}`; }); } + +/** + * Metadata link descriptor returned by getMetadataLink(). + */ +export interface MetadataLink { + external: boolean; + /** Full URL for external links */ + href?: string; + /** Router path for internal links */ + routerLink?: string; + /** Query parameters for internal links */ + queryParams?: Record; +} + +/** + * For specific metadata fields, build an appropriate hyperlink. + * Returns null when the field needs no special treatment (fall back to makeLinks). + */ +export function getMetadataLink(key: string, value: string): MetadataLink | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + switch (key) { + case 'local.identifier.doi': + // If the DOI value is already a full URL, let makeLinks handle it + if (/^https?:\/\//i.test(trimmed)) { + return null; + } + return { external: true, href: `https://doi.org/${encodeURIComponent(trimmed)}` }; + + case 'local.identifier.scopus': + return { external: true, href: `https://www.scopus.com/record/display.uri?eid=${encodeURIComponent(trimmed)}` }; + + case 'local.identifier.wos': + return { external: true, href: `https://www.webofscience.com/wos/woscc/full-record/${encodeURIComponent(trimmed)}` }; + + case 'dc.subject': + return { external: false, routerLink: '/search', queryParams: { 'f.subject': `${trimmed},equals` } }; + + case 'dc.contributor.author': + return { external: false, routerLink: '/search', queryParams: { 'f.author': `${trimmed},equals` } }; + + default: + return null; + } +} From e6ab964712e2c58c158994eb169ad394011d9d34 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 20 Mar 2026 15:32:45 +0100 Subject: [PATCH 09/10] Used constants instead of hardcoded values --- src/app/shared/utils/make-links.ts | 60 ++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/src/app/shared/utils/make-links.ts b/src/app/shared/utils/make-links.ts index e99835a9417..654b5015ee0 100644 --- a/src/app/shared/utils/make-links.ts +++ b/src/app/shared/utils/make-links.ts @@ -25,6 +25,29 @@ export interface MetadataLink { queryParams?: Record; } +// --- External resolver base URLs --- +const DOI_RESOLVER = 'https://doi.org/'; +const SCOPUS_RECORD = 'https://www.scopus.com/record/display.uri?eid='; +const WOS_RECORD = 'https://www.webofscience.com/wos/woscc/full-record/'; + +// --- Internal search configuration --- +const SEARCH_PATH = '/search'; +const SEARCH_FILTER_SUFFIX = ',equals'; + +/** Maps metadata keys to their corresponding search filter parameter. */ +const SEARCH_FIELD_FILTERS: Record = { + 'dc.subject': 'f.subject', + 'dc.contributor.author': 'f.author', +}; + +/** Maps metadata keys to their external resolver base URL. */ +const EXTERNAL_RESOLVERS: Record = { + 'local.identifier.scopus': SCOPUS_RECORD, + 'local.identifier.wos': WOS_RECORD, +}; + +const HTTP_URL_PATTERN = /^https?:\/\//i; + /** * For specific metadata fields, build an appropriate hyperlink. * Returns null when the field needs no special treatment (fall back to makeLinks). @@ -35,27 +58,24 @@ export function getMetadataLink(key: string, value: string): MetadataLink | null return null; } - switch (key) { - case 'local.identifier.doi': - // If the DOI value is already a full URL, let makeLinks handle it - if (/^https?:\/\//i.test(trimmed)) { - return null; - } - return { external: true, href: `https://doi.org/${encodeURIComponent(trimmed)}` }; - - case 'local.identifier.scopus': - return { external: true, href: `https://www.scopus.com/record/display.uri?eid=${encodeURIComponent(trimmed)}` }; - - case 'local.identifier.wos': - return { external: true, href: `https://www.webofscience.com/wos/woscc/full-record/${encodeURIComponent(trimmed)}` }; - - case 'dc.subject': - return { external: false, routerLink: '/search', queryParams: { 'f.subject': `${trimmed},equals` } }; + // DOI: bare identifiers → doi.org resolver; full URLs fall through to makeLinks + if (key === 'local.identifier.doi') { + return HTTP_URL_PATTERN.test(trimmed) + ? null + : { external: true, href: `${DOI_RESOLVER}${encodeURIComponent(trimmed)}` }; + } - case 'dc.contributor.author': - return { external: false, routerLink: '/search', queryParams: { 'f.author': `${trimmed},equals` } }; + // External resolvers (Scopus, WOS, …) + const resolver = EXTERNAL_RESOLVERS[key]; + if (resolver) { + return { external: true, href: `${resolver}${encodeURIComponent(trimmed)}` }; + } - default: - return null; + // Internal search links (subject, author, …) + const filterParam = SEARCH_FIELD_FILTERS[key]; + if (filterParam) { + return { external: false, routerLink: SEARCH_PATH, queryParams: { [filterParam]: `${trimmed}${SEARCH_FILTER_SUFFIX}` } }; } + + return null; } From 3d51bf3a8a437b5026357aaff3aa696ee7b531ac Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 20 Mar 2026 16:18:02 +0100 Subject: [PATCH 10/10] refactor: address code review comments on metadata hyperlinks --- .../full/full-item-page.component.spec.ts | 154 ---------------- src/app/shared/utils/make-links.spec.ts | 169 ++++++++++++++++++ src/app/shared/utils/make-links.ts | 44 ++++- 3 files changed, 206 insertions(+), 161 deletions(-) create mode 100644 src/app/shared/utils/make-links.spec.ts diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 9e86b70678c..dbc949bd2f9 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -43,10 +43,6 @@ import { import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { ThemeService } from '../../shared/theme-support/theme.service'; -import { - getMetadataLink, - makeLinks, -} from '../../shared/utils/make-links'; import { TruncatePipe } from '../../shared/utils/truncate.pipe'; import { VarDirective } from '../../shared/utils/var.directive'; import { CollectionsComponent } from '../field-components/collections/collections.component'; @@ -336,97 +332,6 @@ describe('FullItemPageComponent', () => { }); }); - describe('makeLinks', () => { - it('should convert https URLs to clickable links', () => { - expect(makeLinks('https://example.com')).toContain(' { - expect(makeLinks('http://example.com')).toContain(' { - expect(makeLinks('ftp://files.example.com/resource')).toContain(' { - const result = makeLinks('www.example.com'); - expect(result).toContain('www.example.com'); - }); - - it('should return plain text unchanged', () => { - expect(makeLinks('just some text')).toBe('just some text'); - }); - - it('should handle null/undefined gracefully', () => { - expect(makeLinks(null)).toBeUndefined(); - expect(makeLinks(undefined)).toBeUndefined(); - }); - - it('should handle empty string', () => { - expect(makeLinks('')).toBe(''); - }); - - it('should convert URLs embedded in text', () => { - const result = makeLinks('Visit https://example.com for details'); - expect(result).toContain(' { - const result = makeLinks('https://hdl.handle.net/123456789/1'); - expect(result).toContain(' { - const result = makeLinks('https://doi.org/10.1234/test'); - expect(result).toContain(' { - const result = makeLinks('javascript:alert(1)'); - expect(result).not.toContain(' { - const result = makeLinks('data:text/html,'); - expect(result).not.toContain(' { - const result = makeLinks('See https://a.com and https://b.com'); - expect(result).toContain(' { - const result = makeLinks('https://example.com/search?q=test&page=1'); - expect(result).toContain(' { - const result = makeLinks('https://example.com/page#section'); - expect(result).toContain(' { - const result = makeLinks('https://example.com/path/to/resource'); - expect(result).toContain(' { - const result = makeLinks('(https://example.com)'); - expect(result).toContain(' { beforeEach(() => { routeData.dso = createSuccessfulRemoteDataObject(mockItemWithUrl); @@ -452,65 +357,6 @@ describe('FullItemPageComponent', () => { }); }); - describe('getMetadataLink', () => { - it('should return DOI resolver link for bare DOI', () => { - const link = getMetadataLink('local.identifier.doi', '10.1234/test'); - expect(link).toBeTruthy(); - expect(link.external).toBeTrue(); - expect(link.href).toBe('https://doi.org/10.1234%2Ftest'); - }); - - it('should return null for DOI that is already a full URL', () => { - expect(getMetadataLink('local.identifier.doi', 'https://doi.org/10.1234/test')).toBeNull(); - }); - - it('should return Scopus link for Scopus ID', () => { - const link = getMetadataLink('local.identifier.scopus', '2-s2.0-85012345678'); - expect(link).toBeTruthy(); - expect(link.external).toBeTrue(); - expect(link.href).toBe('https://www.scopus.com/record/display.uri?eid=2-s2.0-85012345678'); - }); - - it('should return WOS link for WOS ID', () => { - const link = getMetadataLink('local.identifier.wos', 'WOS:000123456789'); - expect(link).toBeTruthy(); - expect(link.external).toBeTrue(); - expect(link.href).toBe('https://www.webofscience.com/wos/woscc/full-record/WOS%3A000123456789'); - }); - - it('should return internal search link for dc.subject', () => { - const link = getMetadataLink('dc.subject', 'Mathematics'); - expect(link).toBeTruthy(); - expect(link.external).toBeFalse(); - expect(link.routerLink).toBe('/search'); - expect(link.queryParams).toEqual({ 'f.subject': 'Mathematics,equals' }); - }); - - it('should return internal search link for dc.contributor.author', () => { - const link = getMetadataLink('dc.contributor.author', 'Novák, Jan'); - expect(link).toBeTruthy(); - expect(link.external).toBeFalse(); - expect(link.routerLink).toBe('/search'); - expect(link.queryParams).toEqual({ 'f.author': 'Nov\u00e1k, Jan,equals' }); - }); - - it('should return null for non-special metadata fields', () => { - expect(getMetadataLink('dc.title', 'some title')).toBeNull(); - expect(getMetadataLink('dc.description', 'some description')).toBeNull(); - }); - - it('should return null for empty or null values', () => { - expect(getMetadataLink('local.identifier.doi', '')).toBeNull(); - expect(getMetadataLink('local.identifier.doi', null)).toBeNull(); - expect(getMetadataLink('local.identifier.doi', undefined)).toBeNull(); - }); - - it('should trim whitespace from values', () => { - const link = getMetadataLink('local.identifier.doi', ' 10.1234/test '); - expect(link.href).toBe('https://doi.org/10.1234%2Ftest'); - }); - }); - describe('field-specific metadata link rendering', () => { beforeEach(() => { routeData.dso = createSuccessfulRemoteDataObject(mockItemWithSpecialFields); diff --git a/src/app/shared/utils/make-links.spec.ts b/src/app/shared/utils/make-links.spec.ts new file mode 100644 index 00000000000..14a737c889b --- /dev/null +++ b/src/app/shared/utils/make-links.spec.ts @@ -0,0 +1,169 @@ +import { + getMetadataLink, + makeLinks, +} from './make-links'; + +describe('makeLinks', () => { + it('should convert https URLs to clickable links', () => { + expect(makeLinks('https://example.com')).toContain(' { + expect(makeLinks('http://example.com')).toContain(' { + expect(makeLinks('ftp://files.example.com/resource')).toContain(' { + const result = makeLinks('www.example.com'); + expect(result).toContain('www.example.com'); + }); + + it('should return plain text unchanged (HTML-escaped)', () => { + expect(makeLinks('just some text')).toBe('just some text'); + }); + + it('should handle null/undefined gracefully', () => { + expect(makeLinks(null)).toBeUndefined(); + expect(makeLinks(undefined)).toBeUndefined(); + }); + + it('should handle empty string', () => { + expect(makeLinks('')).toBe(''); + }); + + it('should convert URLs embedded in text', () => { + const result = makeLinks('Visit https://example.com for details'); + expect(result).toContain(' { + const result = makeLinks('https://hdl.handle.net/123456789/1'); + expect(result).toContain(' { + const result = makeLinks('https://doi.org/10.1234/test'); + expect(result).toContain(' { + const result = makeLinks('javascript:alert(1)'); + expect(result).not.toContain(' { + const result = makeLinks('data:text/html,'); + expect(result).not.toContain(' { + const result = makeLinks('See https://a.com and https://b.com'); + expect(result).toContain(' { + const result = makeLinks('https://example.com/search?q=test&page=1'); + expect(result).toContain('href="https://example.com/search?q=test&page=1"'); + }); + + it('should handle URLs with fragments', () => { + const result = makeLinks('https://example.com/page#section'); + expect(result).toContain(' { + const result = makeLinks('https://example.com/path/to/resource'); + expect(result).toContain(' { + const result = makeLinks('(https://example.com)'); + expect(result).toContain(' { + const result = makeLinks('bold https://example.com'); + expect(result).toContain('<b>bold</b>'); + expect(result).toContain(' { + const result = makeLinks('A & B'); + expect(result).toBe('A & B'); + }); +}); + +describe('getMetadataLink', () => { + it('should return DOI resolver link for bare DOI', () => { + const link = getMetadataLink('local.identifier.doi', '10.1234/test'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://doi.org/10.1234%2Ftest'); + }); + + it('should return null for DOI that is already a full URL', () => { + expect(getMetadataLink('local.identifier.doi', 'https://doi.org/10.1234/test')).toBeNull(); + }); + + it('should return Scopus link for Scopus ID', () => { + const link = getMetadataLink('local.identifier.scopus', '2-s2.0-85012345678'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://www.scopus.com/record/display.uri?eid=2-s2.0-85012345678'); + }); + + it('should return WOS link for WOS ID', () => { + const link = getMetadataLink('local.identifier.wos', 'WOS:000123456789'); + expect(link).toBeTruthy(); + expect(link.external).toBeTrue(); + expect(link.href).toBe('https://www.webofscience.com/wos/woscc/full-record/WOS%3A000123456789'); + }); + + it('should return internal search link for dc.subject', () => { + const link = getMetadataLink('dc.subject', 'Mathematics'); + expect(link).toBeTruthy(); + expect(link.external).toBeFalse(); + expect(link.routerLink).toBe('/search'); + expect(link.queryParams).toEqual({ 'f.subject': 'Mathematics,equals' }); + }); + + it('should return internal search link for dc.contributor.author', () => { + const link = getMetadataLink('dc.contributor.author', 'Novák, Jan'); + expect(link).toBeTruthy(); + expect(link.external).toBeFalse(); + expect(link.routerLink).toBe('/search'); + expect(link.queryParams).toEqual({ 'f.author': 'Nov\u00e1k, Jan,equals' }); + }); + + it('should return null for non-special metadata fields', () => { + expect(getMetadataLink('dc.title', 'some title')).toBeNull(); + expect(getMetadataLink('dc.description', 'some description')).toBeNull(); + }); + + it('should return null for empty or null values', () => { + expect(getMetadataLink('local.identifier.doi', '')).toBeNull(); + expect(getMetadataLink('local.identifier.doi', null)).toBeNull(); + expect(getMetadataLink('local.identifier.doi', undefined)).toBeNull(); + }); + + it('should trim whitespace from values', () => { + const link = getMetadataLink('local.identifier.doi', ' 10.1234/test '); + expect(link.href).toBe('https://doi.org/10.1234%2Ftest'); + }); + + it('should not double-append operator if value already has one', () => { + const link = getMetadataLink('dc.subject', 'Mathematics,equals'); + expect(link.queryParams['f.subject']).toBe('Mathematics,equals'); + }); +}); diff --git a/src/app/shared/utils/make-links.ts b/src/app/shared/utils/make-links.ts index 654b5015ee0..e3fe68984ba 100644 --- a/src/app/shared/utils/make-links.ts +++ b/src/app/shared/utils/make-links.ts @@ -1,15 +1,46 @@ +import { addOperatorToFilterValue } from '../search/search.utils'; + +/** + * Escape HTML special characters so that non-URL parts of a metadata value + * are rendered as plain text when inserted via [innerHTML]. + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + /** * Convert raw text URLs into clickable HTML links. * Detects http, https, ftp URLs and www. addresses. + * Non-URL parts are HTML-escaped to prevent markup injection via [innerHTML]. * * Ported from dtq-dev clarin-shared-util.ts to be reused across components. */ -export function makeLinks(text: string): string { +export function makeLinks(text: string | null | undefined): string | undefined { + if (text == null) { + return undefined; + } const regex = /(?:https?|ftp):\/\/[^\s)]+|www\.[^\s)]+/g; - return text?.replace(regex, (url) => { + let result = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + // Escape the plain text segment before this URL + result += escapeHtml(text.slice(lastIndex, match.index)); + const url = match[0]; const href = url.startsWith('www.') ? `https://${url}` : url; - return `${url}`; - }); + result += `${escapeHtml(url)}`; + lastIndex = regex.lastIndex; + } + + // Escape the remaining plain text after the last URL + result += escapeHtml(text.slice(lastIndex)); + return result; } /** @@ -32,7 +63,6 @@ const WOS_RECORD = 'https://www.webofscience.com/wos/woscc/full-record/'; // --- Internal search configuration --- const SEARCH_PATH = '/search'; -const SEARCH_FILTER_SUFFIX = ',equals'; /** Maps metadata keys to their corresponding search filter parameter. */ const SEARCH_FIELD_FILTERS: Record = { @@ -52,7 +82,7 @@ const HTTP_URL_PATTERN = /^https?:\/\//i; * For specific metadata fields, build an appropriate hyperlink. * Returns null when the field needs no special treatment (fall back to makeLinks). */ -export function getMetadataLink(key: string, value: string): MetadataLink | null { +export function getMetadataLink(key: string, value: string | null | undefined): MetadataLink | null { const trimmed = value?.trim(); if (!trimmed) { return null; @@ -74,7 +104,7 @@ export function getMetadataLink(key: string, value: string): MetadataLink | null // Internal search links (subject, author, …) const filterParam = SEARCH_FIELD_FILTERS[key]; if (filterParam) { - return { external: false, routerLink: SEARCH_PATH, queryParams: { [filterParam]: `${trimmed}${SEARCH_FILTER_SUFFIX}` } }; + return { external: false, routerLink: SEARCH_PATH, queryParams: { [filterParam]: addOperatorToFilterValue(trimmed, 'equals') } }; } return null;