Skip to content
Open
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 @@ -8,20 +8,41 @@
{{mdRepresentation.getValue()}}
</a>
<ng-container *ngIf="(mdRepresentation.representationType=='authority_controlled')">
<ng-container *ngIf="orcidDomainUrl$ | async as orcidDomainUrl; else noOrcidDomain">
<a *ngIf="isOrcidAuthority(orcidDomainUrl); else plainAuthority"
class="dont-break-out orcid-author-link"
[href]="getOrcidUrl(orcidDomainUrl)"
target="_blank"
rel="noopener noreferrer">
{{mdRepresentation.getValue()}}
<i class="fa-brands fa-orcid orcid-icon" aria-hidden="true"></i>
</a>
<ng-template #plainAuthority>
<span class="dont-break-out">{{mdRepresentation.getValue()}}</span>
<ng-container *ngIf="isOrcidAuthority(); else plainAuthority">
<ng-container *ngIf="(authorOrcidLinkTarget$ | async) === 'orcid'; else searchModeOrcid">
<!-- linkTarget === 'orcid': the whole author name + icon link to the ORCID profile. -->
<a 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="fa-brands fa-orcid orcid-icon" aria-hidden="true"></i>
</a>
</ng-container>
<ng-template #searchModeOrcid>
<!-- Default linkTarget === 'browse': author name navigates to the browse-by-author page;
the ORCID icon is rendered as a separate link to the ORCID profile. -->
<ng-container *ngIf="hasBrowseDefinition(); else searchModeOrcidNoBrowse">
<a class="dont-break-out ds-browse-link"
[routerLink]="['/browse/', mdRepresentation.browseDefinition.id]"
[queryParams]="getAuthorityBrowseQueryParams()">
{{mdRepresentation.getValue()}}
</a>
</ng-container>
<ng-template #searchModeOrcidNoBrowse>
<span class="dont-break-out">{{mdRepresentation.getValue()}}</span>
</ng-template>
<a class="orcid-icon-link"
[href]="getOrcidUrl()"
target="_blank"
rel="noopener noreferrer"
[title]="'item.view.box.author.preview.orcid-link.title' | translate"><i
class="fa-brands fa-orcid orcid-icon" aria-hidden="true"></i></a>
</ng-template>
</ng-container>
<ng-template #noOrcidDomain>
<ng-template #plainAuthority>
<span class="dont-break-out">{{mdRepresentation.getValue()}}</span>
</ng-template>
</ng-container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
}
}

.orcid-icon-link {
text-decoration: none;

&:hover {
opacity: 0.8;
}
}

.orcid-icon {
color: #a6ce39; // Official ORCID green color
font-size: 1em;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';

import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
Expand Down Expand Up @@ -34,9 +35,12 @@ const mockOrcidWithWhitespaceRepresentation = Object.assign(new MetadatumReprese
});

const mockConfigurationDataService = {
findByPropertyName: jasmine.createSpy('findByPropertyName').and.returnValue(
createSuccessfulRemoteDataObject$({ values: ['https://orcid.org'] }),
),
findByPropertyName: jasmine.createSpy('findByPropertyName').and.callFake((property: string) => {
if (property === 'orcid.author.link-target') {
return createSuccessfulRemoteDataObject$({ values: ['browse'] });
}
return createSuccessfulRemoteDataObject$({ values: ['https://orcid.org'] });
}),
};

