diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index 00b5c80bfda..f6b039ed7c1 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -30,6 +30,7 @@ import { VocabularyOptions } from './models/vocabulary-options.model'; import { VocabularyDataService } from './vocabulary.data.service'; import { VocabularyService } from './vocabulary.service'; import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service'; +import { ExternalSourceDataService } from '../../data/external-source-data.service'; describe('VocabularyService', () => { let scheduler: TestScheduler; @@ -39,6 +40,7 @@ describe('VocabularyService', () => { let objectCache: ObjectCacheService; let halService: HALEndpointService; let hrefOnlyDataService: HrefOnlyDataService; + let externalSourceDataService: ExternalSourceDataService; let responseCacheEntry: RequestEntry; const vocabulary: any = { @@ -211,11 +213,13 @@ describe('VocabularyService', () => { function initTestService() { hrefOnlyDataService = getMockHrefOnlyDataService(); objectCache = new ObjectCacheServiceStub() as ObjectCacheService; + externalSourceDataService = jasmine.createSpyObj('ExternalSourceDataService', ['getExternalSourceEntries']); return new VocabularyService( requestService, new VocabularyDataService(requestService, rdbService, objectCache, halService), new VocabularyEntryDetailsDataService(requestService, rdbService, objectCache, halService), + externalSourceDataService, ); } diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index e2d18975cd7..9625d4d4e50 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { + catchError, map, mergeMap, switchMap, @@ -28,6 +29,12 @@ import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; import { VocabularyOptions } from './models/vocabulary-options.model'; import { VocabularyDataService } from './vocabulary.data.service'; import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service'; +import { ExternalSourceDataService } from '../../data/external-source-data.service'; +import { ExternalSourceEntry } from '../../shared/external-source-entry.model'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { buildPaginatedList } from '../../data/paginated-list.model'; /** * A service responsible for fetching/sending data from/to the REST API on the vocabularies endpoint @@ -40,6 +47,7 @@ export class VocabularyService { protected requestService: RequestService, protected vocabularyDataService: VocabularyDataService, protected vocabularyEntryDetailDataService: VocabularyEntryDetailsDataService, + protected externalSourceDataService: ExternalSourceDataService, ) { } @@ -149,6 +157,48 @@ export class VocabularyService { * Return an observable that emits object list */ getVocabularyEntriesByValue(value: string, exact: boolean, vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { + // Handle authority fields specially — use ORCID external source for search + const authorityFields = ['dc.contributor.author', 'dc.creator', 'dc.contributor.editor', 'dc.contributor.advisor']; + if (authorityFields.includes(vocabularyOptions.name)) { + if (value && value.length >= 2) { + const paginationOptions = Object.assign(new PaginationComponentOptions(), { + id: 'orcid-search', + currentPage: pageInfo.currentPage || 1, + pageSize: pageInfo.elementsPerPage || 10, + }); + const searchOptions = new PaginatedSearchOptions({ + query: value, + pagination: paginationOptions, + }); + return this.externalSourceDataService.getExternalSourceEntries('orcid', searchOptions).pipe( + switchMap((orcidResponse: RemoteData>) => { + if (orcidResponse.hasSucceeded && orcidResponse.payload && orcidResponse.payload.page.length > 0) { + const vocabularyEntries: VocabularyEntry[] = orcidResponse.payload.page.map(entry => { + const vocabEntry = new VocabularyEntry(); + vocabEntry.display = `${entry.display} (ORCID: ${entry.id})`; + vocabEntry.value = entry.display; + vocabEntry.authority = entry.id; + vocabEntry.otherInformation = { orcid: entry.id }; + return vocabEntry; + }); + const resultList = buildPaginatedList(pageInfo, vocabularyEntries); + return createSuccessfulRemoteDataObject$(resultList); + } else { + const emptyList = buildPaginatedList(new PageInfo(), []); + return createSuccessfulRemoteDataObject$(emptyList); + } + }), + catchError(() => { + const emptyList = buildPaginatedList(new PageInfo(), []); + return createSuccessfulRemoteDataObject$(emptyList); + }), + ); + } else { + const emptyList = buildPaginatedList(new PageInfo(), []); + return createSuccessfulRemoteDataObject$(emptyList); + } + } + const options: VocabularyFindOptions = new VocabularyFindOptions( null, value, diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts index 01a6ba48caa..9c394147c38 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.spec.ts @@ -81,7 +81,8 @@ describe('DsoEditMetadataForm', () => { it('should set both its original and new place to match its position in the value array', () => { expect(form.fields[mdField][expectedPlace].newValue.place).toEqual(expectedPlace); - expect(form.fields[mdField][expectedPlace].originalValue.place).toEqual(expectedPlace); + // For ADD operations, originalValue.place should remain undefined + expect(form.fields[mdField][expectedPlace].originalValue.place).toBeUndefined(); }); it('should clear \"newValue\"', () => { diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts index f45f43181b6..08ddf856489 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts @@ -235,8 +235,14 @@ export class DsoEditMetadataForm { this.addValueToField(this.newValue, mdField); // Set the place property to match the new value's position within its field const place = this.fields[mdField].length - 1; - this.fields[mdField][place].originalValue.place = place; - this.fields[mdField][place].newValue.place = place; + + // For new values (ADD operation), don't modify originalValue.place since it represents the original state + if (this.fields[mdField][place].change === DsoEditMetadataChangeType.ADD) { + this.fields[mdField][place].newValue.place = place; + } else { + this.fields[mdField][place].originalValue.place = place; + this.fields[mdField][place].newValue.place = place; + } this.newValue = undefined; } @@ -412,31 +418,53 @@ export class DsoEditMetadataForm { const removeOperations: MetadataPatchRemoveOperation[] = []; const addOperations: MetadataPatchAddOperation[] = []; [...values] - .sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place) + .sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => { + // Handle ADD operations that might have undefined originalValue.place + const aPlace = a.originalValue.place ?? Number.MAX_SAFE_INTEGER; + const bPlace = b.originalValue.place ?? Number.MAX_SAFE_INTEGER; + return aPlace - bPlace; + }) .forEach((value: DsoEditMetadataValue) => { if (hasValue(value.change)) { if (value.change === DsoEditMetadataChangeType.UPDATE) { // Only changes to value or language are considered "replace" operations. Changes to place are considered "move", which is processed below. if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language - || value.originalValue.authority !== value.newValue.authority || value.originalValue.confidence !== value.newValue.confidence) { - replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, { - value: value.newValue.value, - language: value.newValue.language, - authority: value.newValue.authority, - confidence: value.newValue.confidence, - })); + || value.originalValue.authority !== value.newValue.authority || value.originalValue.confidence !== value.newValue.confidence) { + // Validate that this is truly an existing metadata value + if (value.originalValue.place === undefined || value.originalValue.place === null) { + value.change = DsoEditMetadataChangeType.ADD; + } else if (!value.originalValue.value || value.originalValue.value.trim() === '') { + value.change = DsoEditMetadataChangeType.ADD; + } } + } + + // Process the operation based on the (possibly updated) change type + if (value.change === DsoEditMetadataChangeType.UPDATE) { + const replaceData: any = { + value: value.newValue.value, + language: value.newValue.language || null, + }; + if (value.newValue.authority && value.newValue.authority.trim() !== '') { + replaceData.authority = value.newValue.authority; + replaceData.confidence = (value.newValue.confidence !== undefined && value.newValue.confidence !== -1) ? value.newValue.confidence : null; + } + replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, replaceData)); } else if (value.change === DsoEditMetadataChangeType.REMOVE) { + if (value.originalValue.place === undefined || value.originalValue.place === null) { + return; + } removeOperations.push(new MetadataPatchRemoveOperation(field, value.originalValue.place)); } else if (value.change === DsoEditMetadataChangeType.ADD) { - addOperations.push(new MetadataPatchAddOperation(field, { + const addData: any = { value: value.newValue.value, - language: value.newValue.language, - authority: value.newValue.authority, - confidence: value.newValue.confidence, - })); - } else { - console.warn('Illegal metadata change state detected for', value); + language: value.newValue.language || null, + }; + if (value.newValue.authority && value.newValue.authority.trim() !== '') { + addData.authority = value.newValue.authority; + addData.confidence = (value.newValue.confidence !== undefined && value.newValue.confidence !== -1) ? value.newValue.confidence : null; + } + addOperations.push(new MetadataPatchAddOperation(field, addData)); } } }); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts index a6a4e0cb626..c64ad6363ae 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts @@ -57,6 +57,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat import { DebounceDirective } from '../../../../shared/utils/debounce.directive'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component'; +import { DsoEditMetadataChangeType } from '../../dso-edit-metadata-form'; import { DsoEditMetadataFieldService } from '../dso-edit-metadata-field.service'; /** @@ -125,6 +126,16 @@ export class DsoEditMetadataAuthorityFieldComponent extends AbstractDsoEditMetad super(); } + private static readonly KNOWN_AUTHORITY_FIELDS = [ + 'dc.contributor.author', + 'dc.creator', + 'dc.contributor.editor', + 'dc.contributor.advisor', + 'dc.contributor.other', + 'dcterms.creator', + 'dcterms.contributor', + ]; + ngOnInit(): void { this.initAuthorityProperties(); } @@ -163,15 +174,16 @@ export class DsoEditMetadataAuthorityFieldComponent extends AbstractDsoEditMetad if (isNotEmpty(vocabulary)) { let formFieldValue: FormFieldMetadataValueObject | string; if (isNotEmpty(this.mdValue.newValue.value)) { - formFieldValue = new FormFieldMetadataValueObject(); - formFieldValue.value = this.mdValue.newValue.value; - formFieldValue.display = this.mdValue.newValue.value; - if (this.mdValue.newValue.authority) { - formFieldValue.authority = this.mdValue.newValue.authority; - formFieldValue.confidence = this.mdValue.newValue.confidence; - } + formFieldValue = new FormFieldMetadataValueObject( + this.mdValue.newValue.value, + this.mdValue.newValue.language, + this.mdValue.newValue.authority, + this.mdValue.newValue.value, + 0, + this.mdValue.newValue.confidence, + ); } else { - formFieldValue = this.mdValue.newValue.value; + formFieldValue = this.mdValue.newValue.value || ''; } const vocabularyOptions = vocabulary ? { @@ -224,17 +236,22 @@ export class DsoEditMetadataAuthorityFieldComponent extends AbstractDsoEditMetad changes.mdField.previousValue !== changes.mdField.currentValue) { // Clear authority value in case it has been assigned with the previous metadataField used this.mdValue.newValue.authority = null; - this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + this.mdValue.newValue.confidence = null; } - // Only ask if the current mdField have a period character to reduce request + // Skip validation for known authority fields and directly initialize if (changes.mdField.currentValue.includes('.')) { - this.validateMetadataField().subscribe((isValid: boolean) => { - if (isValid) { - this.initAuthorityProperties(); - this.cdr.detectChanges(); - } - }); + if (DsoEditMetadataAuthorityFieldComponent.KNOWN_AUTHORITY_FIELDS.includes(changes.mdField.currentValue)) { + this.initAuthorityProperties(); + this.cdr.detectChanges(); + } else { + this.validateMetadataField().subscribe((isValid: boolean) => { + if (isValid) { + this.initAuthorityProperties(); + this.cdr.detectChanges(); + } + }); + } } } } @@ -272,14 +289,16 @@ export class DsoEditMetadataAuthorityFieldComponent extends AbstractDsoEditMetad this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; } else { this.mdValue.newValue.authority = null; - this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + this.mdValue.newValue.confidence = null; } + this.mdValue.change = DsoEditMetadataChangeType.UPDATE; this.confirm.emit(false); } else { // The event is undefined when the user clears the selection in scrollable dropdown this.mdValue.newValue.value = ''; this.mdValue.newValue.authority = null; - this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + this.mdValue.newValue.confidence = null; + this.mdValue.change = DsoEditMetadataChangeType.UPDATE; this.confirm.emit(false); } } @@ -308,9 +327,11 @@ export class DsoEditMetadataAuthorityFieldComponent extends AbstractDsoEditMetad onChangeAuthorityKey() { if (this.mdValue.newValue.authority === '') { this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE; + this.mdValue.change = DsoEditMetadataChangeType.UPDATE; this.confirm.emit(false); } else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) { this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + this.mdValue.change = DsoEditMetadataChangeType.UPDATE; this.confirm.emit(false); } } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts index bcf5d525d1d..a803812f24c 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts @@ -1,19 +1,24 @@ import { Injectable } from '@angular/core'; import { Observable, - of, + of as observableOf, } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; -import { ItemDataService } from '../../../core/data/item-data.service'; -import { Collection } from '../../../core/shared/collection.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { Item } from '../../../core/shared/item.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; -import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service'; -import { isNotEmpty } from '../../../shared/empty.util'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; + +/** + * Known authority-controlled metadata fields for ORCID integration + */ +const AUTHORITY_FIELDS = [ + 'dc.contributor.author', + 'dc.creator', + 'dc.contributor.editor', + 'dc.contributor.advisor', + 'dc.contributor.other', + 'dcterms.creator', + 'dcterms.contributor', +]; /** * A service containing all the common logic for the components generated by the @@ -24,33 +29,26 @@ import { followLink } from '../../../shared/utils/follow-link-config.model'; }) export class DsoEditMetadataFieldService { - constructor( - protected itemService: ItemDataService, - protected vocabularyService: VocabularyService, - ) { - } - /** * Find the vocabulary of the given {@link mdField} for the given item. + * For known authority fields (ORCID), returns a basic vocabulary configuration + * to enable authority field functionality without requiring a backend vocabulary endpoint. * * @param dso The item * @param mdField The metadata field */ findDsoFieldVocabulary(dso: DSpaceObject, mdField: string): Observable { - if (isNotEmpty(mdField)) { - const owningCollection$: Observable = this.itemService.findByHref(dso._links.self.href, true, true, followLink('owningCollection')).pipe( - getFirstSucceededRemoteDataPayload(), - switchMap((item: Item) => item.owningCollection), - getFirstSucceededRemoteDataPayload(), - ); - - return owningCollection$.pipe( - switchMap((c: Collection) => this.vocabularyService.getVocabularyByMetadataAndCollection(mdField, c.uuid).pipe( - getFirstSucceededRemoteDataPayload(), - )), - ); - } else { - return of(undefined); + if (AUTHORITY_FIELDS.includes(mdField)) { + const authVocabulary = new Vocabulary(); + authVocabulary.name = mdField; + authVocabulary.id = 'authority-' + mdField.replace(/\./g, '-'); + authVocabulary.scrollable = false; + authVocabulary.hierarchical = false; + authVocabulary.preloadLevel = 0; + authVocabulary.type = 'vocabulary'; + return observableOf(authVocabulary); } + + return observableOf(undefined); } } diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.html b/src/app/item-page/field-components/metadata-values/metadata-values.component.html index 60fca0a8b71..5dba682bb28 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.html +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.html @@ -1,10 +1,10 @@ @for (mdValue of mdValues; track mdValue; let last = $last) { - + @if (!last) { @@ -50,3 +50,25 @@ [routerLink]="['/browse', browseDefinition.id]" [queryParams]="getQueryParams(value)" role="link" tabindex="0">{{value}} + + + + + + {{value}} + + + ORCID iD icon + + + diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.scss b/src/app/item-page/field-components/metadata-values/metadata-values.component.scss index e69de29bb2d..b148d925712 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.scss +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.scss @@ -0,0 +1,30 @@ +.orcid-author { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.ds-orcid-link { + text-decoration: none; + color: inherit; + + &:hover { + text-decoration: underline; + } +} + +.orcid-badge { + display: inline-flex; + align-items: center; + text-decoration: none; + + &:hover { + opacity: 0.8; + } +} + +.orcid-icon { + width: 16px; + height: 16px; + vertical-align: middle; +} diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts index 56701287336..93c5f0a4ebc 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.spec.ts @@ -99,4 +99,56 @@ describe('MetadataValuesComponent', () => { expect(result.rel).toBe('noopener noreferrer'); }); + it('should correctly detect ORCID authority pattern', () => { + const orcidMdValue = { + value: 'John Doe', + authority: '0000-0002-1825-0097', + confidence: 600, + } as MetadataValue; + expect(comp.isOrcidAuthority(orcidMdValue)).toBe(true); + }); + + it('should return false for non-ORCID authority patterns', () => { + const nonOrcidMdValue = { + value: 'Jane Smith', + authority: 'not-an-orcid', + confidence: 600, + } as MetadataValue; + expect(comp.isOrcidAuthority(nonOrcidMdValue)).toBe(false); + }); + + it('should return false for metadata values without authority', () => { + const noAuthorityMdValue = { + value: 'Anonymous Author', + authority: null, + confidence: -1, + } as MetadataValue; + expect(comp.isOrcidAuthority(noAuthorityMdValue)).toBe(false); + }); + + it('should generate correct ORCID profile URL', () => { + const orcidId = '0000-0002-1825-0097'; + const expectedUrl = 'https://orcid.org/0000-0002-1825-0097'; + expect(comp.getOrcidUrl(orcidId)).toBe(expectedUrl); + }); + + it('should render ORCID link and badge for metadata with ORCID authority', () => { + const orcidMetadata = [ + { + language: 'en_US', + value: 'John Doe', + authority: '0000-0002-1825-0097', + confidence: 600, + }, + ] as MetadataValue[]; + comp.mdValues = orcidMetadata; + fixture.detectChanges(); + const orcidLink = fixture.debugElement.query(By.css('a.ds-orcid-link')); + expect(orcidLink).toBeTruthy(); + expect(orcidLink.nativeElement.getAttribute('href')).toBe('https://orcid.org/0000-0002-1825-0097'); + expect(orcidLink.nativeElement.textContent).toContain('John Doe'); + const orcidBadge = fixture.debugElement.query(By.css('a.orcid-badge')); + expect(orcidBadge).toBeTruthy(); + }); + }); diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index be58645015f..f9125711bcc 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -140,6 +140,28 @@ export class MetadataValuesComponent implements OnChanges { return linkValue.startsWith(environment.ui.baseUrl); } + /** + * Checks if a metadata value has ORCID authority (matches ORCID pattern) + * @param mdValue - The metadata value to check + * @returns True if the authority field matches ORCID pattern + */ + isOrcidAuthority(mdValue: MetadataValue): boolean { + if (!hasValue(mdValue.authority)) { + return false; + } + const orcidPattern = /^\d{4}-\d{4}-\d{4}-\d{4}$/; + return orcidPattern.test(mdValue.authority); + } + + /** + * Generates ORCID profile URL from authority ID + * @param authorityId - The ORCID authority ID + * @returns The full ORCID profile URL + */ + getOrcidUrl(authorityId: string): string { + return `https://orcid.org/${encodeURIComponent(authorityId)}`; + } + /** * This method performs a validation and determines the target of the url. * @returns - Returns the target url. diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index 5b2cfd01a8b..cf66e5cba70 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -31,6 +31,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { Observable, of, + of as observableOf, Subject, Subscription, } from 'rxjs'; @@ -347,4 +348,22 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple .forEach((sub) => sub.unsubscribe()); } + /** + * Override getInitValueFromModel to handle ORCID authority fields properly. + * For ORCID fields, return the original FormFieldMetadataValueObject without vocabulary lookup. + */ + getInitValueFromModel(preserveConfidence = false): Observable { + if (isNotEmpty(this.model.value) && + (this.model.value instanceof FormFieldMetadataValueObject) && + !this.model.value.hasAuthorityToGenerate() && + this.model.value.hasAuthority()) { + const authority = this.model.value.authority; + const isORCID = authority && /^\d{4}-\d{4}-\d{4}-\d{4}$/.test(authority); + if (isORCID) { + return observableOf(this.model.value); + } + } + return super.getInitValueFromModel(preserveConfidence); + } + } diff --git a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts index b36e793f446..587fadc3484 100644 --- a/src/app/shared/form/builder/models/form-field-metadata-value.model.ts +++ b/src/app/shared/form/builder/models/form-field-metadata-value.model.ts @@ -52,7 +52,7 @@ export class FormFieldMetadataValueObject implements MetadataValueInterface { } else if (isNotEmpty(confidence)) { this.confidence = confidence; } else { - this.confidence = ConfidenceType.CF_UNSET; + this.confidence = null; } this.place = place; diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html index 8c550d0276a..ae13e22a68d 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.html @@ -11,7 +11,19 @@ {{mdRepresentation.getValue()}} } - @if ((mdRepresentation.representationType==='authority_controlled')) { + @if ((mdRepresentation.representationType==='authority_controlled') && isOrcidAuthority()) { + + + {{mdRepresentation.getValue()}} + + + ORCID iD icon + + + } + @if ((mdRepresentation.representationType==='authority_controlled') && !isOrcidAuthority()) { {{mdRepresentation.getValue()}} } @if ((mdRepresentation.representationType==='browse_link')) { diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.scss b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.scss new file mode 100644 index 00000000000..4e8edf5081a --- /dev/null +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.scss @@ -0,0 +1,30 @@ +.orcid-author { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.orcid-link { + text-decoration: none; + color: inherit; + + &:hover { + text-decoration: underline; + } +} + +.orcid-badge { + display: inline-flex; + align-items: center; + text-decoration: none; + + &:hover { + opacity: 0.8; + } +} + +.orcid-icon { + width: 16px; + height: 16px; + vertical-align: middle; +} diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts index e35f7d67590..43273f28050 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.spec.ts @@ -51,4 +51,68 @@ describe('PlainTextMetadataListElementComponent', () => { expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockMetadataRepresentation.value); }); + it('should correctly detect ORCID authority', () => { + const orcidRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'John Doe', + authority: '0000-0002-1825-0097', + confidence: 600, + }); + comp.mdRepresentation = orcidRepresentation; + expect(comp.isOrcidAuthority()).toBe(true); + }); + + it('should return false for non-ORCID authority', () => { + const nonOrcidRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'Jane Smith', + authority: 'not-an-orcid', + confidence: 600, + }); + comp.mdRepresentation = nonOrcidRepresentation; + expect(comp.isOrcidAuthority()).toBe(false); + }); + + it('should generate correct ORCID profile URL', () => { + const orcidRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'John Doe', + authority: '0000-0002-1825-0097', + confidence: 600, + }); + comp.mdRepresentation = orcidRepresentation; + expect(comp.getOrcidUrl()).toBe('https://orcid.org/0000-0002-1825-0097'); + }); + + it('should render ORCID link and badge for authority controlled metadata with ORCID', () => { + const orcidRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'John Doe', + authority: '0000-0002-1825-0097', + confidence: 600, + }); + comp.mdRepresentation = orcidRepresentation; + fixture.detectChanges(); + const orcidLink = fixture.debugElement.query(By.css('a.orcid-link')); + expect(orcidLink).toBeTruthy(); + expect(orcidLink.nativeElement.getAttribute('href')).toBe('https://orcid.org/0000-0002-1825-0097'); + expect(orcidLink.nativeElement.textContent).toContain('John Doe'); + const orcidBadge = fixture.debugElement.query(By.css('a.orcid-badge')); + expect(orcidBadge).toBeTruthy(); + }); + + it('should render plain text for authority controlled metadata without ORCID', () => { + const nonOrcidRepresentation = Object.assign(new MetadatumRepresentation('type'), { + key: 'dc.contributor.author', + value: 'Jane Smith', + authority: 'some-other-authority', + confidence: 600, + }); + comp.mdRepresentation = nonOrcidRepresentation; + fixture.detectChanges(); + const orcidLink = fixture.debugElement.query(By.css('a.orcid-link')); + expect(orcidLink).toBeFalsy(); + expect(fixture.nativeElement.textContent).toContain('Jane Smith'); + }); + }); diff --git a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts index 0132f9b05b1..1b8c9fe8887 100644 --- a/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts +++ b/src/app/shared/object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component.ts @@ -3,11 +3,14 @@ import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; import { VALUE_LIST_BROWSE_DEFINITION } from '../../../../core/shared/value-list-browse-definition.resource-type'; +import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; +import { hasValue } from '../../../empty.util'; import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component'; @Component({ selector: 'ds-plain-text-metadata-list-element', templateUrl: './plain-text-metadata-list-element.component.html', + styleUrls: ['./plain-text-metadata-list-element.component.scss'], standalone: true, imports: [ RouterLink, @@ -31,4 +34,24 @@ export class PlainTextMetadataListElementComponent extends MetadataRepresentatio } return queryParams; } + + /** + * Check if this metadata representation has an ORCID authority + */ + isOrcidAuthority(): boolean { + const metadatum = this.mdRepresentation as MetadatumRepresentation; + if (!hasValue(metadatum.authority)) { + return false; + } + const orcidPattern = /^\d{4}-\d{4}-\d{4}-\d{4}$/; + return orcidPattern.test(metadatum.authority); + } + + /** + * Get the ORCID profile URL + */ + getOrcidUrl(): string { + const metadatum = this.mdRepresentation as MetadatumRepresentation; + return `https://orcid.org/${encodeURIComponent(metadatum.authority)}`; + } }