Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
<span *ngIf="i == (itemAuthors | async).length -1 && (itemAuthors | async).length > 1">
{{'item.view.box.author.preview.and' | translate}}
</span>
<a [href]="author.url" class="item-author">{{ author.name }}
<i *ngIf="author.isAuthority" class="fa-brands fa-orcid"> &nbsp;</i></a>
<a [href]="author.url" class="item-author">{{ author.name }}</a><a
*ngIf="author.isOrcid" [href]="author.orcidUrl" target="_blank" rel="noopener noreferrer"
class="orcid-icon-link" [title]="'item.view.box.author.preview.orcid-link.title' | translate"><i class="fa-brands fa-orcid orcid-icon"></i></a><i
*ngIf="author.isAuthority && !author.isOrcid" class="fa-brands fa-orcid"> &nbsp;</i>
</span>
</div>
<div *ngIf="(itemAuthors | async).length > 5">
<div *ngFor="let author of (itemAuthors | async); let i = index">
<span *ngIf="i == 0" ><a [href]="author.url" class="item-author">{{ author.name }}
<i *ngIf="author.isAuthority" class="fa-brands fa-orcid"> &nbsp;</i></a>; et al.</span>
<span *ngIf="i == 0" ><a [href]="author.url" class="item-author">{{ author.name }}</a><a
*ngIf="author.isOrcid" [href]="author.orcidUrl" target="_blank" rel="noopener noreferrer"
class="orcid-icon-link" [title]="'item.view.box.author.preview.orcid-link.title' | translate"><i class="fa-brands fa-orcid orcid-icon"></i></a><i
*ngIf="author.isAuthority && !author.isOrcid" class="fa-brands fa-orcid"> &nbsp;</i>; et al.</span>
</div>
<div class="item-author-wrapper">
<span (click)="toggleShowEveryAuthor()" class="clarin-font-size cursor-pointer">
Expand All @@ -27,8 +31,10 @@
<span *ngIf="i == (itemAuthors | async).length -1">
{{'item.view.box.author.preview.and' | translate}}
</span>
<a *ngIf="i > 0" [href]="author.url" class="item-author">{{author.name}}
<i *ngIf="author.isAuthority" class="fa-brands fa-orcid"> &nbsp;</i></a>
<a *ngIf="i > 0" [href]="author.url" class="item-author">{{author.name}}</a><a
*ngIf="i > 0 && author.isOrcid" [href]="author.orcidUrl" target="_blank" rel="noopener noreferrer"
class="orcid-icon-link" [title]="'item.view.box.author.preview.orcid-link.title' | translate"><i class="fa-brands fa-orcid orcid-icon"></i></a><i
*ngIf="i > 0 && author.isAuthority && !author.isOrcid" class="fa-brands fa-orcid"> &nbsp;</i>
</span>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
/**
This is a styling file for the clarin-item-author-preview component.
*/

.orcid-icon-link {
margin-left: 0.25rem;
text-decoration: none;

&:hover {
opacity: 0.8;
}
}

.orcid-icon {
color: #a6ce39;
}

Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ClarinItemAuthorPreviewComponent } from './clarin-item-author-preview.component';
import {of} from 'rxjs';
import {ConfigurationDataService} from '../../core/data/configuration-data.service';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';

