Skip to content

Commit a6b90a3

Browse files
milanmajchrakKasinhouMatus Kasak
authored
ZCU-PUB/ORCID-badge (#1293)
* MENDELU/ORCID hyperlinks (#1271) * Add orcid hyperlink to author in item view * Url orcid fetch from backend instead of hardcoded * Remove duplicate import * Fix unit test * Handled edge cases with orcid domain url * Usinng behaviour subject * Removed not wanted async property --------- Co-authored-by: Matus Kasak <matus.kasak@dataquest.sk> * MENDELU/Fixed ORCID badge (#1272) * Fixed ORCID badge --------- Co-authored-by: Matus Kasak <matus.kasak@dataquest.sk> * ZCU-PUB/Fix ORCID cherry-pick test conflict artifacts * ZCU-PUB/Reuse async ORCID domain binding in template --------- Co-authored-by: Kasinhou <129340513+Kasinhou@users.noreply.github.com> Co-authored-by: Matus Kasak <matus.kasak@dataquest.sk>
1 parent 658c16b commit a6b90a3

5 files changed

Lines changed: 277 additions & 24 deletions

File tree

src/app/shared/metadata-representation/metadata-representation-loader.component.spec.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
21
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
2+
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
3+
4+
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
35
import { Context } from '../../core/shared/context.model';
46
import {
57
MetadataRepresentation,
6-
MetadataRepresentationType
8+
MetadataRepresentationType,
79
} from '../../core/shared/metadata-representation/metadata-representation.model';
8-
import { MetadataRepresentationLoaderComponent } from './metadata-representation-loader.component';
9-
import { MetadataRepresentationDirective } from './metadata-representation.directive';
10-
import { METADATA_REPRESENTATION_COMPONENT_FACTORY } from './metadata-representation.decorator';
11-
import { ThemeService } from '../theme-support/theme.service';
1210
import { PlainTextMetadataListElementComponent } from '../object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component';
11+
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
12+
import { ThemeService } from '../theme-support/theme.service';
13+
import { METADATA_REPRESENTATION_COMPONENT_FACTORY } from './metadata-representation.decorator';
14+
import { MetadataRepresentationDirective } from './metadata-representation.directive';
15+
import { MetadataRepresentationLoaderComponent } from './metadata-representation-loader.component';
1316

1417
const testType = 'TestType';
1518
const testContext = Context.Search;
@@ -41,23 +44,35 @@ describe('MetadataRepresentationLoaderComponent', () => {
4144
});
4245
TestBed.configureTestingModule({
4346
imports: [],
44-
declarations: [MetadataRepresentationLoaderComponent, PlainTextMetadataListElementComponent, MetadataRepresentationDirective],
47+
declarations: [
48+
MetadataRepresentationLoaderComponent,
49+
PlainTextMetadataListElementComponent,
50+
MetadataRepresentationDirective,
51+
],
4552
schemas: [NO_ERRORS_SCHEMA],
4653
providers: [
4754
{
4855
provide: METADATA_REPRESENTATION_COMPONENT_FACTORY,
49-
useValue: jasmine.createSpy('getMetadataRepresentationComponent').and.returnValue(PlainTextMetadataListElementComponent)
56+
useValue: jasmine.createSpy('getMetadataRepresentationComponent').and.returnValue(PlainTextMetadataListElementComponent),
5057
},
5158
{
5259
provide: ThemeService,
5360
useValue: themeService,
54-
}
55-
]
61+
},
62+
{
63+
provide: ConfigurationDataService,
64+
useValue: {
65+
findByPropertyName: jasmine.createSpy('findByPropertyName').and.returnValue(
66+
createSuccessfulRemoteDataObject$({ values: ['https://orcid.org'] }),
67+
),
68+
},
69+
},
70+
],
5671
}).overrideComponent(MetadataRepresentationLoaderComponent, {
5772
set: {
5873
changeDetection: ChangeDetectionStrategy.Default,
59-
entryComponents: [PlainTextMetadataListElementComponent]
60-
}
74+
entryComponents: [PlainTextMetadataListElementComponent],
75+
},
6176
}).compileComponents();
6277
}));
6378

