Skip to content

Commit 5bab4f1

Browse files
151 support for ror identifiers (#153)
* fix(item-view): use authority value for publisher search links (ROR) fix(item-view): use authority value for publisher search links (ROR) * fix(search-results): use authority value for publisher links on result cards fix(search-results): use authority value for publisher links on result cards * feat(ror): add ROR icon to publisher links on item view and search cards feat(ror): add ROR icon to publisher links on item view and search cards * fix(ror-itempage-search-results): put the image in the publisher redirect fix(ror-itempage-search-results): put the image in the publisher redirect * fix(ror-icon): add icon in the item page fix(ror-icon): add icon in the item page * fix(lint): remove redundant type annotation for hasPublisherAuthority fix(lint): remove redundant type annotation for hasPublisherAuthority * feat(ror): add ROR icon and authority-based publisher search links feat(ror): add ROR icon and authority-based publisher search links * 153 ror shared helper (#159) * refactor(ror): extract shared authority search-filter helper, drop creativework.publisher Follow-up cleanup on the ROR publisher support (PR #153): - Add buildAuthoritySearchFilter() in clarin-shared-util.ts and use it from getLinkToSearch (generic item field), the search-result box view, and loadItemAuthors, so the authority-vs-equals operator logic lives in one place. - Drop creativework.publisher from the CLARIN item view (untyped-item fields, the ROR icon condition and convertMetadataFieldIntoSearchType) so the item page and the search card agree on dc.publisher. Journal entity pages, which use the upstream ds-generic-item-page-field, are unaffected. - Add a clarin-generic-item-field spec covering getLinkToSearch branches and a unit test for the new helper. * don't remove the creativework.publisher * Avoid transient broken publisher href during async base-URL fetch Use [attr.href] instead of [href] so Angular omits the attribute while publisherRedirectLink is still undefined (before assignBaseUrl resolves), rendering the publisher as plain text until the URL is ready. --------- Co-authored-by: Ondřej Košarko <kosarko@ufal.mff.cuni.cz>
1 parent 54d86cb commit 5bab4f1

9 files changed

Lines changed: 308 additions & 46 deletions

src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
</a>
2828
</span>
2929
<span *ngIf="type === 'search'" class="d-inline-flex pr-1">
30-
<a [href]="getLinkToSearch(i)">{{mdValue.value | dsReplace: this.replaceCharacter}}</a>
30+
<a [href]="getLinkToSearch(i)">{{mdValue.value | dsReplace: this.replaceCharacter}} <img *ngIf="mdValue.authority && (fields?.includes('dc.publisher') || fields?.includes('creativework.publisher'))" src="assets/images/ror-icon.svg" alt="ROR ID" height="16" class="ror-icon ml-1 align-middle"></a>
3131
<span *ngIf="!last" [innerHTML]="separator"></span>
3232
</span>
3333
<span *ngIf="type === 'subject'" class="pr-1">
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { NO_ERRORS_SCHEMA } from '@angular/core';
3+
import { of } from 'rxjs';
4+
5+
import { ClarinGenericItemFieldComponent } from './clarin-generic-item-field.component';
6+
import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
7+
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
8+
import { Item } from '../../../../core/shared/item.model';
9+
10+
describe('ClarinGenericItemFieldComponent', () => {
11+
let component: ClarinGenericItemFieldComponent;
12+
let fixture: ComponentFixture<ClarinGenericItemFieldComponent>;
13+
14+
const configurationServiceSpy = jasmine.createSpyObj('configurationService', {
15+
findByPropertyName: of(true),
16+
});
17+
const dsoNameServiceSpy = jasmine.createSpyObj('dsoNameService', ['getName']);
18+
19+
/** Build an Item carrying a single dc.publisher value with the given authority. */
20+
function itemWithPublisher(authority: string | null): Item {
21+
const item = new Item();
22+
item.metadata = {
23+
'dc.publisher': [
24+
{
25+
value: 'ACME Press',
26+
authority,
27+
confidence: authority ? 600 : -1,
28+
place: 0,
29+
language: null,
30+
uuid: 'mock-uuid',
31+
isVirtual: false,
32+
virtualValue: null,
33+
} as any,
34+
],
35+
};
36+
return item;
37+
}
38+
39+
beforeEach(async () => {
40+
await TestBed.configureTestingModule({
41+
declarations: [ClarinGenericItemFieldComponent],
42+
providers: [
43+
{ provide: ConfigurationDataService, useValue: configurationServiceSpy },
44+
{ provide: DSONameService, useValue: dsoNameServiceSpy },
45+
],
46+
schemas: [NO_ERRORS_SCHEMA],
47+
}).compileComponents();
48+
});
49+
50+
beforeEach(() => {
51+
fixture = TestBed.createComponent(ClarinGenericItemFieldComponent);
52+
component = fixture.componentInstance;
53+
// Avoid ngOnInit (detectChanges) so we can exercise getLinkToSearch in isolation.
54+
component.baseUrl = 'http://localhost:4000';
55+
});
56+
57+
it('should create', () => {
58+
expect(component).toBeTruthy();
59+
});
60+
61+
describe('getLinkToSearch', () => {
62+
it('uses the authority operator and key when the metadata value has an authority', () => {
63+
component.item = itemWithPublisher('02mhbdp94');
64+
component.fields = ['dc.publisher'];
65+
expect(component.getLinkToSearch(0))
66+
.toBe('http://localhost:4000/search?f.publisher=02mhbdp94,authority');
67+
});
68+
69+
it('uses the equals operator and plain value when the metadata value has no authority', () => {
70+
component.item = itemWithPublisher(null);
71+
component.fields = ['dc.publisher'];
72+
expect(component.getLinkToSearch(0))
73+
.toBe('http://localhost:4000/search?f.publisher=ACME%20Press,equals');
74+
});
75+
76+
it('uses the explicitly provided value (e.g. a split subject) with the equals operator', () => {
77+
const item = new Item();
78+
item.metadata = {
79+
'dc.subject': [
80+
{ value: 'history;art', authority: null, confidence: -1, place: 0, language: null } as any,
81+
],
82+
};
83+
component.item = item;
84+
component.fields = ['dc.subject'];
85+
expect(component.getLinkToSearch(-1, 'history'))
86+
.toBe('http://localhost:4000/search?f.subject=history,equals');
87+
});
88+
89+
it('falls back to the bare search endpoint when the index is out of range', () => {
90+
component.item = itemWithPublisher(null);
91+
component.fields = ['dc.publisher'];
92+
expect(component.getLinkToSearch(5)).toBe('http://localhost:4000/search');
93+
});
94+
});
95+
});

src/app/item-page/simple/field-components/clarin-generic-item-field/clarin-generic-item-field.component.ts

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Item } from '../../../../core/shared/item.model';
33
import { isEmpty, isNotUndefined } from '../../../../shared/empty.util';
44
import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model';
55
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
6-
import { convertMetadataFieldIntoSearchType, getBaseUrl } from '../../../../shared/clarin-shared-util';
6+
import { buildAuthoritySearchFilter, convertMetadataFieldIntoSearchType, getBaseUrl } from '../../../../shared/clarin-shared-util';
77
import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
88
import { BehaviorSubject, firstValueFrom } from 'rxjs';
99
import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators';
@@ -138,38 +138,17 @@ export class ClarinGenericItemFieldComponent implements OnInit {
138138
* @param index
139139
*/
140140
public getLinkToSearch(index, value = '') {
141-
let metadataValue = 'Error: value is empty';
142-
if (isEmpty(value)) {
143-
// Get metadata value from the Item's metadata field
144-
metadataValue = this.getMetadataValue(index);
145-
} else {
146-
// The metadata value is passed from the parameter.
147-
metadataValue = value;
148-
}
149-
150141
const searchType = convertMetadataFieldIntoSearchType(this.fields);
151-
return this.baseUrl + '/search?f.' + encodeURIComponent(searchType) + '=' +
152-
encodeURIComponent(metadataValue) + ',equals';
153-
}
154142

155-
/**
156-
* If the metadata field has more than 1 value return the value based on the index.
157-
* @param index of the metadata value
158-
*/
159-
public getMetadataValue(index) {
160-
let metadataValue = '';
161-
if (index === 0) {
162-
// Return first metadata value.
163-
return this.item.firstMetadataValue(this.fields);
143+
// If a value is explicitly provided (e.g. a single subject from a split list), search by that plain value.
144+
// Otherwise resolve the full MetadataValue for this index so an authority (e.g. ROR) can be used.
145+
const mdValue = !isEmpty(value) ? { value } : this.item.allMetadata(this.fields)?.[index];
146+
if (!mdValue) {
147+
// ultimate fallback (should not happen)
148+
return this.baseUrl + '/search';
164149
}
165-
// The metadata field has more metadata values - get the actual one
166-
this.item.allMetadataValues(this.fields)?.forEach((metadataValueArray, arrayIndex) => {
167-
if (index !== arrayIndex) {
168-
return metadataValue;
169-
}
170-
metadataValue = metadataValueArray;
171-
});
172-
return metadataValue;
150+
151+
return this.baseUrl + '/search?' + buildAuthoritySearchFilter(searchType, mdValue);
173152
}
174153

175154
/**

src/app/shared/clarin-item-box-view/clarin-item-box-view.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<div><a [routerLink]="itemUri" class="item-name">{{ itemName }}</a></div>
1212
<div *ngIf="isSearchResult" class="pt-1">
1313
<div class="font-weight-bold">{{ 'item.view.box.publisher.message' | translate }}</div>
14-
<span>(<span *ngIf="itemPublisher != null"><a [href]="publisherRedirectLink">{{itemPublisher}}</a> / </span>{{itemDate}})</span>
14+
<span>(<span *ngIf="itemPublisher != null"><a [attr.href]="publisherRedirectLink">{{itemPublisher}} <img *ngIf="hasPublisherRorAuthority" src="assets/images/ror-icon.svg" alt="ROR ID" height="16" class="ror-icon ml-1 align-middle"></a> / </span>{{itemDate}})</span>
1515
</div>
1616
</div>
1717
<div class="col-2 d-flex justify-content-end">

src/app/shared/clarin-item-box-view/clarin-item-box-view.component.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { ClarinLicenseDataService } from 'src/app/core/data/clarin/clarin-licens
1414
import { ClarinDateService } from '../clarin-date.service';
1515
import { DomSanitizer } from '@angular/platform-browser';
1616
import { DSONameServiceMock } from '../mocks/dso-name.service.mock';
17+
import { Item } from '../../core/shared/item.model';
18+
import { of } from 'rxjs';
1719

1820
describe('ClarinItemBoxViewComponent', () => {
1921
let component: ClarinItemBoxViewComponent;
@@ -48,6 +50,7 @@ describe('ClarinItemBoxViewComponent', () => {
4850
]);
4951

5052
beforeEach(waitForAsync(() => {
53+
configurationServiceMock.findByPropertyName.and.returnValue(of({ values: ['http://localhost:4000'] }));
5154
TestBed.configureTestingModule({
5255
imports: [
5356
NoopAnimationsModule,
@@ -82,6 +85,7 @@ describe('ClarinItemBoxViewComponent', () => {
8285
beforeEach(() => {
8386
fixture = TestBed.createComponent(ClarinItemBoxViewComponent);
8487
component = fixture.componentInstance;
88+
component.baseUrl = 'http://localhost:4000';
8589
fixture.detectChanges();
8690
});
8791

@@ -115,4 +119,119 @@ describe('ClarinItemBoxViewComponent', () => {
115119
expect(result).toBe('Research data icon');
116120
});
117121
});
122+
123+
it('should build publisher link with authority when authority exists', async () => {
124+
const mockItem = new Item();
125+
mockItem.metadata = {
126+
'dc.publisher': [
127+
{
128+
value: 'Test Publisher',
129+
authority: 'test123',
130+
confidence: 600,
131+
place: 0,
132+
language: null,
133+
uuid: 'mock-uuid-1',
134+
isVirtual: false,
135+
virtualValue: null
136+
}
137+
]
138+
};
139+
component.object = mockItem;
140+
component.isSearchResult = true;
141+
spyOn(component, 'assignBaseUrl').and.returnValue(Promise.resolve());
142+
spyOn(component as any, 'getItemCommunity').and.stub();
143+
spyOn(component as any, 'getItemFilesSize').and.stub();
144+
spyOn(component as any, 'loadItemLicense').and.stub();
145+
await component.ngOnInit();
146+
fixture.detectChanges();
147+
expect(component.publisherRedirectLink).toContain('f.publisher=test123,authority');
148+
expect(component.hasPublisherRorAuthority).toBeTrue();
149+
});
150+
151+
it('should build publisher link with equals when no authority', async () => {
152+
const mockItem = new Item();
153+
mockItem.metadata = {
154+
'dc.publisher': [
155+
{
156+
value: 'Test Publisher',
157+
authority: null,
158+
confidence: -1,
159+
place: 0,
160+
language: null,
161+
uuid: 'mock-uuid-2',
162+
isVirtual: false,
163+
virtualValue: null
164+
}
165+
]
166+
};
167+
component.object = mockItem;
168+
component.isSearchResult = true;
169+
spyOn(component, 'assignBaseUrl').and.returnValue(Promise.resolve());
170+
spyOn(component as any, 'getItemCommunity').and.stub();
171+
spyOn(component as any, 'getItemFilesSize').and.stub();
172+
spyOn(component as any, 'loadItemLicense').and.stub();
173+
await component.ngOnInit();
174+
fixture.detectChanges();
175+
expect(component.publisherRedirectLink).toContain('f.publisher=Test%20Publisher,equals');
176+
expect(component.hasPublisherRorAuthority).toBeFalse();
177+
});
178+
179+
it('should show ROR icon when hasPublisherRorAuthority is true', async () => {
180+
const mockItem = new Item();
181+
mockItem.metadata = {
182+
'dc.publisher': [
183+
{
184+
value: 'Test Publisher',
185+
authority: 'test123',
186+
confidence: 600,
187+
place: 0,
188+
language: null,
189+
uuid: 'mock-uuid-1',
190+
isVirtual: false,
191+
virtualValue: null
192+
}
193+
]
194+
};
195+
component.object = mockItem;
196+
component.isSearchResult = true;
197+
spyOn(component, 'assignBaseUrl').and.returnValue(Promise.resolve());
198+
spyOn(component as any, 'getItemCommunity').and.stub();
199+
spyOn(component as any, 'getItemFilesSize').and.stub();
200+
spyOn(component as any, 'loadItemLicense').and.stub();
201+
await component.ngOnInit();
202+
fixture.detectChanges();
203+
const compiled = fixture.nativeElement;
204+
const icon = compiled.querySelector('img[src*="ror-icon.svg"]');
205+
expect(icon).toBeTruthy();
206+
expect(icon.src).toContain('ror-icon.svg');
207+
});
208+
209+
it('should hide ROR icon when hasPublisherRorAuthority is false', async () => {
210+
const mockItem = new Item();
211+
mockItem.metadata = {
212+
'dc.publisher': [
213+
{
214+
value: 'Test Publisher',
215+
authority: null,
216+
confidence: -1,
217+
place: 0,
218+
language: null,
219+
uuid: 'mock-uuid-2',
220+
isVirtual: false,
221+
virtualValue: null
222+
}
223+
]
224+
};
225+
component.object = mockItem;
226+
component.isSearchResult = true;
227+
spyOn(component, 'assignBaseUrl').and.returnValue(Promise.resolve());
228+
spyOn(component as any, 'getItemCommunity').and.stub();
229+
spyOn(component as any, 'getItemFilesSize').and.stub();
230+
spyOn(component as any, 'loadItemLicense').and.stub();
231+
await component.ngOnInit();
232+
fixture.detectChanges();
233+
const compiled = fixture.nativeElement;
234+
const icon = compiled.querySelector('img[src*="ror-icon.svg"]');
235+
expect(icon).toBeFalsy();
236+
});
118237
});

src/app/shared/clarin-item-box-view/clarin-item-box-view.component.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { RemoteData } from '../../core/data/remote-data';
1919
import { PaginatedList } from '../../core/data/paginated-list.model';
2020
import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model';
2121
import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service';
22-
import { getBaseUrl, secureImageData } from '../clarin-shared-util';
22+
import { buildAuthoritySearchFilter, getBaseUrl, secureImageData } from '../clarin-shared-util';
2323
import { DomSanitizer } from '@angular/platform-browser';
2424
import { BundleDataService } from '../../core/data/bundle-data.service';
2525
import { Bundle } from '../../core/shared/bundle.model';
@@ -108,6 +108,10 @@ export class ClarinItemBoxViewComponent implements OnInit {
108108
* Redirect the user after clicking on the Publisher link.
109109
*/
110110
publisherRedirectLink: string;
111+
/**
112+
* Whether the publisher has an authority (e.g., ROR ID)
113+
*/
114+
hasPublisherRorAuthority = false;
111115
/**
112116
* Composed date of the Item.
113117
*/
@@ -153,12 +157,15 @@ export class ClarinItemBoxViewComponent implements OnInit {
153157
const descMeta = this.item?.firstMetadata('dc.description');
154158
this.itemDescription = descMeta?.value || null;
155159
this.itemDescriptionLang = metadataLangToBcp47(descMeta?.language);
156-
this.itemPublisher = this.item?.firstMetadataValue('dc.publisher');
160+
const publisherMd = this.item?.allMetadata(['dc.publisher', 'creativework.publisher'])?.[0];
161+
this.hasPublisherRorAuthority = !!publisherMd?.authority;
162+
this.itemPublisher = publisherMd?.value;
157163
this.itemDate = this.clarinDateService.composeItemDate(this.item);
158164

159165
await this.assignBaseUrl();
160-
this.publisherRedirectLink = this.getSearchEndpoint() + '?f.publisher=' + encodeURIComponent(this.itemPublisher)
161-
+ ',equals';
166+
if (publisherMd) {
167+
this.publisherRedirectLink = this.getSearchEndpoint() + '?' + buildAuthoritySearchFilter('publisher', publisherMd);
168+
}
162169
this.getItemCommunity();
163170
this.loadItemLicense();
164171
this.getItemFilesSize();
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { buildAuthoritySearchFilter, convertMetadataFieldIntoSearchType } from './clarin-shared-util';
2+
3+
describe('clarin-shared-util', () => {
4+
describe('buildAuthoritySearchFilter', () => {
5+
it('uses the authority operator and key when an authority is present', () => {
6+
expect(buildAuthoritySearchFilter('publisher', { value: 'ACME Press', authority: '02mhbdp94' }))
7+
.toBe('f.publisher=02mhbdp94,authority');
8+
});
9+
10+
it('uses the equals operator and value when no authority is present', () => {
11+
expect(buildAuthoritySearchFilter('publisher', { value: 'ACME Press', authority: null }))
12+
.toBe('f.publisher=ACME%20Press,equals');
13+
});
14+
15+
it('treats an empty-string authority as absent', () => {
16+
expect(buildAuthoritySearchFilter('author', { value: 'Doe, J', authority: '' }))
17+
.toBe('f.author=Doe%2C%20J,equals');
18+
});
19+
20+
it('url-encodes both the filter name and the value', () => {
21+
expect(buildAuthoritySearchFilter('publisher', { value: 'A&B', authority: null }))
22+
.toBe('f.publisher=A%26B,equals');
23+
});
24+
});
25+
26+
describe('convertMetadataFieldIntoSearchType', () => {
27+
it('maps dc.publisher to the publisher filter', () => {
28+
expect(convertMetadataFieldIntoSearchType(['dc.publisher'])).toBe('publisher');
29+
});
30+
31+
it('maps creativework.publisher to the publisher filter', () => {
32+
expect(convertMetadataFieldIntoSearchType(['creativework.publisher'])).toBe('publisher');
33+
});
34+
35+
it('maps dc.type to the type filter', () => {
36+
expect(convertMetadataFieldIntoSearchType(['dc.type'])).toBe('type');
37+
});
38+
});
39+
});

0 commit comments

Comments
 (0)