describe('ClarinItemAuthorPreviewComponent', () => {
let component: ClarinItemAuthorPreviewComponent;
let fixture: ComponentFixture<ClarinItemAuthorPreviewComponent>;

const configurationServiceSpy = jasmine.createSpyObj('configurationService', {
findByPropertyName: of(true),
findByPropertyName: createSuccessfulRemoteDataObject$({ values: ['https://orcid.org'] }),
});

beforeEach(async () => {
Expand All @@ -31,4 +31,11 @@ describe('ClarinItemAuthorPreviewComponent', () => {
it('should create', () => {
expect(component).toBeTruthy();
});

it('assignOrcidDomainUrl should read orcid.domain-url from backend config', async () => {
component.orcidDomainUrl = null;
await component.assignOrcidDomainUrl();
expect(component.orcidDomainUrl).toBe('https://orcid.org');
expect(configurationServiceSpy.findByPropertyName).toHaveBeenCalledWith('orcid.domain-url');
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Component, Input, OnInit } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { take } from 'rxjs/operators';
import { getBaseUrl, loadItemAuthors } from '../clarin-shared-util';
import { Item } from '../../core/shared/item.model';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { AuthorNameLink } from '../clarin-item-box-view/clarin-author-name-link.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';

@Component({
selector: 'ds-clarin-item-author-preview',
Expand Down Expand Up @@ -38,11 +40,18 @@ export class ClarinItemAuthorPreviewComponent implements OnInit {
*/
baseUrl = '';

/**
* ORCID domain URL loaded from the backend property `orcid.domain-url`.
* When unset (backend does not expose the property) bare ORCID iDs are kept as plain text.
*/
orcidDomainUrl: string | null = null;

constructor(protected configurationService: ConfigurationDataService) { }

async ngOnInit(): Promise<void> {
await this.assignBaseUrl();
loadItemAuthors(this.item, this.itemAuthors, this.baseUrl, this.fields);
await this.assignOrcidDomainUrl();
loadItemAuthors(this.item, this.itemAuthors, this.baseUrl, this.fields, this.orcidDomainUrl);
}
toggleShowEveryAuthor() {
this.showEveryAuthor.next(!this.showEveryAuthor.value);
Expand All @@ -57,4 +66,20 @@ export class ClarinItemAuthorPreviewComponent implements OnInit {
return baseUrlResponse?.values?.[0];
});
}

/**
* Load `orcid.domain-url` from the backend configuration so that bare ORCID iDs
* can be expanded into a hyperlink to the configured ORCID host.
*/
async assignOrcidDomainUrl(): Promise<void> {
this.orcidDomainUrl = await this.configurationService.findByPropertyName('orcid.domain-url').pipe(
getFirstCompletedRemoteData(),
take(1),
).toPromise().then((rd) => {
if (rd?.hasSucceeded && rd.payload?.values?.length > 0) {
return rd.payload.values[0];
}
return null;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ export class AuthorNameLink {
name: string;
url: string;
isAuthority: boolean;
isOrcid?: boolean;
orcidUrl?: string;
}
105 changes: 105 additions & 0 deletions src/app/shared/clarin-shared-util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { BehaviorSubject } from 'rxjs';

import { loadItemAuthors, ORCID_ID_PATTERN, ORCID_URL_PATTERN } from './clarin-shared-util';
import { AuthorNameLink } from './clarin-item-box-view/clarin-author-name-link.model';
import { MetadataValue } from '../core/shared/metadata.models';

function mv(value: string, authority?: string): MetadataValue {
return Object.assign(new MetadataValue(), { value, authority });
}

function itemWithAuthors(values: MetadataValue[]): any {
return {
allMetadata: () => values,
};
}

describe('clarin-shared-util ORCID helpers', () => {

describe('ORCID_ID_PATTERN', () => {
it('matches a bare ORCID iD', () => {
expect(ORCID_ID_PATTERN.test('0000-0001-2345-6789')).toBeTrue();
expect(ORCID_ID_PATTERN.test('0000-0001-2345-678X')).toBeTrue();
});

it('does not match a full URL or arbitrary text', () => {
expect(ORCID_ID_PATTERN.test('https://orcid.org/0000-0001-2345-6789')).toBeFalse();
expect(ORCID_ID_PATTERN.test('not-an-orcid')).toBeFalse();
});
});

describe('ORCID_URL_PATTERN', () => {
it('matches the production and sandbox ORCID URLs', () => {
expect(ORCID_URL_PATTERN.test('https://orcid.org/0000-0001-2345-6789')).toBeTrue();
expect(ORCID_URL_PATTERN.test('https://sandbox.orcid.org/0000-0001-2345-678X')).toBeTrue();
});

it('does not match a bare ORCID iD or non-URL strings', () => {
expect(ORCID_URL_PATTERN.test('0000-0001-2345-6789')).toBeFalse();
expect(ORCID_URL_PATTERN.test('not-an-orcid')).toBeFalse();
});
});

describe('loadItemAuthors', () => {
const baseUrl = 'http://localhost:4000';
const fields = ['dc.contributor.author'];

it('builds the search link for every author with the equals operator', () => {
const subject = new BehaviorSubject<AuthorNameLink[]>([]);
loadItemAuthors(itemWithAuthors([mv('Doe, John')]), subject, baseUrl, fields);
expect(subject.value.length).toBe(1);
expect(subject.value[0].url)
.toBe('http://localhost:4000/search?f.author=Doe%2C%20John,equals');
expect(subject.value[0].isOrcid).toBeFalse();
});

it('expands a bare ORCID iD using the orcid.domain-url from the backend', () => {
const subject = new BehaviorSubject<AuthorNameLink[]>([]);
loadItemAuthors(
itemWithAuthors([mv('Doe, John', '0000-0001-2345-6789')]),
subject, baseUrl, fields, 'https://sandbox.orcid.org',
);
expect(subject.value[0].isOrcid).toBeTrue();
expect(subject.value[0].orcidUrl).toBe('https://sandbox.orcid.org/0000-0001-2345-6789');
});

it('strips a trailing slash from the orcid.domain-url before composing the URL', () => {
const subject = new BehaviorSubject<AuthorNameLink[]>([]);
loadItemAuthors(
itemWithAuthors([mv('Doe, John', '0000-0001-2345-6789')]),
subject, baseUrl, fields, 'https://orcid.org/',
);
expect(subject.value[0].orcidUrl).toBe('https://orcid.org/0000-0001-2345-6789');
});

it('passes through an authority that already is a full ORCID URL', () => {
const subject = new BehaviorSubject<AuthorNameLink[]>([]);
loadItemAuthors(
itemWithAuthors([mv('Doe, John', 'https://orcid.org/0000-0001-2345-6789')]),
subject, baseUrl, fields, null,
);
expect(subject.value[0].isOrcid).toBeTrue();
expect(subject.value[0].orcidUrl).toBe('https://orcid.org/0000-0001-2345-6789');
});

it('does not flag a bare ORCID iD when orcid.domain-url is missing', () => {
const subject = new BehaviorSubject<AuthorNameLink[]>([]);
loadItemAuthors(
itemWithAuthors([mv('Doe, John', '0000-0001-2345-6789')]),
subject, baseUrl, fields, null,
);
expect(subject.value[0].isOrcid).toBeFalse();
expect(subject.value[0].orcidUrl).toBeUndefined();
});

it('does not flag a non-ORCID authority', () => {
const subject = new BehaviorSubject<AuthorNameLink[]>([]);
loadItemAuthors(
itemWithAuthors([mv('Smith, Jane', 'some-internal-authority')]),
subject, baseUrl, fields, 'https://orcid.org',
);
expect(subject.value[0].isOrcid).toBeFalse();
expect(subject.value[0].isAuthority).toBeTrue();
});
});
});
54 changes: 43 additions & 11 deletions src/app/shared/clarin-shared-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ import { isNull, isUndefined } from './empty.util';
import { MetadataValue } from '../core/shared/metadata.models';
import { AuthorNameLink } from './clarin-item-box-view/clarin-author-name-link.model';

/**
* Pattern that matches a bare ORCID iD (16 digits in groups of 4, last char may be X).
* Example: `0000-0001-2345-6789` or `0000-0001-2345-678X`.
*/
export const ORCID_ID_PATTERN = /^(\d{4}-){3}\d{3}[\dX]$/i;

/**
* Pattern that matches a full ORCID URL authority value. Matches any ORCID-like
* host so the backend `orcid.domain-url` (e.g. `https://orcid.org`,
* `https://sandbox.orcid.org`) drives canonicalisation, not the regex.
* Example: `https://orcid.org/0000-0001-2345-6789` or `https://sandbox.orcid.org/0000-0001-2345-678X`.
*/
export const ORCID_URL_PATTERN = /^https?:\/\/[^/]+\/((\d{4}-){3}\d{3}[\dX])$/i;

/**
* Convert raw byte array to the image is not secure - this function make it secure
* @param imageByteArray as secure byte array
Expand Down Expand Up @@ -44,15 +58,19 @@ export function convertMetadataFieldIntoSearchType(field: string[]) {
}

/**
* Load Authors of the current item into BehaviourSubject - ItemAuthors. This method also compose
* search link for every Author.
* Load Authors of the current item into BehaviourSubject - ItemAuthors. This method also composes
* the search link for every Author and, when the authority value is an ORCID iD / URL, the link
* to the ORCID profile.
*
* @param item current Item
* @param itemAuthors BehaviourSubject (async) of Authors with search links
* @param baseUrl e.g. localhost:8080
* @param baseUrl e.g. `localhost:8080`
* @param fields metadata fields where authors are stored
* @param orcidDomainUrl ORCID domain URL loaded from the backend property `orcid.domain-url`
* (e.g. `https://orcid.org` or `https://sandbox.orcid.org`). When not provided, bare ORCID
* iDs cannot be turned into hyperlinks, but full ORCID URLs are still recognised.
*/
export function loadItemAuthors(item, itemAuthors, baseUrl, fields) {
export function loadItemAuthors(item, itemAuthors, baseUrl, fields, orcidDomainUrl: string | null = null) {
if (isNull(item) || isNull(itemAuthors) || isNull(baseUrl)) {
return;
}
Expand All @@ -61,21 +79,35 @@ export function loadItemAuthors(item, itemAuthors, baseUrl, fields) {
if (isUndefined(authorsMV)) {
return null;
}
const domain = orcidDomainUrl?.endsWith('/') ? orcidDomainUrl.slice(0, -1) : orcidDomainUrl;
const itemAuthorsLocal = [];
authorsMV.forEach((authorMV: MetadataValue) => {
let value: string, operator: string;
let isOrcid = false;
let orcidUrl: string;
let searchValue: string;
let searchOperator: string;
if (authorMV.authority) {
value = encodeURIComponent(authorMV.authority);
operator = 'authority';
const authority = String(authorMV.authority).trim();
if (ORCID_URL_PATTERN.test(authority)) {
orcidUrl = authority;
isOrcid = true;
} else if (domain && ORCID_ID_PATTERN.test(authority)) {
orcidUrl = `${domain}/${authority}`;
isOrcid = true;
}
searchValue = encodeURIComponent(authorMV.authority);
searchOperator = 'authority';
} else {
value = encodeURIComponent(authorMV.value);
operator = 'equals';
searchValue = encodeURIComponent(authorMV.value);
searchOperator = 'equals';
}
const authorSearchLink = baseUrl + '/search?f.author=' + value + ',' + operator;
const authorSearchLink = baseUrl + '/search?f.author=' + searchValue + ',' + searchOperator;
const authorNameLink = Object.assign(new AuthorNameLink(), {
name: authorMV.value,
url: authorSearchLink,
isAuthority: !!authorMV.authority
isAuthority: !!authorMV.authority,
isOrcid: isOrcid,
orcidUrl: orcidUrl
});
itemAuthorsLocal.push(authorNameLink);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { MetadataRepresentationDirective } from './metadata-representation.direc
import { METADATA_REPRESENTATION_COMPONENT_FACTORY } from './metadata-representation.decorator';
import { ThemeService } from '../theme-support/theme.service';
import { PlainTextMetadataListElementComponent } from '../object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { ConfigurationDataServiceStub } from '../testing/configuration-data.service.stub';

const testType = 'TestType';
const testContext = Context.Search;
Expand Down Expand Up @@ -51,6 +53,10 @@ describe('MetadataRepresentationLoaderComponent', () => {
{
provide: ThemeService,
useValue: themeService,
},
{
provide: ConfigurationDataService,
useClass: ConfigurationDataServiceStub,
}
]
}).overrideComponent(MetadataRepresentationLoaderComponent, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,21 @@
target="_blank" [href]="mdRepresentation.getValue()">
{{mdRepresentation.getValue()}}
</a>
<span *ngIf="(mdRepresentation.representationType=='authority_controlled')" class="dont-break-out">{{mdRepresentation.getValue()}}</span>
<ng-container *ngIf="(mdRepresentation.representationType=='authority_controlled')">
<a *ngIf="isOrcidAuthority(); else plainAuthority"
class="dont-break-out orcid-author-link"
[href]="getOrcidUrl()"
target="_blank"
rel="noopener noreferrer"
[title]="'item.view.box.author.preview.orcid-link.title' | translate"
[attr.aria-label]="('item.view.box.author.preview.orcid-link.title' | translate) + ' ' + mdRepresentation.getValue()">
{{mdRepresentation.getValue()}}
<i class="fab fa-orcid orcid-icon" aria-hidden="true"></i>
</a>
<ng-template #plainAuthority>
<span class="dont-break-out">{{mdRepresentation.getValue()}}</span>
</ng-template>
</ng-container>
<a *ngIf="(mdRepresentation.representationType=='browse_link')"
class="dont-break-out ds-browse-link"
[routerLink]="['/browse/', mdRepresentation.browseDefinition.id]"
Expand Down
Loading
Loading