src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,24 @@
77
target="_blank" [href]="mdRepresentation.getValue()">
88
{{mdRepresentation.getValue()}}
99
</a>
10-
<span *ngIf="(mdRepresentation.representationType=='authority_controlled')" class="dont-break-out">{{mdRepresentation.getValue()}}</span>
10+
<ng-container *ngIf="(mdRepresentation.representationType=='authority_controlled')">
11+
<ng-container *ngIf="orcidDomainUrl$ | async as orcidDomainUrl; else noOrcidDomain">
12+
<a *ngIf="isOrcidAuthority(orcidDomainUrl); else plainAuthority"
13+
class="dont-break-out orcid-author-link"
14+
[href]="getOrcidUrl(orcidDomainUrl)"
15+
target="_blank"
16+
rel="noopener noreferrer">
17+
{{mdRepresentation.getValue()}}
18+
<i class="fa-brands fa-orcid orcid-icon" aria-hidden="true"></i>
19+
</a>
20+
<ng-template #plainAuthority>
21+
<span class="dont-break-out">{{mdRepresentation.getValue()}}</span>
22+
</ng-template>
23+
</ng-container>
24+
<ng-template #noOrcidDomain>
25+
<span class="dont-break-out">{{mdRepresentation.getValue()}}</span>
26+
</ng-template>
27+
</ng-container>
1128
<a *ngIf="(mdRepresentation.representationType=='browse_link')"
1229
class="dont-break-out ds-browse-link"
1330
[routerLink]="['/browse/', mdRepresentation.browseDefinition.id]"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.orcid-author-link {
2+
text-decoration: none;
3+
4+
&:hover {
5+
text-decoration: underline;
6+
}
7+
}
8+
9+
.orcid-icon {
10+
color: #a6ce39; // Official ORCID green color
11+
font-size: 1em;
12+
margin-left: 0.25rem;
13+
14+
&:hover {
15+
opacity: 0.8;
16+
}
17+
}

src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,44 @@
1-
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
21
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
3-
import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component';
4-
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
52
import { By } from '@angular/platform-browser';
3+
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
4+
5+
import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
6+
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
7+
import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils';
68
import { mockData } from '../../../testing/browse-definition-data-service.stub';
9+
import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component';
710

811
// Render the mock representation with the default mock author browse definition so it is also rendered as a link
912
// without affecting other tests
1013
const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type', mockData[1]), {
1114
key: 'dc.contributor.author',
12-
value: 'Test Author'
15+
value: 'Test Author',
1316
});
1417

18+
const mockOrcidRepresentation = Object.assign(new MetadatumRepresentation('type'), {
19+
key: 'dc.contributor.author',
20+
value: 'Doe, John',
21+
authority: '1234-5678-9012-3456',
22+
});
23+
24+
const mockNonOrcidAuthorityRepresentation = Object.assign(new MetadatumRepresentation('type'), {
25+
key: 'dc.contributor.author',
26+
value: 'Smith, Jane',
27+
authority: 'some-non-orcid-authority-key',
28+
});
29+
30+
const mockOrcidWithWhitespaceRepresentation = Object.assign(new MetadatumRepresentation('type'), {
31+
key: 'dc.contributor.author',
32+
value: 'Doe, Jane',
33+
authority: ' 1234-5678-9012-3456 ',
34+
});
35+
36+
const mockConfigurationDataService = {
37+
findByPropertyName: jasmine.createSpy('findByPropertyName').and.returnValue(
38+
createSuccessfulRemoteDataObject$({ values: ['https://orcid.org'] }),
39+
),
40+
};
41+
1542
describe('PlainTextMetadataListElementComponent', () => {
1643
let comp: PlainTextMetadataListElementComponent;
1744
let fixture: ComponentFixture<PlainTextMetadataListElementComponent>;
@@ -20,9 +47,12 @@ describe('PlainTextMetadataListElementComponent', () => {
2047
TestBed.configureTestingModule({
2148
imports: [],
2249
declarations: [PlainTextMetadataListElementComponent],
23-
schemas: [NO_ERRORS_SCHEMA]
50+
providers: [
51+
{ provide: ConfigurationDataService, useValue: mockConfigurationDataService },
52+
],
53+
schemas: [NO_ERRORS_SCHEMA],
2454
}).overrideComponent(PlainTextMetadataListElementComponent, {
25-
set: { changeDetection: ChangeDetectionStrategy.Default }
55+
set: { changeDetection: ChangeDetectionStrategy.Default },
2656
}).compileComponents();
2757
}));
2858

@@ -41,4 +71,117 @@ describe('PlainTextMetadataListElementComponent', () => {
4171
expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockMetadataRepresentation.value);
4272
});
4373

