From 16090f638655e249b5a54688ccc093ee35bd5014 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 21 Jul 2026 16:18:56 +0200 Subject: [PATCH] UFAL/ROR identifiers on CLARIN display surfaces + ROR authority i18n labels (#1339, #1337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADAPT port of the dtq-dev ROR display cluster (FE side of the ROR feature): - a9fc7f538b (#1339): authority-based publisher search links + ROR icon on the CLARIN item page untyped field and the search-result card. - 0ac3e53c8e (#1337): form.other-information.ror-id / .location i18n labels. Runtime: - clarin-shared-util.ts: new buildAuthoritySearchFilter(searchType, mdValue) centralises the authority-vs-equals operator logic; loadItemAuthors refactored onto it (behaviour-preserving). isEmpty added to the empty.util import. - clarin-generic-item-field: getLinkToSearch rewritten to resolve the full MetadataValue via allMetadata()[index] (so a ROR authority is used); getMetadataValue removed. HTML: ROR added inside the type==='search' anchor, guarded by mdValue.authority && fields includes dc.publisher/creativework.publisher. - clarin-item-box-view: publisherMd via allMetadata(['dc.publisher','creativework.publisher']); hasPublisherRorAuthority flag; publisherRedirectLink via the helper. HTML: [href]->[attr.href] (no transient broken link before assignBaseUrl resolves) + @if(hasPublisherRorAuthority) ROR icon. v9 adaptations: - Templates rewritten to @if (v9 is @if-migrated; fork used *ngIf); BS4 ml-1 -> BS5 ms-1. - item-box-view: did NOT drag in the fork pre-image's metadataLangToBcp47 line (that comes from the not-yet-ported a11y commit d154682a9f). - ror-icon.svg: kept the existing vanilla blob (24735df519), NOT the fork blob (add/add). - i18n: location BEFORE orcid, ror-id AFTER orcid (en) / AFTER other-names (cs), commented- English + Czech pair convention. Hand-merged into the CLARIN-modified json5. - Specs (all 3 dropped by the v9 squash) restored & adapted to standalone TestBed: clarin-shared-util.spec.ts (7 tests), clarin-generic-item-field.component.spec.ts (7 tests incl. the AC5 negative-icon rendering test: no ROR icon on dc.subject), clarin-item-box-view.component.spec.ts (10 tests, +provideRouter for the standalone routerLink). Karma: 24/24 green. lint:nobuild: 0 errors. Cluster: BE 00501a2db0 (SimpleRORAuthority) is already deployed (merged BE-3 #1381), so the feature is live-verifiable. Without an authority the FE degrades to the equals fallback. Fulfils CLARIN_V9_POST_SNAPSHOT_SYNC_ACCEPTANCE.md §5 / a9fc7f538b + 0ac3e53c8e (FE-3, Vlna 3). --- .../clarin-generic-item-field.component.html | 2 +- ...larin-generic-item-field.component.spec.ts | 125 +++++++++ .../clarin-generic-item-field.component.ts | 38 +-- .../clarin-item-box-view.component.html | 2 +- .../clarin-item-box-view.component.spec.ts | 246 ++++++++++++++++++ .../clarin-item-box-view.component.ts | 14 +- src/app/shared/clarin-shared-util.spec.ts | 42 +++ src/app/shared/clarin-shared-util.ts | 28 +- src/assets/i18n/cs.json5 | 6 + src/assets/i18n/en.json5 | 4 + 10 files changed, 464 insertions(+), 43 deletions(-) create mode 100644 src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.spec.ts create mode 100644 src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts create mode 100644 src/app/shared/clarin-shared-util.spec.ts diff --git a/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.html b/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.html index c83a9f7aff6..0875151e6b0 100644 --- a/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.html +++ b/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.html @@ -42,7 +42,7 @@ } @if (type === 'search') { - {{mdValue.value | dsReplace: this.replaceCharacter}} + {{mdValue.value | dsReplace: this.replaceCharacter}} @if (mdValue.authority && (fields?.includes('dc.publisher') || fields?.includes('creativework.publisher'))) {ROR ID} @if (!last) { } diff --git a/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.spec.ts b/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.spec.ts new file mode 100644 index 00000000000..5de0bd7cc8d --- /dev/null +++ b/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.spec.ts @@ -0,0 +1,125 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ClarinGenericItemFieldComponent } from './clarin-generic-item-field.component'; + +describe('ClarinGenericItemFieldComponent', () => { + let component: ClarinGenericItemFieldComponent; + let fixture: ComponentFixture; + + const configurationServiceSpy = jasmine.createSpyObj('configurationService', { + findByPropertyName: of(true), + }); + const dsoNameServiceSpy = jasmine.createSpyObj('dsoNameService', ['getName']); + + /** Build an Item carrying a single metadata value (dc.publisher by default) with the given authority. */ + function itemWith(authority: string | null, field = 'dc.publisher', value = 'ACME Press'): Item { + const item = new Item(); + item.metadata = { + [field]: [ + { + value, + authority, + confidence: authority ? 600 : -1, + place: 0, + language: null, + uuid: 'mock-uuid', + isVirtual: false, + virtualValue: null, + } as any, + ], + }; + return item; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + ClarinGenericItemFieldComponent, + ], + providers: [ + { provide: ConfigurationDataService, useValue: configurationServiceSpy }, + { provide: DSONameService, useValue: dsoNameServiceSpy }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ClarinGenericItemFieldComponent); + component = fixture.componentInstance; + // Avoid ngOnInit (detectChanges) for the isolation tests so we can exercise getLinkToSearch directly. + component.baseUrl = 'http://localhost:4000'; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('getLinkToSearch', () => { + it('uses the authority operator and key when the metadata value has an authority', () => { + component.item = itemWith('02mhbdp94'); + component.fields = ['dc.publisher']; + expect(component.getLinkToSearch(0)) + .toBe('http://localhost:4000/search?f.publisher=02mhbdp94,authority'); + }); + + it('uses the equals operator and plain value when the metadata value has no authority', () => { + component.item = itemWith(null); + component.fields = ['dc.publisher']; + expect(component.getLinkToSearch(0)) + .toBe('http://localhost:4000/search?f.publisher=ACME%20Press,equals'); + }); + + it('uses the explicitly provided value (e.g. a split subject) with the equals operator', () => { + const item = new Item(); + item.metadata = { + 'dc.subject': [ + { value: 'history;art', authority: null, confidence: -1, place: 0, language: null } as any, + ], + }; + component.item = item; + component.fields = ['dc.subject']; + expect(component.getLinkToSearch(-1, 'history')) + .toBe('http://localhost:4000/search?f.subject=history,equals'); + }); + + it('falls back to the bare search endpoint when the index is out of range', () => { + component.item = itemWith(null); + component.fields = ['dc.publisher']; + expect(component.getLinkToSearch(5)).toBe('http://localhost:4000/search'); + }); + }); + + describe('ROR icon rendering (guard scope)', () => { + it('renders the ROR icon for an authority-bearing dc.publisher search field', () => { + component.item = itemWith('02mhbdp94', 'dc.publisher'); + component.fields = ['dc.publisher']; + component.type = 'search'; + fixture.detectChanges(); + const img = fixture.debugElement.query(By.css('img.ror-icon')); + expect(img).not.toBeNull(); + expect(img.nativeElement.getAttribute('src')).toContain('ror-icon.svg'); + }); + + it('does NOT render the ROR icon for an authority-bearing NON-publisher field (dc.subject)', () => { + component.item = itemWith('some-authority', 'dc.subject', 'History'); + component.fields = ['dc.subject']; + component.type = 'search'; + fixture.detectChanges(); + const imgs = fixture.debugElement.queryAll(By.css('img')) + .filter((de) => (de.nativeElement.getAttribute('src') || '').includes('ror-icon.svg')); + expect(imgs.length).toBe(0); + }); + }); +}); diff --git a/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.ts b/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.ts index a8501523a54..724250ae7f5 100644 --- a/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.ts +++ b/src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.ts @@ -18,6 +18,7 @@ import { Item } from '../../../../core/shared/item.model'; import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; import { ClarinItemAuthorPreviewComponent } from '../../../../shared/clarin-item-author-preview/clarin-item-author-preview.component'; import { + buildAuthoritySearchFilter, convertMetadataFieldIntoSearchType, getBaseUrl, } from '../../../../shared/clarin-shared-util'; @@ -172,38 +173,17 @@ export class ClarinGenericItemFieldComponent implements OnInit { * @param index */ public getLinkToSearch(index, value = '') { - let metadataValue = 'Error: value is empty'; - if (isEmpty(value)) { - // Get metadata value from the Item's metadata field - metadataValue = this.getMetadataValue(index); - } else { - // The metadata value is passed from the parameter. - metadataValue = value; - } - const searchType = convertMetadataFieldIntoSearchType(this.fields); - return this.baseUrl + '/search?f.' + encodeURIComponent(searchType) + '=' + - encodeURIComponent(metadataValue) + ',equals'; - } - /** - * If the metadata field has more than 1 value return the value based on the index. - * @param index of the metadata value - */ - public getMetadataValue(index) { - let metadataValue = ''; - if (index === 0) { - // Return first metadata value. - return this.item.firstMetadataValue(this.fields); + // If a value is explicitly provided (e.g. a single subject from a split list), search by that plain value. + // Otherwise resolve the full MetadataValue for this index so an authority (e.g. ROR) can be used. + const mdValue = !isEmpty(value) ? { value } : this.item.allMetadata(this.fields)?.[index]; + if (!mdValue) { + // ultimate fallback (should not happen) + return this.baseUrl + '/search'; } - // The metadata field has more metadata values - get the actual one - this.item.allMetadataValues(this.fields)?.forEach((metadataValueArray, arrayIndex) => { - if (index !== arrayIndex) { - return metadataValue; - } - metadataValue = metadataValueArray; - }); - return metadataValue; + + return this.baseUrl + '/search?' + buildAuthoritySearchFilter(searchType, mdValue); } /** diff --git a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html index 21e0a32758c..9e6bdfd9801 100644 --- a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html +++ b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html @@ -22,7 +22,7 @@ (@if (itemPublisher) { - {{itemPublisher}} / + {{itemPublisher}} @if (hasPublisherRorAuthority) {ROR ID} / }{{itemDate}}) } diff --git a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts new file mode 100644 index 00000000000..bea3af672dd --- /dev/null +++ b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts @@ -0,0 +1,246 @@ +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { DomSanitizer } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideRouter } from '@angular/router'; +import { StoreModule } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { DSONameService } from 'src/app/core/breadcrumbs/dso-name.service'; +import { BundleDataService } from 'src/app/core/data/bundle-data.service'; +import { ClarinLicenseDataService } from 'src/app/core/data/clarin/clarin-license-data.service'; +import { CollectionDataService } from 'src/app/core/data/collection-data.service'; +import { ConfigurationDataService } from 'src/app/core/data/configuration-data.service'; + +import { Item } from '../../core/shared/item.model'; +import { ClarinDateService } from '../clarin-date.service'; +import { DSONameServiceMock } from '../mocks/dso-name.service.mock'; +import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; +import { ClarinItemBoxViewComponent } from './clarin-item-box-view.component'; + +describe('ClarinItemBoxViewComponent', () => { + let component: ClarinItemBoxViewComponent; + let fixture: ComponentFixture; + let sanitizerStub: DomSanitizer; + + const initialState = { + core: { auth: { loading: false } }, + }; + + const collectionDataServiceMock = jasmine.createSpyObj( + 'CollectionDataService', + ['findByHref'], + ); + + const bundleDataServiceMock = jasmine.createSpyObj('BundleDataService', [ + 'findByItemAndName', + ]); + + const configurationServiceMock = jasmine.createSpyObj( + 'ConfigurationDataService', + ['findByPropertyName'], + ); + + const clarinLicenseServiceMock = jasmine.createSpyObj( + 'ClarinLicenseDataService', + ['searchBy'], + ); + + const clarinDateServiceMock = jasmine.createSpyObj('ClarinDateService', [ + 'composeItemDate', + ]); + + beforeEach(waitForAsync(() => { + configurationServiceMock.findByPropertyName.and.returnValue(of({ values: ['http://localhost:4000'] })); + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + StoreModule.forRoot(), + ClarinItemBoxViewComponent, + ], + providers: [ + { provide: CollectionDataService, useValue: collectionDataServiceMock }, + { provide: BundleDataService, useValue: bundleDataServiceMock }, + { + provide: ConfigurationDataService, + useValue: configurationServiceMock, + }, + { + provide: ClarinLicenseDataService, + useValue: clarinLicenseServiceMock, + }, + { provide: ClarinDateService, useValue: clarinDateServiceMock }, + { provide: DSONameService, useValue: new DSONameServiceMock() }, + { provide: DomSanitizer, useValue: sanitizerStub }, + provideMockStore({ initialState }), + provideRouter([]), + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ClarinItemBoxViewComponent); + component = fixture.componentInstance; + component.baseUrl = 'http://localhost:4000'; + fixture.detectChanges(); + }); + + it('Should create', () => { + expect(component).toBeDefined(); + }); + + describe('formateIconsAltText', () => { + it('should format camelCase item type correctly', () => { + const result = component.formateIconsAltText('researchData'); + expect(result).toBe('Research data icon'); + }); + + it('should format kebab-case item type correctly', () => { + const result = component.formateIconsAltText('research-data'); + expect(result).toBe('Research data icon'); + }); + + it('should handle single word item type', () => { + const result = component.formateIconsAltText('article'); + expect(result).toBe('Article icon'); + }); + + it('should handle empty string', () => { + const result = component.formateIconsAltText(''); + expect(result).toBe('icon'); + }); + + it('should handle empty string', () => { + const result = component.formateIconsAltText('research_data'); + expect(result).toBe('Research data icon'); + }); + }); + + it('should build publisher link with authority when authority exists', async () => { + const mockItem = new Item(); + mockItem.metadata = { + 'dc.publisher': [ + { + value: 'Test Publisher', + authority: 'test123', + confidence: 600, + place: 0, + language: null, + uuid: 'mock-uuid-1', + isVirtual: false, + virtualValue: null, + } as any, + ], + }; + component.object = mockItem; + component.isSearchResult = true; + spyOn(component, 'assignBaseUrl').and.returnValue(Promise.resolve()); + spyOn(component as any, 'getItemCommunity').and.stub(); + spyOn(component as any, 'getItemFilesSize').and.stub(); + spyOn(component as any, 'loadItemLicense').and.stub(); + await component.ngOnInit(); + fixture.detectChanges(); + expect(component.publisherRedirectLink).toContain('f.publisher=test123,authority'); + expect(component.hasPublisherRorAuthority).toBeTrue(); + }); + + it('should build publisher link with equals when no authority', async () => { + const mockItem = new Item(); + mockItem.metadata = { + 'dc.publisher': [ + { + value: 'Test Publisher', + authority: null, + confidence: -1, + place: 0, + language: null, + uuid: 'mock-uuid-2', + isVirtual: false, + virtualValue: null, + } as any, + ], + }; + component.object = mockItem; + component.isSearchResult = true; + spyOn(component, 'assignBaseUrl').and.returnValue(Promise.resolve()); + spyOn(component as any, 'getItemCommunity').and.stub(); + spyOn(component as any, 'getItemFilesSize').and.stub(); + spyOn(component as any, 'loadItemLicense').and.stub(); + await component.ngOnInit(); + fixture.detectChanges(); + expect(component.publisherRedirectLink).toContain('f.publisher=Test%20Publisher,equals'); + expect(component.hasPublisherRorAuthority).toBeFalse(); + }); + + it('should show ROR icon when hasPublisherRorAuthority is true', async () => { + const mockItem = new Item(); + mockItem.metadata = { + 'dc.publisher': [ + { + value: 'Test Publisher', + authority: 'test123', + confidence: 600, + place: 0, + language: null, + uuid: 'mock-uuid-1', + isVirtual: false, + virtualValue: null, + } as any, + ], + }; + component.object = mockItem; + component.isSearchResult = true; + spyOn(component, 'assignBaseUrl').and.returnValue(Promise.resolve()); + spyOn(component as any, 'getItemCommunity').and.stub(); + spyOn(component as any, 'getItemFilesSize').and.stub(); + spyOn(component as any, 'loadItemLicense').and.stub(); + await component.ngOnInit(); + fixture.detectChanges(); + const compiled = fixture.nativeElement; + const icon = compiled.querySelector('img[src*="ror-icon.svg"]'); + expect(icon).toBeTruthy(); + expect(icon.src).toContain('ror-icon.svg'); + }); + + it('should hide ROR icon when hasPublisherRorAuthority is false', async () => { + const mockItem = new Item(); + mockItem.metadata = { + 'dc.publisher': [ + { + value: 'Test Publisher', + authority: null, + confidence: -1, + place: 0, + language: null, + uuid: 'mock-uuid-2', + isVirtual: false, + virtualValue: null, + } as any, + ], + }; + component.object = mockItem; + component.isSearchResult = true; + spyOn(component, 'assignBaseUrl').and.returnValue(Promise.resolve()); + spyOn(component as any, 'getItemCommunity').and.stub(); + spyOn(component as any, 'getItemFilesSize').and.stub(); + spyOn(component as any, 'loadItemLicense').and.stub(); + await component.ngOnInit(); + fixture.detectChanges(); + const compiled = fixture.nativeElement; + const icon = compiled.querySelector('img[src*="ror-icon.svg"]'); + expect(icon).toBeFalsy(); + }); +}); diff --git a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts index c2a30d0416c..bd296fc40b1 100644 --- a/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts +++ b/src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts @@ -42,6 +42,7 @@ import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; import { ClarinDateService } from '../clarin-date.service'; import { ClarinItemAuthorPreviewComponent } from '../clarin-item-author-preview/clarin-item-author-preview.component'; import { + buildAuthoritySearchFilter, getBaseUrl, secureImageData, } from '../clarin-shared-util'; @@ -135,6 +136,10 @@ export class ClarinItemBoxViewComponent implements OnInit { * Redirect the user after clicking on the Publisher link. */ publisherRedirectLink: string; + /** + * Whether the publisher has an authority (e.g., ROR ID) + */ + hasPublisherRorAuthority = false; /** * Composed date of the Item. */ @@ -178,12 +183,15 @@ export class ClarinItemBoxViewComponent implements OnInit { this.itemName = this.item?.firstMetadataValue('dc.title'); this.itemUri = getItemPageRoute(this.item); this.itemDescription = this.item?.firstMetadataValue('dc.description'); - this.itemPublisher = this.item?.firstMetadataValue('dc.publisher'); + const publisherMd = this.item?.allMetadata(['dc.publisher', 'creativework.publisher'])?.[0]; + this.hasPublisherRorAuthority = !!publisherMd?.authority; + this.itemPublisher = publisherMd?.value; this.itemDate = this.clarinDateService.composeItemDate(this.item); await this.assignBaseUrl(); - this.publisherRedirectLink = this.getSearchEndpoint() + '?f.publisher=' + encodeURIComponent(this.itemPublisher) - + ',equals'; + if (publisherMd) { + this.publisherRedirectLink = this.getSearchEndpoint() + '?' + buildAuthoritySearchFilter('publisher', publisherMd); + } this.getItemCommunity(); this.loadItemLicense(); this.getItemFilesSize(); diff --git a/src/app/shared/clarin-shared-util.spec.ts b/src/app/shared/clarin-shared-util.spec.ts new file mode 100644 index 00000000000..c956791aed6 --- /dev/null +++ b/src/app/shared/clarin-shared-util.spec.ts @@ -0,0 +1,42 @@ +import { + buildAuthoritySearchFilter, + convertMetadataFieldIntoSearchType, +} from './clarin-shared-util'; + +describe('clarin-shared-util', () => { + describe('buildAuthoritySearchFilter', () => { + it('uses the authority operator and key when an authority is present', () => { + expect(buildAuthoritySearchFilter('publisher', { value: 'ACME Press', authority: '02mhbdp94' })) + .toBe('f.publisher=02mhbdp94,authority'); + }); + + it('uses the equals operator and value when no authority is present', () => { + expect(buildAuthoritySearchFilter('publisher', { value: 'ACME Press', authority: null })) + .toBe('f.publisher=ACME%20Press,equals'); + }); + + it('treats an empty-string authority as absent', () => { + expect(buildAuthoritySearchFilter('author', { value: 'Doe, J', authority: '' })) + .toBe('f.author=Doe%2C%20J,equals'); + }); + + it('url-encodes both the filter name and the value', () => { + expect(buildAuthoritySearchFilter('publisher', { value: 'A&B', authority: null })) + .toBe('f.publisher=A%26B,equals'); + }); + }); + + describe('convertMetadataFieldIntoSearchType', () => { + it('maps dc.publisher to the publisher filter', () => { + expect(convertMetadataFieldIntoSearchType(['dc.publisher'])).toBe('publisher'); + }); + + it('maps creativework.publisher to the publisher filter', () => { + expect(convertMetadataFieldIntoSearchType(['creativework.publisher'])).toBe('publisher'); + }); + + it('maps dc.type to the type filter', () => { + expect(convertMetadataFieldIntoSearchType(['dc.type'])).toBe('type'); + }); + }); +}); diff --git a/src/app/shared/clarin-shared-util.ts b/src/app/shared/clarin-shared-util.ts index a512b378dc0..ad7fef77478 100644 --- a/src/app/shared/clarin-shared-util.ts +++ b/src/app/shared/clarin-shared-util.ts @@ -5,6 +5,7 @@ import { MetadataValue } from '../core/shared/metadata.models'; import { getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; import { AuthorNameLink } from './clarin-item-box-view/clarin-author-name-link.model'; import { + isEmpty, isNull, isUndefined, } from './empty.util'; @@ -56,6 +57,23 @@ export function convertMetadataFieldIntoSearchType(field: string[]) { * @param baseUrl e.g. localhost:8080 * @param fields metadata fields where authors are stored */ +/** + * Build the discovery search filter fragment for a metadata value's search link. + * When the value carries an authority (e.g. a ROR ID for a publisher or an ORCID for an author), + * the `authority` operator is used with the authority key; otherwise the plain `equals` operator is + * used with the text value. + * + * @param searchType discovery filter name (e.g. `author`, `publisher`), see {@link convertMetadataFieldIntoSearchType} + * @param mdValue metadata value providing the `authority` (preferred) or text `value` + * @returns query fragment, e.g. `f.publisher=02mhbdp94,authority` or `f.publisher=ACME,equals` + */ +export function buildAuthoritySearchFilter(searchType: string, mdValue: { authority?: string | null; value?: string }): string { + const hasAuthority = !isEmpty(mdValue?.authority); + const filterValue = hasAuthority ? mdValue.authority : mdValue?.value; + const operator = hasAuthority ? 'authority' : 'equals'; + return 'f.' + encodeURIComponent(searchType) + '=' + encodeURIComponent(filterValue ?? '') + ',' + operator; +} + export function loadItemAuthors(item, itemAuthors, baseUrl, fields) { if (isNull(item) || isNull(itemAuthors) || isNull(baseUrl)) { return; @@ -67,15 +85,7 @@ export function loadItemAuthors(item, itemAuthors, baseUrl, fields) { } const itemAuthorsLocal = []; authorsMV.forEach((authorMV: MetadataValue) => { - let value: string, operator: string; - if (authorMV.authority) { - value = encodeURIComponent(authorMV.authority); - operator = 'authority'; - } else { - value = encodeURIComponent(authorMV.value); - operator = 'equals'; - } - const authorSearchLink = baseUrl + '/search?f.author=' + value + ',' + operator; + const authorSearchLink = baseUrl + '/search?' + buildAuthoritySearchFilter('author', authorMV); const authorNameLink = Object.assign(new AuthorNameLink(), { name: authorMV.value, url: authorSearchLink, diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index f1bb3ca7dec..539e2f197f8 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -3548,12 +3548,18 @@ // "form.other-information.last-name": "Last Name", "form.other-information.last-name": "Příjmení", + // "form.other-information.location": "Location", + "form.other-information.location": "Místo", + // "form.other-information.orcid": "ORCID", "form.other-information.orcid": "ORCID", // "form.other-information.other-names": "Other Names", "form.other-information.other-names": "Další jména", + // "form.other-information.ror-id": "ROR ID", + "form.other-information.ror-id": "ROR ID", + // "form.remove": "Remove", "form.remove": "Odstranit", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 5d13a13e063..6c31e342baf 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2091,8 +2091,12 @@ "form.other-information.last-name": "Last Name", + "form.other-information.location": "Location", + "form.other-information.orcid": "ORCID", + "form.other-information.ror-id": "ROR ID", + "form.remove": "Remove", "form.save": "Save",