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 @@ -51,6 +51,9 @@
[fields]="['dc.title']"
[label]="'person.page.name'">
</ds-generic-item-page-field>
<ds-item-page-orcid-field
[item]="object">
</ds-item-page-orcid-field>
<div>
<a class="btn btn-outline-primary" [routerLink]="[itemPageRoute + '/full']" role="button" tabindex="0">
{{"item.page.link.full" | translate}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TranslateModule } from '@ngx-translate/core';

import { ViewMode } from '../../../../core/shared/view-mode.model';
import { GenericItemPageFieldComponent } from '../../../../item-page/simple/field-components/specific-field/generic/generic-item-page-field.component';
import { ItemPageOrcidFieldComponent } from '../../../../item-page/simple/field-components/specific-field/orcid/item-page-orcid-field.component';
import { ThemedItemPageTitleFieldComponent } from '../../../../item-page/simple/field-components/specific-field/title/themed-item-page-field.component';
import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
import { TabbedRelatedEntitiesSearchComponent } from '../../../../item-page/simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component';
Expand All @@ -25,6 +26,7 @@ import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail
AsyncPipe,
DsoEditMenuComponent,
GenericItemPageFieldComponent,
ItemPageOrcidFieldComponent,
MetadataFieldWrapperComponent,
RelatedItemsComponent,
RouterLink,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@if (hasOrcidMetadata) {
<div class="item-page-field mb-2">
<label class="font-weight-bold">{{ label | translate }}</label>
<a [href]="orcidUrl$ | async" target="_blank" rel="noopener noreferrer" class="d-flex align-items-center">
<img [src]="img.URI" [alt]="img.alt | translate" class="orcid-icon" />
<span>{{ orcidId }}</span>
</a>
</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.orcid-icon {
height: var(--ds-orcid-icon-height, 16px);
margin-right: 8px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { APP_CONFIG } from 'src/config/app-config.interface';

import { BrowseService } from '../../../../../core/browse/browse.service';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service';
import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model';
import { Item } from '../../../../../core/shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
import { ItemPageOrcidFieldComponent } from './item-page-orcid-field.component';

describe('ItemPageOrcidFieldComponent', () => {
let component: ItemPageOrcidFieldComponent;
let fixture: ComponentFixture<ItemPageOrcidFieldComponent>;
let configurationService: jasmine.SpyObj<ConfigurationDataService>;

const mockItem = Object.assign(new Item(), {
metadata: {
'person.identifier.orcid': [
{
value: '0000-0002-1825-0097',
language: null,
authority: null,
confidence: -1,
place: 0,
},
],
},
});

const mockConfigProperty = Object.assign(new ConfigurationProperty(), {
name: 'orcid.domain-url',
values: ['https://sandbox.orcid.org'],
});

const mockAppConfig = {
ui: {
ssl: false,
host: 'localhost',
port: 4000,
nameSpace: '/',
},
markdown: {
enabled: false,
mathjax: false,
},
};

beforeEach(async () => {
configurationService = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']);
configurationService.findByPropertyName.and.returnValue(
createSuccessfulRemoteDataObject$(mockConfigProperty),
);

const browseDefinitionDataServiceStub = {
findAll: jasmine.createSpy('findAll').and.returnValue(of({})),
getBrowseDefinitions: jasmine.createSpy('getBrowseDefinitions').and.returnValue(of([])),
};

const browseServiceStub = {
getBrowseEntriesFor: jasmine.createSpy('getBrowseEntriesFor').and.returnValue(of({})),
getBrowseDefinitions: jasmine.createSpy('getBrowseDefinitions').and.returnValue(of([])),
};

await TestBed.configureTestingModule({
imports: [
ItemPageOrcidFieldComponent,
TranslateModule.forRoot(),
],
providers: [
{ provide: ConfigurationDataService, useValue: configurationService },
{ provide: BrowseDefinitionDataService, useValue: browseDefinitionDataServiceStub },
{ provide: BrowseService, useValue: browseServiceStub },
{ provide: APP_CONFIG, useValue: mockAppConfig },
],
schemas: [NO_ERRORS_SCHEMA],
})
.compileComponents();

fixture = TestBed.createComponent(ItemPageOrcidFieldComponent);
component = fixture.componentInstance;
component.item = mockItem;
});

it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});

it('should check if item has ORCID', () => {
expect(component.hasOrcid()).toBe(true);
});

it('should return false when item has no ORCID', () => {
component.item = Object.assign(new Item(), { metadata: {} });
expect(component.hasOrcid()).toBe(false);
});

it('should set hasOrcidMetadata property on init', () => {
fixture.detectChanges();
expect(component.hasOrcidMetadata).toBe(true);
});

it('should set hasOrcidMetadata to false when item has no ORCID', () => {
component.item = Object.assign(new Item(), { metadata: {} });
component.ngOnInit();
expect(component.hasOrcidMetadata).toBe(false);
});

it('should construct ORCID URL on init', (done) => {
fixture.detectChanges();

component.orcidUrl$.subscribe(url => {
expect(url).toBe('https://sandbox.orcid.org/0000-0002-1825-0097');
done();
});
});

it('should extract ORCID ID on init', () => {
fixture.detectChanges();
expect(component.orcidId).toBe('0000-0002-1825-0097');
});

it('should handle ORCID with leading slash', (done) => {
component.item = Object.assign(new Item(), {
metadata: {
'person.identifier.orcid': [
{
value: '/0000-0002-1825-0097',
language: null,
authority: null,
confidence: -1,
place: 0,
},
],
},
});

component.ngOnInit();
fixture.detectChanges();

expect(component.orcidId).toBe('0000-0002-1825-0097');

component.orcidUrl$.subscribe(url => {
expect(url).toBe('https://sandbox.orcid.org/0000-0002-1825-0097');
done();
});
});

it('should return null when item has no ORCID metadata', (done) => {
component.item = Object.assign(new Item(), { metadata: {} });
component.ngOnInit();
fixture.detectChanges();

expect(component.orcidId).toBeNull();

component.orcidUrl$.subscribe(url => {
expect(url).toBeNull();
done();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { AsyncPipe } from '@angular/common';
import {
Component,
Input,
OnInit,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import {
combineLatest,
map,
Observable,
} from 'rxjs';

import { BrowseService } from '../../../../../core/browse/browse.service';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service';
import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model';
import { Item } from '../../../../../core/shared/item.model';
import { MetadataValue } from '../../../../../core/shared/metadata.models';
import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
import { ImageField } from '../image-field';
import { ItemPageFieldComponent } from '../item-page-field.component';

@Component({
selector: 'ds-item-page-orcid-field',
templateUrl: './item-page-orcid-field.component.html',
styleUrls: ['./item-page-orcid-field.component.scss'],
standalone: true,
imports: [
AsyncPipe,
TranslatePipe,
],
})
/**
* This component is used for displaying ORCID identifier as a clickable link
*/
export class ItemPageOrcidFieldComponent extends ItemPageFieldComponent implements OnInit {

/**
* The item to display metadata for
*/
@Input() item: Item;

/**
* Separator string between multiple values of the metadata fields defined
* @type {string}
*/
separator: string;

/**
* Fields (schema.element.qualifier) used to render their values.
* In this component, we want to display values for metadata 'person.identifier.orcid'
*/
fields: string[] = [
'person.identifier.orcid',
];

/**
* Label i18n key for the rendered metadata
*/
label = 'item.page.orcid-profile';

/**
* Observable for the ORCID URL from configuration
*/
baseUrl$: Observable<string>;

/**
* ORCID ID (without full URL)
*/
orcidId: string | null;

/**
* Observable for the full ORCID URL
*/
orcidUrl$: Observable<string>;

/**
* Whether the item has ORCID metadata
*/
hasOrcidMetadata: boolean;

/**
* ORCID icon configuration
*/
img: ImageField = {
URI: 'assets/images/orcid.logo.icon.svg',
alt: 'item.page.orcid-icon',
heightVar: '--ds-orcid-icon-height',
};

/**
* Creates an instance of ItemPageOrcidFieldComponent.
*
* @param {BrowseDefinitionDataService} browseDefinitionDataService - Service for managing browse definitions
* @param {BrowseService} browseService - Service for browse functionality
* @param {ConfigurationDataService} configurationService - Service for accessing configuration properties
*/
constructor(
protected browseDefinitionDataService: BrowseDefinitionDataService,
protected browseService: BrowseService,
protected configurationService: ConfigurationDataService,
) {
super(browseDefinitionDataService, browseService);
}

/**
* Initializes the component and sets up observables for ORCID URL.
* Separates the display value (ORCID ID) from the link URL.
*
* @returns {void}
*/
ngOnInit(): void {

this.hasOrcidMetadata = this.hasOrcid();

this.baseUrl$ = this.configurationService
.findByPropertyName('orcid.domain-url')
.pipe(
getFirstSucceededRemoteDataPayload(),
map((property: ConfigurationProperty) =>
property?.values?.length > 0 ? property.values[0] : null,
),
);

const metadata = this.getOrcidMetadata();

this.orcidId = metadata?.value.replace(/^\//, '') || null;

this.orcidUrl$ = combineLatest([
this.baseUrl$,
]).pipe(
map(([baseUrl]) => {
if (!baseUrl || !this.orcidId) {
return null;
}

const cleanBaseUrl = baseUrl.replace(/\/$/, '');
return `${cleanBaseUrl}/${this.orcidId}`;
}),
);
}

/**
* Retrieves the ORCID metadata value from the item.
* Extracts the first ORCID identifier from the item's metadata fields,
* ensuring the value is not empty or whitespace only.
*
* @private
* @returns {MetadataValue | null} The ORCID metadata value if found and valid, null otherwise
*/
private getOrcidMetadata(): MetadataValue | null {
if (!this.item || !this.hasOrcid()) {
return null;
}

const metadata = this.item.findMetadataSortedByPlace('person.identifier.orcid');
return metadata.length > 0 && metadata[0].value?.trim() ? metadata[0] : null;
}

/**
* Checks whether the item has ORCID metadata associated with it.
*
* @public
* @returns {boolean} True if the item has 'person.identifier.orcid' metadata, false otherwise
*/
public hasOrcid(): boolean {
return this.item?.hasMetadata('person.identifier.orcid');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { TruncatableService } from '../../../../shared/truncatable/truncatable.s
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
import { ItemPageOrcidFieldComponent } from '../../field-components/specific-field/orcid/item-page-orcid-field.component';
import { ThemedItemPageTitleFieldComponent } from '../../field-components/specific-field/title/themed-item-page-field.component';
import { ThemedMetadataRepresentationListComponent } from '../../metadata-representation-list/themed-metadata-representation-list.component';
import { TabbedRelatedEntitiesSearchComponent } from '../../related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component';
Expand Down Expand Up @@ -200,6 +201,7 @@ export function getItemPageFieldsTest(mockItem: Item, component) {
RelatedItemsComponent,
TabbedRelatedEntitiesSearchComponent,
ThemedMetadataRepresentationListComponent,
ItemPageOrcidFieldComponent,
],
},
add: { changeDetection: ChangeDetectionStrategy.Default },
Expand Down
Loading