74+
describe('when metadata has ORCID authority', () => {
75+
beforeEach(() => {
76+
comp.mdRepresentation = mockOrcidRepresentation;
77+
fixture.detectChanges();
78+
});
79+
80+
it('should render an ORCID link', () => {
81+
const link = fixture.debugElement.query(By.css('a.orcid-author-link'));
82+
expect(link).toBeTruthy();
83+
expect(link.nativeElement.getAttribute('href')).toBe('https://orcid.org/1234-5678-9012-3456');
84+
expect(link.nativeElement.textContent).toContain('Doe, John');
85+
});
86+
87+
it('should render an ORCID icon', () => {
88+
const icon = fixture.debugElement.query(By.css('a.orcid-author-link i.fa-orcid'));
89+
expect(icon).toBeTruthy();
90+
});
91+
92+
it('isOrcidAuthority should return true', () => {
93+
expect(comp.isOrcidAuthority(comp.orcidDomainUrl$.value)).toBeTrue();
94+
});
95+
96+
it('getOrcidUrl should return full ORCID URL', () => {
97+
expect(comp.getOrcidUrl(comp.orcidDomainUrl$.value)).toBe('https://orcid.org/1234-5678-9012-3456');
98+
});
99+
});
100+
101+
describe('when metadata has non-ORCID authority', () => {
102+
beforeEach(() => {
103+
comp.mdRepresentation = mockNonOrcidAuthorityRepresentation;
104+
fixture.detectChanges();
105+
});
106+
107+
it('should render as plain text (no ORCID link)', () => {
108+
const link = fixture.debugElement.query(By.css('a.orcid-author-link'));
109+
expect(link).toBeFalsy();
110+
});
111+
112+
it('should render the value as a span', () => {
113+
const span = fixture.debugElement.query(By.css('span.dont-break-out'));
114+
expect(span).toBeTruthy();
115+
expect(span.nativeElement.textContent).toContain('Smith, Jane');
116+
});
117+
118+
it('isOrcidAuthority should return false', () => {
119+
expect(comp.isOrcidAuthority(comp.orcidDomainUrl$.value)).toBeFalse();
120+
});
121+
});
122+
123+
describe('getOrcidUrl with trailing slash handling', () => {
124+
it('should not double-slash when domain URL ends with /', () => {
125+
comp.orcidDomainUrl$.next('https://orcid.org/');
126+
comp.mdRepresentation = mockOrcidRepresentation;
127+
expect(comp.getOrcidUrl('https://orcid.org/')).toBe('https://orcid.org/1234-5678-9012-3456');
128+
});
129+
130+
it('should add slash when domain URL does not end with /', () => {
131+
comp.orcidDomainUrl$.next('https://sandbox.orcid.org');
132+
comp.mdRepresentation = mockOrcidRepresentation;
133+
expect(comp.getOrcidUrl('https://sandbox.orcid.org')).toBe('https://sandbox.orcid.org/1234-5678-9012-3456');
134+
});
135+
});
136+
137+
describe('when backend config is not available', () => {
138+
beforeEach(() => {
139+
comp.orcidDomainUrl$.next(null);
140+
comp.mdRepresentation = mockOrcidRepresentation;
141+
fixture.detectChanges();
142+
});
143+
144+
it('should not render ORCID link even if authority is ORCID', () => {
145+
const link = fixture.debugElement.query(By.css('a.orcid-author-link'));
146+
expect(link).toBeFalsy();
147+
});
148+
149+
it('isOrcidAuthority should return false', () => {
150+
expect(comp.isOrcidAuthority(comp.orcidDomainUrl$.value)).toBeFalse();
151+
});
152+
153+
it('getOrcidUrl should return empty string', () => {
154+
expect(comp.getOrcidUrl(comp.orcidDomainUrl$.value)).toBe('');
155+
});
156+
});
157+
158+
describe('getOrcidUrl defensive behavior', () => {
159+
it('should return empty string when orcidDomainUrl is null', () => {
160+
comp.orcidDomainUrl$.next(null);
161+
comp.mdRepresentation = mockOrcidRepresentation;
162+
expect(comp.getOrcidUrl(null)).toBe('');
163+
});
164+
165+
it('should return empty string when mdRepresentation has no authority', () => {
166+
comp.orcidDomainUrl$.next('https://orcid.org');
167+
comp.mdRepresentation = mockMetadataRepresentation;
168+
expect(comp.getOrcidUrl('https://orcid.org')).toBe('');
169+
});
170+
});
171+
172+
describe('when authority has leading/trailing whitespace', () => {
173+
beforeEach(() => {
174+
comp.orcidDomainUrl$.next('https://orcid.org');
175+
comp.mdRepresentation = mockOrcidWithWhitespaceRepresentation;
176+
fixture.detectChanges();
177+
});
178+
179+
it('isOrcidAuthority should return true after trimming', () => {
180+
expect(comp.isOrcidAuthority(comp.orcidDomainUrl$.value)).toBeTrue();
181+
});
182+
183+
it('getOrcidUrl should return trimmed ORCID URL', () => {
184+
expect(comp.getOrcidUrl(comp.orcidDomainUrl$.value)).toBe('https://orcid.org/1234-5678-9012-3456');
185+
});
186+
});
44187
});
Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,55 @@
1+
import { Component, OnInit } from '@angular/core';
2+
import { BehaviorSubject } from 'rxjs';
3+
4+
import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
15
import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model';
2-
import { Component } from '@angular/core';
3-
import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component';
4-
import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator';
6+
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
7+
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
58
import { VALUE_LIST_BROWSE_DEFINITION } from '../../../../core/shared/value-list-browse-definition.resource-type';
9+
import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator';
10+
import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component';
611

