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
@@ -1,4 +1,4 @@
import {

Check failure on line 1 in src/app/core/submission/vocabularies/vocabulary.service.spec.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

Run autofix to sort these imports!

Check failure on line 1 in src/app/core/submission/vocabularies/vocabulary.service.spec.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

Run autofix to sort these imports!
cold,
getTestScheduler,
hot,
Expand Down Expand Up @@ -30,6 +30,7 @@
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;
Expand All @@ -39,6 +40,7 @@
let objectCache: ObjectCacheService;
let halService: HALEndpointService;
let hrefOnlyDataService: HrefOnlyDataService;
let externalSourceDataService: ExternalSourceDataService;
let responseCacheEntry: RequestEntry;

const vocabulary: any = {
Expand Down Expand Up @@ -211,11 +213,13 @@
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,
);
}

Expand Down
52 changes: 51 additions & 1 deletion src/app/core/submission/vocabularies/vocabulary.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';

Check failure on line 1 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

Run autofix to sort these imports!

Check failure on line 1 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

Run autofix to sort these imports!
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';

Check failure on line 2 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

'of' is defined but never used

Check failure on line 2 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

Imports must be broken into multiple lines if there are more than 1 elements

Check failure on line 2 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

'of' is defined but never used

Check failure on line 2 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

Imports must be broken into multiple lines if there are more than 1 elements
import {
catchError,
map,
mergeMap,
switchMap,
Expand All @@ -13,7 +14,7 @@
} from '../../../shared/utils/follow-link-config.model';
import { RequestParam } from '../../cache/models/request-param.model';
import { FindListOptions } from '../../data/find-list-options.model';
import { PaginatedList } from '../../data/paginated-list.model';

Check failure on line 17 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

'../../data/paginated-list.model' imported multiple times

Check failure on line 17 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

'../../data/paginated-list.model' imported multiple times
import { RemoteData } from '../../data/remote-data';
import { RequestService } from '../../data/request.service';
import {
Expand All @@ -28,6 +29,12 @@
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';

Check failure on line 37 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

'../../data/paginated-list.model' imported multiple times

Check failure on line 37 in src/app/core/submission/vocabularies/vocabulary.service.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

'../../data/paginated-list.model' imported multiple times

/**
* A service responsible for fetching/sending data from/to the REST API on the vocabularies endpoint
Expand All @@ -40,6 +47,7 @@
protected requestService: RequestService,
protected vocabularyDataService: VocabularyDataService,
protected vocabularyEntryDetailDataService: VocabularyEntryDetailsDataService,
protected externalSourceDataService: ExternalSourceDataService,
) {
}

Expand Down Expand Up @@ -149,6 +157,48 @@
* Return an observable that emits object list
*/
getVocabularyEntriesByValue(value: string, exact: boolean, vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable<RemoteData<PaginatedList<VocabularyEntry>>> {
// 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<PaginatedList<ExternalSourceEntry>>) => {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\"', () => {
Expand Down
62 changes: 45 additions & 17 deletions src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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));
}
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {

Check failure on line 1 in src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

Run autofix to sort these imports!

Check failure on line 1 in src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

Run autofix to sort these imports!
AsyncPipe,
NgClass,
} from '@angular/common';
Expand Down Expand Up @@ -57,6 +57,7 @@
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';

/**
Expand Down Expand Up @@ -125,6 +126,16 @@
super();
}

private static readonly KNOWN_AUTHORITY_FIELDS = [

Check failure on line 129 in src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

Member KNOWN_AUTHORITY_FIELDS should be declared before all instance field definitions

Check failure on line 129 in src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

Member KNOWN_AUTHORITY_FIELDS should be declared before all instance field definitions
'dc.contributor.author',
'dc.creator',
'dc.contributor.editor',
'dc.contributor.advisor',
'dc.contributor.other',
'dcterms.creator',
'dcterms.contributor',
];

ngOnInit(): void {
this.initAuthorityProperties();
}
Expand Down Expand Up @@ -163,15 +174,16 @@
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 ? {
Expand Down Expand Up @@ -224,17 +236,22 @@
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();
}
});
}
}
}
}
Expand Down Expand Up @@ -272,14 +289,16 @@
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);
}
}
Expand Down Expand Up @@ -308,9 +327,11 @@
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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { Injectable } from '@angular/core';
import {
Observable,
of,
of as observableOf,

Check failure on line 4 in src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts

View workflow job for this annotation

GitHub Actions / tests (18.x)

This import should not use an alias

Check failure on line 4 in src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value-field/dso-edit-metadata-field.service.ts

View workflow job for this annotation

GitHub Actions / tests (20.x)

This import should not use an alias
} 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
Expand All @@ -24,33 +29,26 @@
})
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<Vocabulary> {
if (isNotEmpty(mdField)) {
const owningCollection$: Observable<Collection> = 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);
}
}
Loading
Loading