describe('PlainTextMetadataListElementComponent', () => {
Expand All @@ -45,7 +49,7 @@ describe('PlainTextMetadataListElementComponent', () => {

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [],
imports: [TranslateModule.forRoot()],
declarations: [PlainTextMetadataListElementComponent],
providers: [
{ provide: ConfigurationDataService, useValue: mockConfigurationDataService },
Expand Down Expand Up @@ -74,6 +78,7 @@ describe('PlainTextMetadataListElementComponent', () => {
describe('when metadata has ORCID authority', () => {
beforeEach(() => {
comp.mdRepresentation = mockOrcidRepresentation;
comp.authorOrcidLinkTarget$.next('orcid');
fixture.detectChanges();
});

Expand All @@ -90,11 +95,11 @@ describe('PlainTextMetadataListElementComponent', () => {
});

it('isOrcidAuthority should return true', () => {
expect(comp.isOrcidAuthority(comp.orcidDomainUrl$.value)).toBeTrue();
expect(comp.isOrcidAuthority()).toBeTrue();
});

it('getOrcidUrl should return full ORCID URL', () => {
expect(comp.getOrcidUrl(comp.orcidDomainUrl$.value)).toBe('https://orcid.org/1234-5678-9012-3456');
expect(comp.getOrcidUrl()).toBe('https://orcid.org/1234-5678-9012-3456');
});
});

Expand All @@ -116,21 +121,21 @@ describe('PlainTextMetadataListElementComponent', () => {
});

it('isOrcidAuthority should return false', () => {
expect(comp.isOrcidAuthority(comp.orcidDomainUrl$.value)).toBeFalse();
expect(comp.isOrcidAuthority()).toBeFalse();
});
});

describe('getOrcidUrl with trailing slash handling', () => {
it('should not double-slash when domain URL ends with /', () => {
comp.orcidDomainUrl$.next('https://orcid.org/');
comp.mdRepresentation = mockOrcidRepresentation;
expect(comp.getOrcidUrl('https://orcid.org/')).toBe('https://orcid.org/1234-5678-9012-3456');
expect(comp.getOrcidUrl()).toBe('https://orcid.org/1234-5678-9012-3456');
});

it('should add slash when domain URL does not end with /', () => {
comp.orcidDomainUrl$.next('https://sandbox.orcid.org');
comp.mdRepresentation = mockOrcidRepresentation;
expect(comp.getOrcidUrl('https://sandbox.orcid.org')).toBe('https://sandbox.orcid.org/1234-5678-9012-3456');
expect(comp.getOrcidUrl()).toBe('https://sandbox.orcid.org/1234-5678-9012-3456');
});
});

Expand All @@ -147,25 +152,25 @@ describe('PlainTextMetadataListElementComponent', () => {
});

it('isOrcidAuthority should return false', () => {
expect(comp.isOrcidAuthority(comp.orcidDomainUrl$.value)).toBeFalse();
expect(comp.isOrcidAuthority()).toBeFalse();
});

it('getOrcidUrl should return empty string', () => {
expect(comp.getOrcidUrl(comp.orcidDomainUrl$.value)).toBe('');
expect(comp.getOrcidUrl()).toBe('');
});
});

describe('getOrcidUrl defensive behavior', () => {
it('should return empty string when orcidDomainUrl is null', () => {
comp.orcidDomainUrl$.next(null);
comp.mdRepresentation = mockOrcidRepresentation;
expect(comp.getOrcidUrl(null)).toBe('');
expect(comp.getOrcidUrl()).toBe('');
});

it('should return empty string when mdRepresentation has no authority', () => {
comp.orcidDomainUrl$.next('https://orcid.org');
comp.mdRepresentation = mockMetadataRepresentation;
expect(comp.getOrcidUrl('https://orcid.org')).toBe('');
expect(comp.getOrcidUrl()).toBe('');
});
});

Expand All @@ -177,11 +182,11 @@ describe('PlainTextMetadataListElementComponent', () => {
});

it('isOrcidAuthority should return true after trimming', () => {
expect(comp.isOrcidAuthority(comp.orcidDomainUrl$.value)).toBeTrue();
expect(comp.isOrcidAuthority()).toBeTrue();
});

it('getOrcidUrl should return trimmed ORCID URL', () => {
expect(comp.getOrcidUrl(comp.orcidDomainUrl$.value)).toBe('https://orcid.org/1234-5678-9012-3456');
expect(comp.getOrcidUrl()).toBe('https://orcid.org/1234-5678-9012-3456');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ import { BehaviorSubject } from 'rxjs';
import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model';
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
import { VALUE_LIST_BROWSE_DEFINITION } from '../../../../core/shared/value-list-browse-definition.resource-type';
import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator';
import {
AuthorOrcidLinkTarget,
buildOrcidProfileUrl,
DEFAULT_AUTHOR_ORCID_LINK_TARGET,
isOrcidAuthorityValue,
loadAuthorOrcidLinkTarget,
loadOrcidDomainUrl,
} from '../../../utils/orcid-author.util';
import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component';

@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText)
Expand All @@ -24,30 +31,23 @@ import { MetadataRepresentationListElementComponent } from '../metadata-represen
export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent implements OnInit {

/**
* Regex pattern for ORCID identifiers: four groups of four digits separated by hyphens.
* The last group may end with an X (checksum digit).
* ORCID domain URL loaded from the backend.
*/
private static readonly ORCID_PATTERN = /^\d{4}-\d{4}-\d{4}-(\d{3}X|\d{4})$/;

orcidDomainUrl$ = new BehaviorSubject<string | null>(null);

/**
* Target of the link rendered on the author name for an ORCID author. Loaded from the
* backend property `orcid.author.link-target`.
*/
authorOrcidLinkTarget$ = new BehaviorSubject<AuthorOrcidLinkTarget>(DEFAULT_AUTHOR_ORCID_LINK_TARGET);

constructor(private configurationService: ConfigurationDataService) {
super();
}

ngOnInit(): void {
this.configurationService.findByPropertyName('orcid.domain-url').pipe(
getFirstCompletedRemoteData(),
).subscribe((rd) => {
if (rd.hasFailed || !rd.hasSucceeded || !rd.payload?.values?.length) {
return;
}

const url = rd.payload.values[0]?.trim();
if (url && /^https?:\/\//i.test(url)) {
this.orcidDomainUrl$.next(url);
}
});
loadOrcidDomainUrl(this.configurationService).then((url) => this.orcidDomainUrl$.next(url));
loadAuthorOrcidLinkTarget(this.configurationService).then((t) => this.authorOrcidLinkTarget$.next(t));
}

/**
Expand All @@ -62,30 +62,46 @@ export class PlainTextMetadataListElementComponent extends MetadataRepresentatio
return queryParams;
}

isOrcidAuthority(orcidDomainUrl: string | null): boolean {
if (orcidDomainUrl === null) {
return false;
}
if (this.mdRepresentation instanceof MetadatumRepresentation) {
const authority = this.mdRepresentation.authority?.trim();
return !!authority && PlainTextMetadataListElementComponent.ORCID_PATTERN.test(authority);
}
return false;
/**
* Query parameters for the browse link of an authority-controlled value (e.g. ORCID author).
* Passes both `value` and `authority` so the browse page can call the REST endpoint with
* `filterValue` + `filterAuthority` and resolve items whose metadata is indexed by authority key.
*/
getAuthorityBrowseQueryParams() {
return {
value: this.mdRepresentation.getValue(),
authority: this.getAuthority(),
};
}

getOrcidUrl(orcidDomainUrl: string | null): string {
if (orcidDomainUrl === null) {
return '';
}
const authority = this.mdRepresentation instanceof MetadatumRepresentation
? this.mdRepresentation.authority?.trim()
: undefined;
/**
* True when the current metadatum carries a browse definition (e.g. `dc.contributor.author`),
* which means the value can be rendered as a clickable browse link.
*/
hasBrowseDefinition(): boolean {
return !!this.mdRepresentation?.browseDefinition;
}

if (!authority || !PlainTextMetadataListElementComponent.ORCID_PATTERN.test(authority)) {
return '';
}
/**
* Check whether the authority value of this metadata is an ORCID identifier.
* Accepts either a bare ORCID iD or a full ORCID URL.
*/
isOrcidAuthority(): boolean {
return isOrcidAuthorityValue(this.getAuthority(), this.orcidDomainUrl$.value);
Comment thread
Kasinhou marked this conversation as resolved.
}

const base = orcidDomainUrl.endsWith('/') ? orcidDomainUrl : orcidDomainUrl + '/';
return `${base}${authority}`;
/**
* Build the full ORCID profile URL for the current author. Returns an empty string when
* the authority is not an ORCID value or when the ORCID domain URL is required but missing.
*/
getOrcidUrl(): string {
return buildOrcidProfileUrl(this.getAuthority(), this.orcidDomainUrl$.value);
Comment thread
Kasinhou marked this conversation as resolved.
}

private getAuthority(): string | undefined {
if (this.mdRepresentation instanceof MetadatumRepresentation) {
return this.mdRepresentation.authority?.trim();
}
return undefined;
}
}
Loading
Loading