712
@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText)
813
// For now, authority controlled fields are rendered the same way as plain text fields
914
@metadataRepresentationComponent('Publication', MetadataRepresentationType.AuthorityControlled)
1015
@Component({
1116
selector: 'ds-plain-text-metadata-list-element',
12-
templateUrl: './plain-text-metadata-list-element.component.html'
17+
templateUrl: './plain-text-metadata-list-element.component.html',
18+
styleUrls: ['./plain-text-metadata-list-element.component.scss'],
1319
})
1420
/**
1521
* A component for displaying MetadataRepresentation objects in the form of plain text
1622
* It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text
1723
*/
18-
export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent {
24+
export class PlainTextMetadataListElementComponent extends MetadataRepresentationListElementComponent implements OnInit {
25+
26+
/**
27+
* Regex pattern for ORCID identifiers: four groups of four digits separated by hyphens.
28+
* The last group may end with an X (checksum digit).
29+
*/
30+
private static readonly ORCID_PATTERN = /^\d{4}-\d{4}-\d{4}-(\d{3}X|\d{4})$/;
31+
32+
orcidDomainUrl$ = new BehaviorSubject<string | null>(null);
33+
34+
constructor(private configurationService: ConfigurationDataService) {
35+
super();
36+
}
37+
38+
ngOnInit(): void {
39+
this.configurationService.findByPropertyName('orcid.domain-url').pipe(
40+
getFirstCompletedRemoteData(),
41+
).subscribe((rd) => {
42+
if (rd.hasFailed || !rd.hasSucceeded || !rd.payload?.values?.length) {
43+
return;
44+
}
45+
46+
const url = rd.payload.values[0]?.trim();
47+
if (url && /^https?:\/\//i.test(url)) {
48+
this.orcidDomainUrl$.next(url);
49+
}
50+
});
51+
}
52+
1953
/**
2054
* Get the appropriate query parameters for this browse link, depending on whether the browse definition
2155
* expects 'startsWith' (eg browse by date) or 'value' (eg browse by title)
@@ -27,4 +61,31 @@ export class PlainTextMetadataListElementComponent extends MetadataRepresentatio
2761
}
2862
return queryParams;
2963
}
64+
65+
isOrcidAuthority(orcidDomainUrl: string | null): boolean {
66+
if (orcidDomainUrl === null) {
67+
return false;
68+
}
69+
if (this.mdRepresentation instanceof MetadatumRepresentation) {
70+
const authority = this.mdRepresentation.authority?.trim();
71+
return !!authority && PlainTextMetadataListElementComponent.ORCID_PATTERN.test(authority);
72+
}
73+
return false;
74+
}
75+
76+
getOrcidUrl(orcidDomainUrl: string | null): string {
77+
if (orcidDomainUrl === null) {
78+
return '';
79+
}
80+
const authority = this.mdRepresentation instanceof MetadatumRepresentation
81+
? this.mdRepresentation.authority?.trim()
82+
: undefined;
83+
84+
if (!authority || !PlainTextMetadataListElementComponent.ORCID_PATTERN.test(authority)) {
85+
return '';
86+
}
87+
88+
const base = orcidDomainUrl.endsWith('/') ? orcidDomainUrl : orcidDomainUrl + '/';
89+
return `${base}${authority}`;
90+
}
3091
}

0 commit comments

Comments
 (0)