-
- @if (searching || loadingInitialValue) {
-
- }
- @if (!searching && !loadingInitialValue) {
-
+
}
@@ -89,3 +112,18 @@
(keyup)="$event.preventDefault()">
}
+
+@if (additionalInfoSelectIsOpen) {
+
+
{{'form.other-information.selection.' + otherInfoKey | translate}}
+
+ @for (info of otherInfoValues; track info) {
+
+ -
+ {{info}}
+
+
+ }
+
+
+}
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss
index 4f09ab6c1a4..3d73f4549d1 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss
@@ -37,3 +37,38 @@
right: 0;
transform: translateY(-50%)
}
+
+.source-icon {
+ height: 20px
+}
+
+
+.tree-toggle {
+ padding: 0.70rem 0.70rem 0 0.70rem ;
+}
+
+.tree-input[readonly]{
+ background-color: #fff;
+ cursor: pointer;
+}
+
+.additional-items-icon {
+ padding-right: 5rem !important;
+ cursor: pointer;
+}
+.additional-info-selection {
+ z-index: 9999;
+ width: calc(100% - 10px);
+ border-radius: 4px;
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.175);
+ .list-item {
+ cursor: pointer;
+ }
+ .list-item:hover {
+ background-color: var(--bs-dropdown-link-hover-bg);
+ }
+}
+
+.list-item img {
+ height: 20px
+}
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts
index 8a62addfb01..cf6a7154e0a 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts
@@ -20,7 +20,9 @@ import {
UntypedFormGroup,
} from '@angular/forms';
import { By } from '@angular/platform-browser';
+import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type';
import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model';
+import { Vocabulary } from '@dspace/core/submission/vocabularies/models/vocabulary.model';
import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model';
import { VocabularyOptions } from '@dspace/core/submission/vocabularies/models/vocabulary-options.model';
import { VocabularyService } from '@dspace/core/submission/vocabularies/vocabulary.service';
@@ -28,6 +30,7 @@ import {
mockDynamicFormLayoutService,
mockDynamicFormValidationService,
} from '@dspace/core/testing/dynamic-form-mock-services';
+import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub';
import { createTestComponent } from '@dspace/core/testing/utils.test';
import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub';
import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
@@ -40,22 +43,29 @@ import {
DynamicFormsCoreModule,
DynamicFormValidationService,
} from '@ng-dynamic-forms/core';
+import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { getTestScheduler } from 'jasmine-marbles';
import { of } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
+import { SubmissionService } from 'src/app/submission/submission.service';
+import { v4 as uuidv4 } from 'uuid';
import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe';
import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive';
import { VocabularyTreeviewComponent } from '../../../../vocabulary-treeview/vocabulary-treeview.component';
+import { FormBuilderService } from '../../../form-builder.service';
import { DsDynamicOneboxComponent } from './dynamic-onebox.component';
import { DynamicOneboxModel } from './dynamic-onebox.model';
+
export let ONEBOX_TEST_GROUP;
export let ONEBOX_TEST_MODEL_CONFIG;
+const validAuthority = uuidv4();
+
// Mock class for NgbModalRef
export class MockNgbModalRef {
componentInstance = {
@@ -100,7 +110,7 @@ describe('DsDynamicOneboxComponent test suite', () => {
let modalService: any;
let html;
let modal;
- const vocabulary = {
+ const vocabulary = Object.assign(new Vocabulary(), {
id: 'vocabulary',
name: 'vocabulary',
scrollable: true,
@@ -115,9 +125,9 @@ describe('DsDynamicOneboxComponent test suite', () => {
url: 'entries',
},
},
- };
+ });
- const hierarchicalVocabulary = {
+ const hierarchicalVocabulary = Object.assign(new Vocabulary(), {
id: 'hierarchicalVocabulary',
name: 'hierarchicalVocabulary',
scrollable: true,
@@ -132,7 +142,7 @@ describe('DsDynamicOneboxComponent test suite', () => {
url: 'entries',
},
},
- };
+ });
// waitForAsync beforeEach
beforeEach(() => {
@@ -167,6 +177,10 @@ describe('DsDynamicOneboxComponent test suite', () => {
{ provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService },
{ provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService },
{ provide: NgbModal, useValue: modal },
+ { provide: FormBuilderService },
+ { provide: SubmissionService, useClass: SubmissionServiceStub },
+ { provide: APP_DATA_SERVICES_MAP, useValue: {} },
+ provideMockStore({ initialState: { core: { index: { } } } }),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
@@ -362,13 +376,13 @@ describe('DsDynamicOneboxComponent test suite', () => {
oneboxComponent.group = ONEBOX_TEST_GROUP;
oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG);
const entry = of(Object.assign(new VocabularyEntry(), {
- authority: 'test001',
+ authority: validAuthority,
value: 'test001',
display: 'test',
}));
spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry);
spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry);
- (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, 'test001');
+ (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, validAuthority, 'test001');
oneboxCompFixture.detectChanges();
});
@@ -379,7 +393,7 @@ describe('DsDynamicOneboxComponent test suite', () => {
it('should init component properly', fakeAsync(() => {
tick();
- expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test001', null, 'test001', 'test'));
+ expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, validAuthority, 'test001'));
expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByID).toHaveBeenCalled();
}));
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 8b3901106e7..c45e0767d35 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
@@ -37,11 +37,13 @@ import {
import {
NgbModal,
NgbModalRef,
+ NgbTooltipModule,
NgbTypeahead,
NgbTypeaheadModule,
NgbTypeaheadSelectItemEvent,
} from '@ng-bootstrap/ng-bootstrap';
import {
+ DynamicFormControlCustomEvent,
DynamicFormLayoutService,
DynamicFormValidationService,
} from '@ng-dynamic-forms/core';
@@ -64,9 +66,12 @@ import {
tap,
} from 'rxjs/operators';
+import { environment } from '../../../../../../../environments/environment';
+import { SubmissionService } from '../../../../../../submission/submission.service';
import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe';
import { AuthorityConfidenceStateDirective } from '../../../../directives/authority-confidence-state.directive';
import { VocabularyTreeviewModalComponent } from '../../../../vocabulary-treeview-modal/vocabulary-treeview-modal.component';
+import { FormBuilderService } from '../../../form-builder.service';
import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component';
import { DynamicOneboxModel } from './dynamic-onebox.model';
@@ -82,6 +87,7 @@ import { DynamicOneboxModel } from './dynamic-onebox.model';
AsyncPipe,
AuthorityConfidenceStateDirective,
FormsModule,
+ NgbTooltipModule,
NgbTypeaheadModule,
NgTemplateOutlet,
ObjNgFor,
@@ -96,6 +102,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
@Output() blur: EventEmitter
= new EventEmitter();
@Output() change: EventEmitter = new EventEmitter();
@Output() focus: EventEmitter = new EventEmitter();
+ @Output() customEvent: EventEmitter = new EventEmitter();
@ViewChild('instance') instance: NgbTypeahead;
@@ -106,10 +113,14 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false));
click$ = new Subject();
currentValue: any;
+ previousValue: any;
inputValue: any;
preloadLevel: number;
+ additionalInfoSelectIsOpen = false;
+ alternativeNamesKey = 'alternative-names';
+ authorithyIcons = environment.submission.icons.authority.sourceIcons;
+
- private vocabulary$: Observable;
private isHierarchicalVocabulary$: Observable;
private subs: Subscription[] = [];
@@ -118,8 +129,10 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
protected layoutService: DynamicFormLayoutService,
protected modalService: NgbModal,
protected validationService: DynamicFormValidationService,
+ protected formBuilderService: FormBuilderService,
+ protected submissionService: SubmissionService,
) {
- super(vocabularyService, layoutService, validationService);
+ super(vocabularyService, layoutService, validationService, formBuilderService, modalService, submissionService);
}
/**
@@ -169,19 +182,18 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
* Initialize the component, setting up the init form value
*/
ngOnInit() {
- if (this.model.value) {
- this.setCurrentValue(this.model.value, true);
- }
-
- this.vocabulary$ = this.vocabularyService.findVocabularyById(this.model.vocabularyOptions.name).pipe(
- getFirstSucceededRemoteDataPayload(),
- distinctUntilChanged(),
- );
-
+ this.initVocabulary();
this.isHierarchicalVocabulary$ = this.vocabulary$.pipe(
- map((result: Vocabulary) => result.hierarchical),
+ filter((vocabulary: Vocabulary) => isNotEmpty(vocabulary)),
+ map((vocabulary: Vocabulary) => vocabulary.hierarchical),
);
+ this.subs.push(this.isHierarchicalVocabulary$.subscribe((isHierarchical) => {
+ if (this.model.value) {
+ this.setCurrentValue(this.model.value, isHierarchical);
+ }
+ }));
+
this.subs.push(this.group.get(this.model.id).valueChanges.pipe(
filter((value) => this.currentValue !== value))
.subscribe((value) => {
@@ -268,8 +280,23 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
*/
onSelectItem(event: NgbTypeaheadSelectItemEvent) {
this.inputValue = null;
- this.setCurrentValue(event.item);
- this.dispatchUpdate(event.item);
+ const item = event.item;
+
+ if ( hasValue(item.otherInformation)) {
+ const otherInfoKeys = Object.keys(item.otherInformation).filter((key) => !key.startsWith('data'));
+ const hasMultipleValues = otherInfoKeys.some(key => hasValue(item.otherInformation[key]) && item.otherInformation[key].includes('|||'));
+
+ if (hasMultipleValues) {
+ this.setMultipleValuesForOtherInfo(otherInfoKeys, item);
+ } else {
+ this.resetMultipleValuesForOtherInfo();
+ }
+ } else {
+ this.resetMultipleValuesForOtherInfo();
+ }
+
+ this.setCurrentValue(item);
+ this.dispatchUpdate(item);
}
/**
@@ -293,6 +320,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
modalRef.result.then((result: VocabularyEntryDetail) => {
if (result) {
this.currentValue = result;
+ this.previousValue = result;
this.dispatchUpdate(result);
}
}, () => {
@@ -323,19 +351,156 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
.subscribe((formValue: FormFieldMetadataValueObject) => {
this.changeLoadingInitialValueStatus(false);
this.currentValue = formValue;
+ this.previousValue = formValue;
this.cdr.detectChanges();
});
} else {
if (isEmpty(value)) {
result = '';
} else {
- result = value.value;
+ result = value;
}
+ this.currentValue = null;
+ this.cdr.detectChanges();
this.currentValue = result;
+ this.previousValue = result;
this.cdr.detectChanges();
}
+ if (hasValue(this.currentValue?.otherInformation)) {
+ const infoKeys = Object.keys(this.currentValue.otherInformation);
+ this.setMultipleValuesForOtherInfo(infoKeys, this.currentValue);
+ }
+ }
+
+ /**
+ * Get the other information value removing the authority section (after the last ::)
+ * @param itemValue the initial item value
+ * @param itemKey
+ */
+ getOtherInfoValue(itemValue: string, itemKey: string): string {
+ if (!itemValue || !itemValue.includes('::')) {
+ return itemValue;
+ }
+
+ if (itemValue.includes('|||')) {
+ let result = '';
+ const values = itemValue.split('|||').map(item => item.substring(0, item.lastIndexOf('::')));
+ const lastIndex = values.length - 1;
+ values.forEach((value, i) => result += i === lastIndex ? value : value + ' · ');
+ return result;
+ }
+
+ return itemValue.substring(0, itemValue.lastIndexOf('::'));
+ }
+
+ toggleOtherInfoSelection() {
+ this.additionalInfoSelectIsOpen = !this.additionalInfoSelectIsOpen;
+ }
+
+ selectAlternativeInfo(info: string) {
+ this.searching = true;
+
+ if (this.otherInfoKey !== this.alternativeNamesKey) {
+ this.otherInfoValue = info;
+ } else {
+ this.otherName = info;
+ }
+
+ const temp = this.createVocabularyObject(info, info, this.currentValue.otherInformation);
+ this.currentValue = null;
+ this.currentValue = temp;
+
+ const unformattedOtherInfoValue = this.otherInfoValuesUnformatted.find((unformattedItem) => {
+ return unformattedItem.startsWith(info);
+ });
+
+ if (hasValue(unformattedOtherInfoValue)) {
+ const lastIndexOfSeparator = unformattedOtherInfoValue.lastIndexOf('::');
+ if (lastIndexOfSeparator !== -1) {
+ this.currentValue.authority = unformattedOtherInfoValue.substring(lastIndexOfSeparator + 2);
+ } else {
+ this.currentValue.authority = undefined;
+ }
+ }
+
+ const event = {
+ item: this.currentValue,
+ } as any;
+
+ this.onSelectItem(event);
+ this.searching = false;
+ this.toggleOtherInfoSelection();
+ }
+
+
+ setMultipleValuesForOtherInfo(keys: string[], item: any) {
+ const hasAlternativeNames = keys.includes(this.alternativeNamesKey);
+
+ this.otherInfoKey = hasAlternativeNames ? this.alternativeNamesKey : keys.find(key => hasValue(item.otherInformation[key]) && item.otherInformation[key].includes('|||'));
+ this.otherInfoValuesUnformatted = item.otherInformation[this.otherInfoKey] ? item.otherInformation[this.otherInfoKey].split('|||') : [];
+
+ this.otherInfoValues = this.otherInfoValuesUnformatted.map(unformattedItem => {
+ let lastIndexOfSeparator = unformattedItem.lastIndexOf('::');
+ if (lastIndexOfSeparator === -1) {
+ lastIndexOfSeparator = undefined;
+ }
+ return unformattedItem.substring(0, lastIndexOfSeparator);
+ });
+
+ if (hasAlternativeNames) {
+ this.otherName = hasValue(this.otherName) ? this.otherName : this.otherInfoValues[0];
+ }
+
+ if (keys.length > 1) {
+ this.otherInfoValue = hasValue(this.otherInfoValue) ? this.otherInfoValue : this.otherInfoValues[0];
+ }
+ }
+
+ resetMultipleValuesForOtherInfo() {
+ this.otherInfoKey = undefined;
+ this.otherInfoValuesUnformatted = [];
+ this.otherInfoValues = [];
+ this.otherInfoValue = undefined;
+ this.otherName = undefined;
+ }
+
+ createVocabularyObject(display, value, otherInformation) {
+ return Object.assign(new VocabularyEntry(), this.model.value, {
+ display: display,
+ value: value,
+ otherInformation: otherInformation,
+ type: 'vocabularyEntry',
+ });
+ }
+
+
+ /**
+ * Hide image on error
+ * @param image
+ */
+ handleImgError(image: HTMLElement): void {
+ image.style.display = 'none';
+ }
+
+ /**
+ * Get configured icon for each authority source
+ * @param source
+ */
+ getAuthoritySourceIcon(source: string, image: HTMLElement): string {
+ if (hasValue(this.authorithyIcons)) {
+ const iconPath = this.authorithyIcons.find(icon => icon.source === source)?.path;
+
+ if (!hasValue(iconPath)) {
+ this.handleImgError(image);
+ }
+
+ return iconPath;
+ } else {
+ this.handleImgError(image);
+ }
+ return '';
}
ngOnDestroy(): void {
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts
index bce1dc5f19d..5213f64d813 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts
@@ -14,12 +14,14 @@ export const DYNAMIC_FORM_CONTROL_TYPE_ONEBOX = 'ONEBOX';
export interface DsDynamicOneboxModelConfig extends DsDynamicInputModelConfig {
minChars?: number;
value?: any;
+ submissionScope?: string;
}
export class DynamicOneboxModel extends DsDynamicInputModel {
@serializable() minChars: number;
@serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_ONEBOX;
+ @serializable() submissionScope: string;
constructor(config: DsDynamicOneboxModelConfig, layout?: DynamicFormControlLayout) {
@@ -27,6 +29,7 @@ export class DynamicOneboxModel extends DsDynamicInputModel {
this.autoComplete = AUTOCOMPLETE_OFF;
this.minChars = config.minChars || 3;
+ this.submissionScope = config.submissionScope;
}
}
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts
index a61c27f5405..43ac5796041 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts
@@ -1,6 +1,9 @@
import { FormRowModel } from '@dspace/core/config/models/config-submission-form.model';
import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from '@dspace/core/shared/form/ds-dynamic-form-constants';
+import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model';
+import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model';
import {
+ hasValue,
isEmpty,
isNull,
} from '@dspace/shared/utils/empty.util';
@@ -60,8 +63,8 @@ export class DynamicRelationGroupModel extends DsDynamicInputModel {
return (value.length === 1 && isNull(value[0][this.mandatoryField]));
}
- getGroupValue(): any[] {
- if (isEmpty(this.value)) {
+ getGroupValue(value?: any): any[] {
+ if (isEmpty(this.value) && isEmpty(value)) {
// If items is empty, last element has been removed
// so emit an empty value that allows to dispatch
// a remove JSON PATCH operation
@@ -72,6 +75,17 @@ export class DynamicRelationGroupModel extends DsDynamicInputModel {
emptyItem[field] = null;
});
return [emptyItem];
+ } else if ((this.value instanceof VocabularyEntry || this.value instanceof FormFieldMetadataValueObject) ||
+ (hasValue(value) && (value instanceof VocabularyEntry || value instanceof FormFieldMetadataValueObject))) {
+
+ const emptyItem = {};
+ emptyItem[this.mandatoryField] = hasValue(value) && (value instanceof VocabularyEntry || value instanceof FormFieldMetadataValueObject) ? value : this.value;
+ this.relationFields
+ .forEach((field) => {
+ emptyItem[field] = hasValue((this.value as any).otherInformation) ? (this.value as any).otherInformation[field] : null;
+ });
+
+ return [emptyItem];
}
return this.value as any[];
}
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts
index e7c3e7ed835..c5796307369 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts
@@ -28,20 +28,27 @@ import {
mockDynamicFormLayoutService,
mockDynamicFormValidationService,
} from '@dspace/core/testing/dynamic-form-mock-services';
+import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub';
import {
createTestComponent,
hasClass,
} from '@dspace/core/testing/utils.test';
import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub';
-import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
+import {
+ NgbModal,
+ NgbModule,
+} from '@ng-bootstrap/ng-bootstrap';
import {
DynamicFormLayoutService,
DynamicFormsCoreModule,
DynamicFormValidationService,
} from '@ng-dynamic-forms/core';
+import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
+import { SubmissionService } from 'src/app/submission/submission.service';
+import { FormBuilderService } from '../../../form-builder.service';
import { DsDynamicScrollableDropdownComponent } from './dynamic-scrollable-dropdown.component';
import { DynamicScrollableDropdownModel } from './dynamic-scrollable-dropdown.model';
@@ -102,7 +109,11 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => {
{ provide: VocabularyService, useValue: vocabularyServiceStub },
{ provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService },
{ provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService },
+ { provide: FormBuilderService },
+ { provide: SubmissionService, useClass: SubmissionServiceStub },
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
+ NgbModal,
+ provideMockStore(),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
});
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts
index 0e287236f6e..4ce9c341acf 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts
@@ -35,8 +35,10 @@ import {
import {
NgbDropdown,
NgbDropdownModule,
+ NgbModal,
} from '@ng-bootstrap/ng-bootstrap';
import {
+ DynamicFormControlCustomEvent,
DynamicFormLayoutService,
DynamicFormValidationService,
} from '@ng-dynamic-forms/core';
@@ -53,11 +55,12 @@ import {
take,
tap,
} from 'rxjs/operators';
+import { FormBuilderService } from 'src/app/shared/form/builder/form-builder.service';
+import { SubmissionService } from 'src/app/submission/submission.service';
import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component';
import { DynamicScrollableDropdownModel } from './dynamic-scrollable-dropdown.model';
-
/**
* Component representing a dropdown input field
*/
@@ -82,6 +85,7 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
@Output() blur: EventEmitter = new EventEmitter();
@Output() change: EventEmitter = new EventEmitter();
@Output() focus: EventEmitter = new EventEmitter();
+ @Output() customEvent: EventEmitter = new EventEmitter();
public currentValue: Observable;
public loading = false;
@@ -111,10 +115,13 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
protected cdr: ChangeDetectorRef,
protected layoutService: DynamicFormLayoutService,
protected validationService: DynamicFormValidationService,
+ protected formBuilderService: FormBuilderService,
+ protected modalService: NgbModal,
+ protected submissionService: SubmissionService,
protected parentInjector: Injector,
@Inject(APP_DATA_SERVICES_MAP) private dataServiceMap: LazyDataServicesMap,
) {
- super(vocabularyService, layoutService, validationService);
+ super(vocabularyService, layoutService, validationService, formBuilderService, modalService, submissionService);
}
/**
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts
index 0df3ff9796e..6372a04092a 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts
@@ -18,6 +18,7 @@ import {
UntypedFormControl,
UntypedFormGroup,
} from '@angular/forms';
+import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type';
import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model';
import { VocabularyEntry } from '@dspace/core/submission/vocabularies/models/vocabulary-entry.model';
import { VocabularyOptions } from '@dspace/core/submission/vocabularies/models/vocabulary-options.model';
@@ -26,6 +27,7 @@ import {
mockDynamicFormLayoutService,
mockDynamicFormValidationService,
} from '@dspace/core/testing/dynamic-form-mock-services';
+import { SubmissionServiceStub } from '@dspace/core/testing/submission-service.stub';
import { createTestComponent } from '@dspace/core/testing/utils.test';
import { VocabularyServiceStub } from '@dspace/core/testing/vocabulary-service.stub';
import {
@@ -37,10 +39,13 @@ import {
DynamicFormsCoreModule,
DynamicFormValidationService,
} from '@ng-dynamic-forms/core';
+import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
+import { SubmissionService } from 'src/app/submission/submission.service';
import { Chips } from '../../../../chips/models/chips.model';
+import { FormBuilderService } from '../../../form-builder.service';
import { DsDynamicTagComponent } from './dynamic-tag.component';
import { DynamicTagModel } from './dynamic-tag.model';
@@ -112,6 +117,10 @@ describe('DsDynamicTagComponent test suite', () => {
{ provide: VocabularyService, useValue: vocabularyServiceStub },
{ provide: DynamicFormLayoutService, useValue: mockDynamicFormLayoutService },
{ provide: DynamicFormValidationService, useValue: mockDynamicFormValidationService },
+ { provide: FormBuilderService },
+ { provide: SubmissionService, useClass: SubmissionServiceStub },
+ provideMockStore(),
+ { provide: APP_DATA_SERVICES_MAP, useValue: {} },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
});
@@ -232,9 +241,9 @@ describe('DsDynamicTagComponent test suite', () => {
tagComp.group = TAG_TEST_GROUP;
tagComp.model = new DynamicTagModel(TAG_TEST_MODEL_CONFIG);
modelValue = [
- new FormFieldMetadataValueObject('a', null, 'test001'),
- new FormFieldMetadataValueObject('b', null, 'test002'),
- new FormFieldMetadataValueObject('c', null, 'test003'),
+ new FormFieldMetadataValueObject('a', null, null, 'test001'),
+ new FormFieldMetadataValueObject('b', null, null, 'test002'),
+ new FormFieldMetadataValueObject('c', null, null, 'test003'),
];
tagComp.model.value = modelValue;
tagFixture.detectChanges();
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts
index 9270ca61ed9..2f053cfc80c 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts
@@ -25,11 +25,13 @@ import {
isNotEmpty,
} from '@dspace/shared/utils/empty.util';
import {
+ NgbModal,
NgbTypeahead,
NgbTypeaheadModule,
NgbTypeaheadSelectItemEvent,
} from '@ng-bootstrap/ng-bootstrap';
import {
+ DynamicFormControlCustomEvent,
DynamicFormLayoutService,
DynamicFormValidationService,
} from '@ng-dynamic-forms/core';
@@ -49,8 +51,10 @@ import {
} from 'rxjs/operators';
import { environment } from '../../../../../../../environments/environment';
+import { SubmissionService } from '../../../../../../submission/submission.service';
import { ChipsComponent } from '../../../../chips/chips.component';
import { Chips } from '../../../../chips/models/chips.model';
+import { FormBuilderService } from '../../../form-builder.service';
import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component';
import { DynamicTagModel } from './dynamic-tag.model';
@@ -76,6 +80,7 @@ export class DsDynamicTagComponent extends DsDynamicVocabularyComponent implemen
@Output() blur: EventEmitter = new EventEmitter();
@Output() change: EventEmitter = new EventEmitter();
@Output() focus: EventEmitter = new EventEmitter();
+ @Output() customEvent: EventEmitter = new EventEmitter();
@ViewChild('instance') instance: NgbTypeahead;
@@ -92,8 +97,11 @@ export class DsDynamicTagComponent extends DsDynamicVocabularyComponent implemen
private cdr: ChangeDetectorRef,
protected layoutService: DynamicFormLayoutService,
protected validationService: DynamicFormValidationService,
+ protected formBuilderService: FormBuilderService,
+ protected modalService: NgbModal,
+ protected submissionService: SubmissionService,
) {
- super(vocabularyService, layoutService, validationService);
+ super(vocabularyService, layoutService, validationService, formBuilderService, modalService, submissionService);
}
/**
diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts
index 7645e329c5f..ce1b209d335 100644
--- a/src/app/shared/form/builder/form-builder.service.spec.ts
+++ b/src/app/shared/form/builder/form-builder.service.spec.ts
@@ -102,6 +102,8 @@ describe('FormBuilderService test suite', () => {
const vocabularyOptions: VocabularyOptions = {
name: 'type_programme',
+ metadata: null,
+ scope: null,
closed: false,
};
diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts
index b7829896cfa..d3c4cee6563 100644
--- a/src/app/shared/form/builder/form-builder.service.ts
+++ b/src/app/shared/form/builder/form-builder.service.ts
@@ -4,6 +4,7 @@ import {
} from '@angular/core';
import {
AbstractControl,
+ FormArray,
UntypedFormControl,
UntypedFormGroup,
} from '@angular/forms';
@@ -54,7 +55,10 @@ import {
import { DsDynamicInputModel } from './ds-dynamic-form-ui/models/ds-dynamic-input.model';
import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model';
import { DynamicRowArrayModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-array-model';
-import { DynamicRelationGroupModel } from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model';
+import {
+ DynamicRelationGroupModel,
+ DynamicRelationGroupModelConfig,
+} from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model';
import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model';
import { RowParser } from './parsers/row-parser';
@@ -410,6 +414,15 @@ export class FormBuilderService extends DynamicFormService {
return (tempModel.id !== tempModel.name) ? tempModel.name : tempModel.id;
}
+ /**
+ * Add new form model to formModels map
+ * @param id id of model
+ * @param model model
+ */
+ addFormModel(id: string, model: DynamicFormControlModel[]): void {
+ this.formModels.set(id, model);
+ }
+
/**
* If present, remove form model from formModels map
* @param id id of model
@@ -439,6 +452,64 @@ export class FormBuilderService extends DynamicFormService {
}
}
+ /**
+ * This method searches a field in all forms instantiated
+ * by form.component and, if found, it updates its value
+ *
+ * @param fieldId id of field to update
+ * @param value new value to set
+ * @return the model updated if found
+ */
+ updateModelValue(fieldId: string, value: FormFieldMetadataValueObject): DynamicFormControlModel {
+ let returnModel = null;
+ [...this.formModels.keys()].forEach((formId) => {
+ const models = this.formModels.get(formId);
+ let fieldModel: any = this.findById(fieldId, models);
+ if (hasValue(fieldModel) && !fieldModel.hidden) {
+ const isIterable = (typeof value[Symbol.iterator] === 'function');
+ if (isNotEmpty(value)) {
+ if (fieldModel.repeatable && isNotEmpty(fieldModel.value) && !(!isIterable && fieldModel instanceof DynamicRelationGroupModel)) {
+ // if model is repeatable and has already a value add a new field instead of replacing it
+ const formGroup = this.formGroups.get(formId);
+ const arrayContext = fieldModel.parent?.context;
+ if (isNotEmpty(formGroup) && isNotEmpty(arrayContext)) {
+ const formArrayControl = this.getFormControlByModel(formGroup, arrayContext) as FormArray;
+ const index = arrayContext?.groups?.length;
+ this.insertFormArrayGroup(index, formArrayControl, arrayContext);
+ const newAddedModel: any = this.findById(fieldId, models, index);
+ this.detectChanges();
+ newAddedModel.value = value;
+ returnModel = newAddedModel;
+ }
+ } else {
+
+ if ((!isIterable && fieldModel instanceof DynamicRelationGroupModel) && isEmpty(fieldModel.value)) {
+ const config: DynamicRelationGroupModelConfig = {
+ submissionId: fieldModel.submissionId,
+ formConfiguration: fieldModel.formConfiguration,
+ mandatoryField: fieldModel.mandatoryField,
+ relationFields: fieldModel.relationFields,
+ scopeUUID: fieldModel.scopeUUID,
+ submissionScope: fieldModel.submissionScope,
+ repeatable: fieldModel.repeatable,
+ metadataFields: fieldModel.metadataFields,
+ hasSelectableMetadata: fieldModel.hasSelectableMetadata,
+ id: fieldModel.id,
+ value: fieldModel.getGroupValue(value),
+ };
+ fieldModel = new DynamicRelationGroupModel(config);
+ } else {
+ fieldModel.value = value;
+ }
+ returnModel = fieldModel;
+
+ }
+ }
+ }
+ });
+ return returnModel;
+ }
+
/**
* Calculate the metadata list related to the event.
* @param event
diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts
index 815fefe2afd..2ecd35a1843 100644
--- a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts
+++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts
@@ -35,7 +35,7 @@ export class DropdownFieldParser extends FieldParser {
let layout: DynamicFormControlLayout;
if (isNotEmpty(this.configData.selectableMetadata[0].controlledVocabulary)) {
- this.setVocabularyOptions(dropdownModelConfig);
+ this.setVocabularyOptions(dropdownModelConfig, this.parserOptions.collectionUUID);
if (isNotEmpty(fieldValue)) {
dropdownModelConfig.value = fieldValue;
}
diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts
index be849852e50..187cd6072ca 100644
--- a/src/app/shared/form/builder/parsers/field-parser.ts
+++ b/src/app/shared/form/builder/parsers/field-parser.ts
@@ -139,10 +139,12 @@ export abstract class FieldParser {
}
}
- public setVocabularyOptions(controlModel) {
+ public setVocabularyOptions(controlModel, scope) {
if (isNotEmpty(this.configData.selectableMetadata) && isNotEmpty(this.configData.selectableMetadata[0].controlledVocabulary)) {
controlModel.vocabularyOptions = new VocabularyOptions(
this.configData.selectableMetadata[0].controlledVocabulary,
+ this.configData.selectableMetadata[0].metadata,
+ scope,
this.configData.selectableMetadata[0].closed,
);
}
diff --git a/src/app/shared/form/builder/parsers/list-field-parser.ts b/src/app/shared/form/builder/parsers/list-field-parser.ts
index 4094f86b164..461ad4b9c3f 100644
--- a/src/app/shared/form/builder/parsers/list-field-parser.ts
+++ b/src/app/shared/form/builder/parsers/list-field-parser.ts
@@ -25,7 +25,7 @@ export class ListFieldParser extends FieldParser {
}
});
}
- this.setVocabularyOptions(listModelConfig);
+ this.setVocabularyOptions(listModelConfig, this.parserOptions.collectionUUID);
}
let listModel;
diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.ts
index a4c92df0eeb..6e2a48d5333 100644
--- a/src/app/shared/form/builder/parsers/lookup-field-parser.ts
+++ b/src/app/shared/form/builder/parsers/lookup-field-parser.ts
@@ -12,7 +12,7 @@ export class LookupFieldParser extends FieldParser {
if (this.configData.selectableMetadata[0].controlledVocabulary) {
const lookupModelConfig: DynamicLookupModelConfig = this.initModel(null, label);
- this.setVocabularyOptions(lookupModelConfig);
+ this.setVocabularyOptions(lookupModelConfig, this.parserOptions.collectionUUID);
this.setValues(lookupModelConfig, fieldValue, true);
diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts
index 72249ea3550..53bac998c01 100644
--- a/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts
+++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.ts
@@ -12,7 +12,7 @@ export class LookupNameFieldParser extends FieldParser {
if (this.configData.selectableMetadata[0].controlledVocabulary) {
const lookupModelConfig: DynamicLookupNameModelConfig = this.initModel(null, label);
- this.setVocabularyOptions(lookupModelConfig);
+ this.setVocabularyOptions(lookupModelConfig, this.parserOptions.collectionUUID);
this.setValues(lookupModelConfig, fieldValue, true);
diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.ts
index 16e0b30b44a..7cf25e99c79 100644
--- a/src/app/shared/form/builder/parsers/onebox-field-parser.ts
+++ b/src/app/shared/form/builder/parsers/onebox-field-parser.ts
@@ -78,14 +78,14 @@ export class OneboxFieldParser extends FieldParser {
}
selectModelConfig.disabled = inputModelConfig.readOnly;
inputSelectGroup.readOnly = selectModelConfig.disabled && inputModelConfig.readOnly;
-
+ inputSelectGroup.language = inputModelConfig.language;
inputSelectGroup.group.push(new DynamicSelectModel(selectModelConfig, clsSelect));
inputSelectGroup.group.push(new DsDynamicInputModel(inputModelConfig, clsInput));
return new DynamicQualdropModel(inputSelectGroup, clsGroup);
} else if (this.configData.selectableMetadata[0].controlledVocabulary) {
const oneboxModelConfig: DsDynamicOneboxModelConfig = this.initModel(null, label);
- this.setVocabularyOptions(oneboxModelConfig);
+ this.setVocabularyOptions(oneboxModelConfig, this.parserOptions.collectionUUID);
this.setValues(oneboxModelConfig, fieldValue, true);
return new DynamicOneboxModel(oneboxModelConfig);
diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.ts b/src/app/shared/form/builder/parsers/tag-field-parser.ts
index d782af1c143..7cead0de898 100644
--- a/src/app/shared/form/builder/parsers/tag-field-parser.ts
+++ b/src/app/shared/form/builder/parsers/tag-field-parser.ts
@@ -12,7 +12,7 @@ export class TagFieldParser extends FieldParser {
const tagModelConfig: DynamicTagModelConfig = this.initModel(null, label);
if (this.configData.selectableMetadata[0].controlledVocabulary
&& this.configData.selectableMetadata[0].controlledVocabulary.length > 0) {
- this.setVocabularyOptions(tagModelConfig);
+ this.setVocabularyOptions(tagModelConfig, this.parserOptions.collectionUUID);
}
this.setValues(tagModelConfig, fieldValue, null, true);
diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts
index 0ec572a6f98..557b7b5a98e 100644
--- a/src/app/shared/form/form.component.ts
+++ b/src/app/shared/form/form.component.ts
@@ -10,6 +10,7 @@ import {
} from '@angular/core';
import {
AbstractControl,
+ FormControl,
ReactiveFormsModule,
UntypedFormArray,
UntypedFormControl,
@@ -305,7 +306,17 @@ export class FormComponent implements OnDestroy, OnInit {
}
onCustomEvent(event: any) {
- this.customEvent.emit(event);
+ if (event?.type === 'authorityEnrichment') {
+ event.$event.updatedModels.forEach((model) => {
+ const control: FormControl = this.formBuilderService.getFormControlByModel(this.formGroup, model) as FormControl;
+ if (control) {
+ const changeEvent = this.formBuilderService.createDynamicFormControlEvent(control, control.parent as UntypedFormGroup, model, 'change');
+ this.onChange(changeEvent);
+ }
+ });
+ } else {
+ this.customEvent.emit(event);
+ }
}
onFocus(event: DynamicFormControlEvent): void {
diff --git a/src/app/shared/form/testing/form-builder-service.mock.ts b/src/app/shared/form/testing/form-builder-service.mock.ts
index 9ef34227d21..087bd7a5199 100644
--- a/src/app/shared/form/testing/form-builder-service.mock.ts
+++ b/src/app/shared/form/testing/form-builder-service.mock.ts
@@ -45,5 +45,9 @@ export function getMockFormBuilderService(): FormBuilderService {
],
},
),
+ removeFormModel: {},
+ addFormModel: {},
+ updateValue: {},
+ addFormGroups: {},
});
}
diff --git a/src/app/shared/form/testing/form-models.mock.ts b/src/app/shared/form/testing/form-models.mock.ts
index 087cff1a55f..36eca95325e 100644
--- a/src/app/shared/form/testing/form-models.mock.ts
+++ b/src/app/shared/form/testing/form-models.mock.ts
@@ -154,7 +154,7 @@ const relationGroupConfig = {
export const MockRelationModel: DynamicRelationGroupModel = new DynamicRelationGroupModel(relationGroupConfig);
export const inputWithLanguageAndAuthorityConfig = {
- vocabularyOptions: new VocabularyOptions('testAuthority', false),
+ vocabularyOptions: new VocabularyOptions('testAuthority', null, null, false),
languageCodes: [
{
display: 'English',
@@ -209,7 +209,7 @@ export const inputWithLanguageConfig = {
export const mockInputWithLanguageModel = new DsDynamicInputModel(inputWithLanguageConfig);
export const inputWithLanguageAndAuthorityArrayConfig = {
- vocabularyOptions: new VocabularyOptions('testAuthority', false),
+ vocabularyOptions: new VocabularyOptions('testAuthority', null, null, false),
languageCodes: [
{
display: 'English',
diff --git a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts
index 6d3eaba24b1..d43ff3e744f 100644
--- a/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts
+++ b/src/app/shared/form/vocabulary-treeview-modal/vocabulary-treeview-modal.component.spec.ts
@@ -14,7 +14,7 @@ describe('VocabularyTreeviewModalComponent', () => {
let fixture: ComponentFixture;
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
- const vocabularyOptions = new VocabularyOptions('vocabularyTest', false);
+ const vocabularyOptions = new VocabularyOptions('vocabularyTest', null, null, false);
beforeEach(async () => {
await TestBed.configureTestingModule({
diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.spec.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.spec.ts
index d9b45aea026..08f407363df 100644
--- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.spec.ts
+++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.component.spec.ts
@@ -45,7 +45,7 @@ describe('VocabularyTreeviewComponent test suite', () => {
const emptyNodeMap = new Map();
const storedNodeMap = new Map().set('test', new TreeviewFlatNode(item2));
const nodeMap = new Map().set('test', new TreeviewFlatNode(item));
- const vocabularyOptions = new VocabularyOptions('vocabularyTest', false);
+ const vocabularyOptions = new VocabularyOptions('vocabularyTest', null, null, false);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const vocabularyTreeviewServiceStub = jasmine.createSpyObj('VocabularyTreeviewService', {
initialize: jasmine.createSpy('initialize'),
@@ -292,7 +292,7 @@ describe('VocabularyTreeviewComponent test suite', () => {
})
class TestComponent {
- vocabularyOptions: VocabularyOptions = new VocabularyOptions('vocabularyTest', false);
+ vocabularyOptions: VocabularyOptions = new VocabularyOptions('vocabularyTest', null, null, false);
preloadLevel = 2;
}
diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.spec.ts
index f88cff2e75a..24060eb8746 100644
--- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.spec.ts
+++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.spec.ts
@@ -177,7 +177,7 @@ describe('VocabularyTreeviewService test suite', () => {
searchNodeMap = new Map([
[item.id, searchItemNode],
]);
- vocabularyOptions = new VocabularyOptions('vocabularyTest', false);
+ vocabularyOptions = new VocabularyOptions('vocabularyTest', null, null,false);
}
beforeEach(waitForAsync(() => {
diff --git a/src/app/shared/image.utils.ts b/src/app/shared/image.utils.ts
new file mode 100644
index 00000000000..a6070364220
--- /dev/null
+++ b/src/app/shared/image.utils.ts
@@ -0,0 +1,34 @@
+import {
+ Observable,
+ of,
+} from 'rxjs';
+import { map } from 'rxjs/operators';
+
+export const getDefaultImageUrlByEntityType = (entityType: string): Observable => {
+ const fallbackImage = 'assets/images/file-placeholder.svg';
+
+ if (!entityType) {
+ return of(fallbackImage);
+ }
+
+ const defaultImage = `assets/images/${entityType.toLowerCase()}-placeholder.svg`;
+ return checkImageExists(defaultImage).pipe(map((exists) => exists ? defaultImage : fallbackImage));
+};
+
+const checkImageExists = (url: string): Observable => {
+ return new Observable((observer) => {
+ const img = new Image();
+
+ img.onload = () => {
+ observer.next(true);
+ observer.complete();
+ };
+
+ img.onerror = () => {
+ observer.next(false);
+ observer.complete();
+ };
+
+ img.src = url;
+ });
+};
diff --git a/src/app/shared/menu/providers/bulk-import.menu.spec.ts b/src/app/shared/menu/providers/bulk-import.menu.spec.ts
new file mode 100644
index 00000000000..ffa7281a636
--- /dev/null
+++ b/src/app/shared/menu/providers/bulk-import.menu.spec.ts
@@ -0,0 +1,62 @@
+
+import { TestBed } from '@angular/core/testing';
+import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service';
+import { Collection } from '@dspace/core/shared/collection.model';
+import { COLLECTION } from '@dspace/core/shared/collection.resource-type';
+import { AuthorizationDataServiceStub } from '@dspace/core/testing/authorization-service.stub';
+import { of } from 'rxjs';
+
+import { MenuItemType } from '../menu-item-type.model';
+import { PartialMenuSection } from '../menu-provider.model';
+import { BulkImportMenuProvider } from './bulk-import.menu';
+
+describe('BulkImportMenuProvider', () => {
+
+ const expectedSections: PartialMenuSection[] = [
+ {
+ visible: true,
+ model: {
+ type: MenuItemType.LINK,
+ text: 'context-menu.actions.bulk-import.btn',
+ link: '/bulk-import/test-uuid',
+ },
+ },
+ ];
+
+ let provider: BulkImportMenuProvider;
+
+ const dso: Collection = Object.assign(new Collection(), {
+ type: COLLECTION.value,
+ id: 'test-uuid',
+ _links: { self: { href: 'self-link' } },
+ });
+
+
+ let authorizationServiceStub = new AuthorizationDataServiceStub();
+
+ beforeEach(() => {
+ spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue(
+ of(true),
+ );
+ TestBed.configureTestingModule({
+ providers: [
+ BulkImportMenuProvider,
+ { provide: AuthorizationDataService, useValue: authorizationServiceStub },
+ ],
+ });
+ provider = TestBed.inject(BulkImportMenuProvider);
+ });
+
+ it('should be created', () => {
+ expect(provider).toBeTruthy();
+ });
+
+ describe('getSectionsForContext', () => {
+ it('should return the expected sections', (done) => {
+ provider.getSectionsForContext(dso).subscribe((sections) => {
+ expect(sections).toEqual(expectedSections);
+ done();
+ });
+ });
+ });
+});
diff --git a/src/app/shared/menu/providers/bulk-import.menu.ts b/src/app/shared/menu/providers/bulk-import.menu.ts
new file mode 100644
index 00000000000..02075dc4422
--- /dev/null
+++ b/src/app/shared/menu/providers/bulk-import.menu.ts
@@ -0,0 +1,49 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+import { Injectable } from '@angular/core';
+import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service';
+import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id';
+import { Collection } from '@dspace/core/shared/collection.model';
+import { DSpaceObject } from '@dspace/core/shared/dspace-object.model';
+import {
+ map,
+ Observable,
+} from 'rxjs';
+import { getBulkImportRoute } from 'src/app/app-routing-paths';
+
+import { LinkMenuItemModel } from '../menu-item/models/link.model';
+import { MenuItemType } from '../menu-item-type.model';
+import { PartialMenuSection } from '../menu-provider.model';
+import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu';
+
+/**
+ * Menu provider to create the "Bulk import" option in the collection menu
+ */
+@Injectable()
+export class BulkImportMenuProvider extends DSpaceObjectPageMenuProvider {
+ constructor(
+ protected authorizationDataService: AuthorizationDataService,
+ ) {
+ super();
+ }
+
+ public getSectionsForContext(dso: DSpaceObject): Observable {
+ return this.authorizationDataService.isAuthorized(FeatureID.AdministratorOf, dso.self, undefined, false).pipe(
+ map((isAuthorized: boolean) => {
+ return [{
+ model: {
+ type: MenuItemType.LINK,
+ text: 'context-menu.actions.bulk-import.btn',
+ link: getBulkImportRoute(dso as Collection),
+ } as LinkMenuItemModel,
+ visible: isAuthorized,
+ }] as PartialMenuSection[];
+ }),
+ );
+ }
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.html b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.html
new file mode 100644
index 00000000000..7a19f47d347
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.html
@@ -0,0 +1,21 @@
+
+ @if (isLoading()) {
+
+ }
+
+ @if (src() !== null) {
+
![]()
+ }
+ @if (src() === null && isLoading() === false) {
+
+
![]()
+
+ }
+
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.scss b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.scss
new file mode 100644
index 00000000000..598ca7ceed4
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.scss
@@ -0,0 +1,10 @@
+:host{
+ img {
+ height: 80px;
+ width: 80px;
+ min-width: 80px;
+ border: 1px solid #ccc;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.spec.ts b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.spec.ts
new file mode 100644
index 00000000000..472283ceedd
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.spec.ts
@@ -0,0 +1,82 @@
+import {
+ ComponentFixture,
+ TestBed,
+ waitForAsync,
+} from '@angular/core/testing';
+import { AuthService } from '@dspace/core/auth/auth.service';
+import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service';
+import { FileService } from '@dspace/core/shared/file.service';
+import { TranslateModule } from '@ngx-translate/core';
+import { of } from 'rxjs';
+
+import { ThemedLoadingComponent } from '../../loading/themed-loading.component';
+import { MetadataLinkViewAvatarPopoverComponent } from './metadata-link-view-avatar-popover.component';
+
+describe('MetadataLinkViewAvatarPopoverComponent', () => {
+ let component: MetadataLinkViewAvatarPopoverComponent;
+ let fixture: ComponentFixture;
+ let authService;
+ let authorizationService;
+ let fileService;
+
+ beforeEach(waitForAsync(() => {
+ authService = jasmine.createSpyObj('AuthService', {
+ isAuthenticated: of(true),
+ });
+ authorizationService = jasmine.createSpyObj('AuthorizationService', {
+ isAuthorized: of(true),
+ });
+ fileService = jasmine.createSpyObj('FileService', {
+ retrieveFileDownloadLink: null,
+ });
+
+ TestBed.configureTestingModule({
+ imports: [
+ MetadataLinkViewAvatarPopoverComponent,
+ TranslateModule.forRoot(),
+ ],
+ providers: [
+ { provide: AuthService, useValue: authService },
+ { provide: AuthorizationDataService, useValue: authorizationService },
+ { provide: FileService, useValue: fileService },
+ ],
+ })
+ .overrideComponent(MetadataLinkViewAvatarPopoverComponent, { remove: { imports: [ThemedLoadingComponent] } }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataLinkViewAvatarPopoverComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should set fallback image if no entity type', (done) => {
+ component.ngOnInit();
+ component.placeholderImageUrl$.subscribe((url) => {
+ expect(url).toBe('assets/images/file-placeholder.svg');
+ done();
+ });
+ });
+
+ it('should set correct placeholder image based on entity type if image exists', (done) => {
+ component.entityType = 'OrgUnit';
+ component.ngOnInit();
+ component.placeholderImageUrl$.subscribe((url) => {
+ expect(url).toBe('assets/images/orgunit-placeholder.svg');
+ done();
+ });
+ });
+
+ it('should set correct fallback image if image does not exists', (done) => {
+ component.entityType = 'missingEntityType';
+ component.ngOnInit();
+ component.placeholderImageUrl$.subscribe((url) => {
+ expect(url).toBe('assets/images/file-placeholder.svg');
+ done();
+ });
+ });
+});
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.ts b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.ts
new file mode 100644
index 00000000000..42fb08d9216
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component.ts
@@ -0,0 +1,46 @@
+import {
+ AsyncPipe,
+ NgClass,
+} from '@angular/common';
+import {
+ Component,
+ Input,
+ OnInit,
+} from '@angular/core';
+import { TranslateModule } from '@ngx-translate/core';
+import { Observable } from 'rxjs';
+import { ThumbnailComponent } from 'src/app/thumbnail/thumbnail.component';
+
+import { getDefaultImageUrlByEntityType } from '../../image.utils';
+import { ThemedLoadingComponent } from '../../loading/themed-loading.component';
+import { SafeUrlPipe } from '../../utils/safe-url-pipe';
+
+@Component({
+ selector: 'ds-metadata-link-view-avatar-popover',
+ templateUrl: './metadata-link-view-avatar-popover.component.html',
+ styleUrls: ['./metadata-link-view-avatar-popover.component.scss'],
+ imports: [
+ AsyncPipe,
+ NgClass,
+ SafeUrlPipe,
+ ThemedLoadingComponent,
+ TranslateModule,
+ ],
+})
+export class MetadataLinkViewAvatarPopoverComponent extends ThumbnailComponent implements OnInit {
+
+
+ /**
+ * Placeholder image url that changes based on entity type
+ */
+ placeholderImageUrl$: Observable;
+
+ /**
+ * The entity type of the item which the avatar belong
+ */
+ @Input() entityType: string;
+
+ ngOnInit() {
+ this.placeholderImageUrl$ = getDefaultImageUrlByEntityType(this.entityType);
+ }
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.html b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.html
new file mode 100644
index 00000000000..c62715fba13
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.html
@@ -0,0 +1,23 @@
+@let hasBadge = hasOrcidBadge();
+@let orcidUrl = orcidUrl$ | async;
+
+@if (hasBadge || orcidUrl) {
+
+ @if (orcidUrl) {
+
+ {{ metadataValue }}
+
+ } @else {
+ {{ metadataValue }}
+ }
+
+ @if (hasBadge) {
+
+ }
+
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.scss b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.scss
new file mode 100644
index 00000000000..b92a52cd35d
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.scss
@@ -0,0 +1,4 @@
+.orcid-icon {
+ height: 1.2rem;
+ padding-left: 0.3rem;
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.spec.ts b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.spec.ts
new file mode 100644
index 00000000000..cb89a89f67f
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.spec.ts
@@ -0,0 +1,74 @@
+import {
+ ComponentFixture,
+ TestBed,
+} from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service';
+import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock';
+import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
+import {
+ TranslateLoader,
+ TranslateModule,
+} from '@ngx-translate/core';
+import { Item } from 'src/app/core/shared/item.model';
+import { MetadataValue } from 'src/app/core/shared/metadata.models';
+
+import { MetadataLinkViewOrcidComponent } from './metadata-link-view-orcid.component';
+
+describe('MetadataLinkViewOrcidComponent', () => {
+ let component: MetadataLinkViewOrcidComponent;
+ let fixture: ComponentFixture;
+
+ const configurationDataService = jasmine.createSpyObj('configurationDataService', {
+ findByPropertyName: createSuccessfulRemoteDataObject$({ values: ['https://sandbox.orcid.org'] }),
+ });
+
+
+ const metadataValue = Object.assign(new MetadataValue(), {
+ 'value': '0000-0001-8918-3592',
+ 'language': 'en_US',
+ 'authority': null,
+ 'confidence': -1,
+ 'place': 0,
+ });
+
+ const testItem = Object.assign(new Item(),
+ {
+ type: 'item',
+ metadata: {
+ 'person.identifier.orcid': [metadataValue],
+ 'dspace.orcid.authenticated': [
+ {
+ language: null,
+ value: 'authenticated',
+ },
+ ],
+ },
+ uuid: 'test-item-uuid',
+ },
+ );
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: TranslateLoaderMock,
+ },
+ }), BrowserAnimationsModule, MetadataLinkViewOrcidComponent],
+ providers: [
+ { provide: ConfigurationDataService, useValue: configurationDataService },
+ ],
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(MetadataLinkViewOrcidComponent);
+ component = fixture.componentInstance;
+ component.itemValue = testItem;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.ts b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.ts
new file mode 100644
index 00000000000..cc0deba1ce4
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-orcid/metadata-link-view-orcid.component.ts
@@ -0,0 +1,64 @@
+import { AsyncPipe } from '@angular/common';
+import {
+ Component,
+ Input,
+ OnInit,
+} from '@angular/core';
+import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service';
+import { RemoteData } from '@dspace/core/data/remote-data';
+import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model';
+import { Item } from '@dspace/core/shared/item.model';
+import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators';
+import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { TranslateModule } from '@ngx-translate/core';
+import {
+ map,
+ Observable,
+} from 'rxjs';
+
+@Component({
+ selector: 'ds-metadata-link-view-orcid',
+ templateUrl: './metadata-link-view-orcid.component.html',
+ styleUrls: ['./metadata-link-view-orcid.component.scss'],
+ imports: [
+ AsyncPipe,
+ NgbTooltipModule,
+ TranslateModule,
+ ],
+})
+export class MetadataLinkViewOrcidComponent implements OnInit {
+ /**
+ * Item value to display the metadata for
+ */
+ @Input() itemValue: Item;
+
+ metadataValue: string;
+
+ orcidUrl$: Observable;
+
+ constructor(protected configurationService: ConfigurationDataService) {}
+
+ ngOnInit(): void {
+ this.orcidUrl$ = this.configurationService
+ .findByPropertyName('orcid.domain-url')
+ .pipe(
+ getFirstCompletedRemoteData(),
+ map((propertyPayload: RemoteData) =>
+ propertyPayload.hasSucceeded ?
+ (propertyPayload.payload?.values?.length > 0 ? propertyPayload.payload.values[0] : null)
+ : null,
+ ),
+ );
+ this.metadataValue = this.itemValue.firstMetadataValue(
+ 'person.identifier.orcid',
+ );
+ }
+
+ public hasOrcid(): boolean {
+ return this.itemValue.hasMetadata('person.identifier.orcid');
+ }
+
+ public hasOrcidBadge(): boolean {
+ return this.itemValue.hasMetadata('dspace.orcid.authenticated');
+ }
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.html b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.html
new file mode 100644
index 00000000000..fa18562e38d
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.html
@@ -0,0 +1,77 @@
+
+
+
+ @if (item.thumbnail | async) {
+
+ }
+
{{title}}
+
+
+ @for (metadata of entityMetdataFields; track metadata) {
+ @if (item.hasMetadata(metadata)) {
+
+
+ {{ "metadata-link-view.popover.label." + (isOtherEntityType ? "other" : item.entityType) + "." + metadata | translate }}
+
+
+ @if (longTextMetadataList.includes(metadata)) {
+
+ {{ item.firstMetadataValue(metadata) }}
+
+ }
+ @if (isLink(item.firstMetadataValue(metadata)) && !getSourceSubTypeIdentifier(metadata)) {
+
+ {{ item.firstMetadataValue(metadata) }}
+
+ }
+ @if (getSourceSubTypeIdentifier(metadata)) {
+
+ }
+ @if (!isLink(item.firstMetadataValue(metadata)) && !longTextMetadataList.includes(metadata)) {
+
+ @if (metadata === 'person.identifier.orcid') {
+
+ } @else {
+ {{ item.firstMetadataValue(metadata) }}
+ }
+
+ }
+
+
+ }
+ }
+
+
+
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.scss b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.scss
new file mode 100644
index 00000000000..47ea2c353d2
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.scss
@@ -0,0 +1,6 @@
+.source-icon {
+ height: var(--ds-identifier-subtype-icon-height);
+ min-height: 16px;
+ width: auto;
+ padding-left: 0.3rem;
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.spec.ts b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.spec.ts
new file mode 100644
index 00000000000..6fce6f93233
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.spec.ts
@@ -0,0 +1,178 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import {
+ ComponentFixture,
+ TestBed,
+ waitForAsync,
+} from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { ActivatedRoute } from '@angular/router';
+import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
+import { TranslateModule } from '@ngx-translate/core';
+import { Bitstream } from 'src/app/core/shared/bitstream.model';
+import { Item } from 'src/app/core/shared/item.model';
+import { MetadataValueFilter } from 'src/app/core/shared/metadata.models';
+import { environment } from 'src/environments/environment.test';
+
+import { MetadataLinkViewAvatarPopoverComponent } from '../metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component';
+import { MetadataLinkViewOrcidComponent } from '../metadata-link-view-orcid/metadata-link-view-orcid.component';
+import { MetadataLinkViewPopoverComponent } from './metadata-link-view-popover.component';
+
+describe('MetadataLinkViewPopoverComponent', () => {
+ let component: MetadataLinkViewPopoverComponent;
+ let fixture: ComponentFixture;
+
+
+ const itemMock = Object.assign(new Item(), {
+ uuid: '1234-1234-1234-1234',
+ entityType: 'Publication',
+
+ firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string {
+ return itemMock.metadata[keyOrKeys as string][0].value;
+ },
+
+ metadata: {
+ 'dc.title': [
+ {
+ value: 'file name',
+ language: null,
+ },
+ ],
+ 'dc.identifier.uri': [
+ {
+ value: 'http://example.com',
+ language: null,
+ },
+ ],
+ 'dc.description.abstract': [
+ {
+ value: 'Long text description',
+ language: null,
+ },
+ ],
+ 'organization.identifier.ror': [
+ {
+ value: 'https://ror.org/1234',
+ language: null,
+ },
+ ],
+ 'person.identifier.orcid': [
+ {
+ value: 'https://orcid.org/0000-0000-0000-0000',
+ language: null,
+ },
+ ],
+ 'dspace.entity.type': [
+ {
+ value: 'Person',
+ language: null,
+ },
+ ],
+ },
+ thumbnail: createSuccessfulRemoteDataObject$(new Bitstream()),
+ });
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), MetadataLinkViewPopoverComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ { provide: ActivatedRoute, useValue: { snapshot: { data: { dso: itemMock } } } },
+ ],
+ })
+ .overrideComponent(MetadataLinkViewPopoverComponent, { remove: { imports: [MetadataLinkViewOrcidComponent, MetadataLinkViewAvatarPopoverComponent] } }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataLinkViewPopoverComponent);
+ component = fixture.componentInstance;
+ component.item = itemMock;
+ itemMock.firstMetadataValue = jasmine.createSpy()
+ .withArgs('dspace.entity.type').and.returnValue('Person')
+ .withArgs('dc.title').and.returnValue('Test Title')
+ .withArgs('dc.identifier.uri').and.returnValue('http://example.com')
+ .withArgs('dc.description.abstract').and.returnValue('Long text description')
+ .withArgs('organization.identifier.ror').and.returnValue('https://ror.org/1234')
+ .withArgs('person.identifier.orcid').and.returnValue('https://orcid.org/0000-0000-0000-0000')
+ .withArgs('dc.nonexistent').and.returnValue(null);
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should display the item title', () => {
+ const titleElement = fixture.debugElement.query(By.css('.font-weight-bold.h4'));
+ expect(titleElement.nativeElement.textContent).toContain('Test Title');
+ });
+
+ it('should display a link for each metadata field that is a valid link', () => {
+ component.entityMetdataFields = ['dc.identifier.uri'];
+ fixture.detectChanges();
+ const linkElement = fixture.debugElement.query(By.css('a[href="http://example.com"]'));
+ expect(linkElement).toBeTruthy();
+ });
+
+ it('should retrieve the identifier subtype configuration based on the given metadata value', () => {
+ const metadataValue = 'organization.identifier.ror';
+ const expectedSubtypeConfig = environment.identifierSubtypes.find((config) => config.name === 'ror');
+ expect(component.getSourceSubTypeIdentifier(metadataValue)).toEqual(expectedSubtypeConfig);
+ });
+
+
+ it('should check if a given metadata value is a valid link', () => {
+ const validLink = 'http://example.com';
+ const invalidLink = 'not a link';
+ expect(component.isLink(validLink)).toBeTrue();
+ expect(component.isLink(invalidLink)).toBeFalse();
+ });
+
+ it('should display the "more info" link with the correct router link', () => {
+ spyOn(component, 'getItemPageRoute').and.returnValue('/item/' + itemMock.uuid);
+ fixture.detectChanges();
+ const moreInfoLinkElement = fixture.debugElement.query(By.css('a[data-test="more-info-link"]'));
+ expect(moreInfoLinkElement.nativeElement.href).toContain('/item/' + itemMock.uuid);
+ });
+
+ it('should display the avatar popover when item has a thumbnail', () => {
+ const avatarPopoverElement = fixture.debugElement.query(By.css('ds-metadata-link-view-avatar-popover'));
+ expect(avatarPopoverElement).toBeTruthy();
+ });
+
+ describe('getTitleFromMetadataList', () => {
+
+ it('should return title from configured metadata when available', () => {
+ component.metadataLinkViewPopoverData = {
+ entityDataConfig: [
+ {
+ entityType: 'Publication',
+ metadataList: ['dc.title', 'dc.identifier.uri'],
+ titleMetadataList: ['dc.title', 'dc.identifier.uri'],
+ },
+ ],
+ fallbackMetdataList: [],
+ };
+
+ const title = component.getTitleFromMetadataList();
+ expect(title).toBe('Test Title, http://example.com');
+ });
+
+ it('should fallback to defaultTitleMetadataList when no configured title is present', () => {
+ component.metadataLinkViewPopoverData = {
+ entityDataConfig: [
+ {
+ entityType: 'Publication',
+ metadataList: ['dc.title', 'dc.identifier.uri'],
+ titleMetadataList: ['dc.nonexistent'],
+ },
+ ],
+ fallbackMetdataList: [],
+ };
+
+ const title = component.getTitleFromMetadataList();
+ expect(title).toBe('Test Title');
+ });
+ });
+
+});
diff --git a/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.ts b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.ts
new file mode 100644
index 00000000000..924c419b99d
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view-popover/metadata-link-view-popover.component.ts
@@ -0,0 +1,142 @@
+import {
+ AsyncPipe,
+ NgOptimizedImage,
+} from '@angular/common';
+import {
+ Component,
+ Input,
+ OnInit,
+} from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { IdentifierSubtypesConfig } from '@dspace/config/identifier-subtypes-config.interface';
+import { MetadataLinkViewPopoverDataConfig } from '@dspace/config/metadata-link-view-popoverdata-config.interface';
+import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils';
+import { Item } from '@dspace/core/shared/item.model';
+import {
+ hasNoValue,
+ hasValue,
+} from '@dspace/shared/utils/empty.util';
+import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { TranslateModule } from '@ngx-translate/core';
+import { AuthorithyIcon } from 'src/config/submission-config.interface';
+import { environment } from 'src/environments/environment';
+
+import { VarDirective } from '../../utils/var.directive';
+import { MetadataLinkViewAvatarPopoverComponent } from '../metadata-link-view-avatar-popover/metadata-link-view-avatar-popover.component';
+import { MetadataLinkViewOrcidComponent } from '../metadata-link-view-orcid/metadata-link-view-orcid.component';
+
+
+@Component({
+ selector: 'ds-metadata-link-view-popover',
+ templateUrl: './metadata-link-view-popover.component.html',
+ styleUrls: ['./metadata-link-view-popover.component.scss'],
+ imports: [
+ AsyncPipe,
+ MetadataLinkViewAvatarPopoverComponent,
+ MetadataLinkViewOrcidComponent,
+ NgbTooltipModule,
+ NgOptimizedImage,
+ RouterLink,
+ TranslateModule,
+ VarDirective,
+ ],
+})
+export class MetadataLinkViewPopoverComponent implements OnInit {
+
+ /**
+ * The item to display the metadata for
+ */
+ @Input() item: Item;
+
+ /**
+ * The metadata link view popover data configuration.
+ * This configuration is used to determine which metadata fields to display for the given entity type
+ */
+ metadataLinkViewPopoverData: MetadataLinkViewPopoverDataConfig = environment.metadataLinkViewPopoverData;
+
+ /**
+ * The metadata fields to display for the given entity type
+ */
+ entityMetdataFields: string[] = [];
+
+ /**
+ * The metadata fields including long text metadata values.
+ * These metadata values should be truncated to a certain length.
+ */
+ longTextMetadataList = ['dc.description.abstract', 'dc.description'];
+
+ /**
+ * The source icons configuration
+ */
+ sourceIcons: AuthorithyIcon[] = environment.submission.icons.authority.sourceIcons;
+
+ /**
+ * The identifier subtype configurations
+ */
+ identifierSubtypeConfig: IdentifierSubtypesConfig[] = environment.identifierSubtypes;
+
+ /**
+ * Whether the entity type is not found in the metadataLinkViewPopoverData configuration
+ */
+ isOtherEntityType = false;
+
+ /**
+ * The title to be displayed
+ */
+ title: string;
+
+ private readonly titleSeparator = ', ';
+ private readonly defaultTitleMetadataList = ['dc.title'];
+
+ /**
+ * If `metadataLinkViewPopoverData` is provided, it retrieves the metadata fields based on the entity type.
+ * If no metadata fields are found for the entity type, it falls back to the fallback metadata list.
+ */
+ ngOnInit() {
+ if (this.metadataLinkViewPopoverData) {
+ const metadataFields = this.metadataLinkViewPopoverData.entityDataConfig.find((config) => config.entityType === this.item.entityType);
+ this.entityMetdataFields = hasValue(metadataFields) ? metadataFields.metadataList : this.metadataLinkViewPopoverData.fallbackMetdataList;
+ this.isOtherEntityType = hasNoValue(metadataFields);
+ this.title = this.getTitleFromMetadataList();
+ }
+ }
+
+ /**
+ * Checks if the given metadata value is a valid link.
+ */
+ isLink(metadataValue: string): boolean {
+ const urlRegex = /^(http|https):\/\/[^ "]+$/;
+ return urlRegex.test(metadataValue);
+ }
+
+ /**
+ * Returns the page route for the item.
+ * @returns The page route for the item.
+ */
+ getItemPageRoute(): string {
+ return getItemPageRoute(this.item);
+ }
+
+ /**
+ * Retrieves the identifier subtype configuration based on the given metadata value.
+ * @param metadataValue - The metadata value used to determine the identifier subtype.
+ * @returns The identifier subtype configuration object.
+ */
+ getSourceSubTypeIdentifier(metadataValue: string): IdentifierSubtypesConfig {
+ const metadataValueSplited = metadataValue.split('.');
+ const subtype = metadataValueSplited[metadataValueSplited.length - 1];
+ const identifierSubtype = this.identifierSubtypeConfig.find((config) => config.name === subtype);
+ return identifierSubtype;
+ }
+
+ /**
+ * Generates the title for the popover based on the title metadata list.
+ * @returns The generated title as a string.
+ */
+ getTitleFromMetadataList(): string {
+ const titleMetadataList = this.metadataLinkViewPopoverData.entityDataConfig.find((config) => config.entityType === this.item.entityType)?.titleMetadataList;
+ const itemHasConfiguredTitle = titleMetadataList?.length && titleMetadataList.map(metadata => this.item.firstMetadataValue(metadata)).some(value => hasValue(value));
+ return (itemHasConfiguredTitle ? titleMetadataList : this.defaultTitleMetadataList)
+ .map(metadataField => this.item.firstMetadataValue(metadataField)).join(this.titleSeparator);
+ }
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view.component.html b/src/app/shared/metadata-link-view/metadata-link-view.component.html
new file mode 100644
index 00000000000..abc7a38549e
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view.component.html
@@ -0,0 +1,51 @@
+
+ @if (metadataView) {
+
+ }
+
+
+
+
+
+ {{metadataView.value}}
+
+
+
+ @if (metadataView.orcidAuthenticated) {
+
+ }
+
+
+
+ {{normalizeValue(metadataView.value)}}
+
+
+
+ {{normalizeValue(metadataView.value)}}
+
+
+
+
+
+
diff --git a/src/app/shared/metadata-link-view/metadata-link-view.component.scss b/src/app/shared/metadata-link-view/metadata-link-view.component.scss
new file mode 100644
index 00000000000..f34f101c7e0
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view.component.scss
@@ -0,0 +1,11 @@
+.orcid-icon {
+ height: 1.2rem;
+ padding-left: 0.3rem;
+}
+
+
+::ng-deep .popover {
+ max-width: 400px !important;
+ width: 100%;
+ min-width: 300px !important;
+}
diff --git a/src/app/shared/metadata-link-view/metadata-link-view.component.spec.ts b/src/app/shared/metadata-link-view/metadata-link-view.component.spec.ts
new file mode 100644
index 00000000000..789730fca64
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view.component.spec.ts
@@ -0,0 +1,217 @@
+import {
+ ComponentFixture,
+ TestBed,
+ waitForAsync,
+} from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { of } from 'rxjs';
+import { v4 as uuidv4 } from 'uuid';
+
+import { ItemDataService } from '../../core/data/item-data.service';
+import { Item } from '../../core/shared/item.model';
+import { MetadataValue } from '../../core/shared/metadata.models';
+import { EntityIconDirective } from '../entity-icon/entity-icon.directive';
+import { VarDirective } from '../utils/var.directive';
+import { MetadataLinkViewComponent } from './metadata-link-view.component';
+import SpyObj = jasmine.SpyObj;
+import {
+ createFailedRemoteDataObject$,
+ createSuccessfulRemoteDataObject$,
+} from '@dspace/core/utilities/remote-data.utils';
+
+import { MetadataLinkViewPopoverComponent } from './metadata-link-view-popover/metadata-link-view-popover.component';
+
+describe('MetadataLinkViewComponent', () => {
+ let component: MetadataLinkViewComponent;
+ let fixture: ComponentFixture;
+ let itemService: SpyObj;
+ const validAuthority = uuidv4();
+
+ const testPerson = Object.assign(new Item(), {
+ id: '1',
+ bundles: of({}),
+ metadata: {
+ 'dspace.entity.type': [
+ Object.assign(new MetadataValue(), {
+ value: 'Person',
+ }),
+ ],
+ 'person.orgunit.id': [
+ Object.assign(new MetadataValue(), {
+ value: 'OrgUnit',
+ authority: '2',
+ }),
+ ],
+ 'person.identifier.orcid': [
+ Object.assign(new MetadataValue(), {
+ language: 'en_US',
+ value: '0000-0001-8918-3592',
+ }),
+ ],
+ 'dspace.orcid.authenticated': [
+ Object.assign(new MetadataValue(), {
+ language: null,
+ value: 'authenticated',
+ }),
+ ],
+ },
+ entityType: 'Person',
+ });
+
+ const testOrgunit = Object.assign(new Item(), {
+ id: '2',
+ bundles: of({}),
+ metadata: {
+ 'dspace.entity.type': [
+ Object.assign(new MetadataValue(), {
+ value: 'OrgUnit',
+ }),
+ ],
+ 'orgunit.person.id': [
+ Object.assign(new MetadataValue(), {
+ value: 'Person',
+ authority: '1',
+ }),
+ ],
+ },
+ entityType: 'OrgUnit',
+ });
+
+ const testMetadataValueWithoutAuthority = Object.assign(new MetadataValue(), {
+ authority: null,
+ confidence: -1,
+ language: null,
+ place: 0,
+ uuid: '56e99d82-2cae-4cce-8d12-39899dea7c72',
+ value: 'Università degli Studi di Milano Bicocca',
+ });
+
+ const testMetadataValueWithAuthority = Object.assign(new MetadataValue(), {
+ authority: validAuthority,
+ confidence: 600,
+ language: null,
+ place: 0,
+ uuid: '56e99d82-2cae-4cce-8d12-39899dea7c72',
+ value: 'Università degli Studi di Milano Bicocca',
+ });
+
+ itemService = jasmine.createSpyObj('ItemDataService', {
+ findById: jasmine.createSpy('findById'),
+ });
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ NgbTooltipModule,
+ RouterTestingModule,
+ MetadataLinkViewComponent, EntityIconDirective, VarDirective,
+ ],
+ providers: [
+ { provide: ItemDataService, useValue: itemService },
+ ],
+ })
+ .overrideComponent(MetadataLinkViewComponent, { remove: { imports: [MetadataLinkViewPopoverComponent] } }).compileComponents();
+ }));
+
+ describe('Check metadata without authority', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataLinkViewComponent);
+ itemService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testOrgunit));
+ component = fixture.componentInstance;
+ component.metadata = testMetadataValueWithoutAuthority;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render the span element', () => {
+ const text = fixture.debugElement.query(By.css('[data-test="textWithoutIcon"]'));
+ const link = fixture.debugElement.query(By.css('[data-test="linkToAuthority"]'));
+
+ expect(text).toBeTruthy();
+ expect(link).toBeNull();
+ });
+
+ });
+
+ describe('Check metadata with authority', () => {
+ describe('when item is found with orcid', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataLinkViewComponent);
+ itemService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testPerson));
+ component = fixture.componentInstance;
+ component.metadata = testMetadataValueWithAuthority;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render the link element', () => {
+ const link = fixture.debugElement.query(By.css('[data-test="linkToAuthority"]'));
+
+ expect(link).toBeTruthy();
+ });
+
+ it('should render the orcid icon', () => {
+ const icon = fixture.debugElement.query(By.css('[data-test="orcidIcon"]'));
+
+ expect(icon).toBeTruthy();
+ });
+ });
+
+ describe('when item is found without orcid', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataLinkViewComponent);
+ itemService.findById.and.returnValue(createSuccessfulRemoteDataObject$(testOrgunit));
+ component = fixture.componentInstance;
+ component.metadata = testMetadataValueWithAuthority;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render the link element', () => {
+ const link = fixture.debugElement.query(By.css('[data-test="linkToAuthority"]'));
+
+ expect(link).toBeTruthy();
+ });
+
+ it('should not render the orcid icon', () => {
+ const icon = fixture.debugElement.query(By.css('[data-test="orcidIcon"]'));
+
+ expect(icon).toBeFalsy();
+ });
+ });
+
+ describe('when item is not found', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MetadataLinkViewComponent);
+ itemService.findById.and.returnValue(createFailedRemoteDataObject$());
+ component = fixture.componentInstance;
+ component.metadata = testMetadataValueWithAuthority;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render the span element', () => {
+ const text = fixture.debugElement.query(By.css('[data-test="textWithIcon"]'));
+ const link = fixture.debugElement.query(By.css('[data-test="linkToAuthority"]'));
+
+ expect(text).toBeTruthy();
+ expect(link).toBeNull();
+ });
+ });
+ });
+
+});
diff --git a/src/app/shared/metadata-link-view/metadata-link-view.component.ts b/src/app/shared/metadata-link-view/metadata-link-view.component.ts
new file mode 100644
index 00000000000..0c4d7f5fd55
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-link-view.component.ts
@@ -0,0 +1,182 @@
+import {
+ AsyncPipe,
+ NgTemplateOutlet,
+} from '@angular/common';
+import {
+ Component,
+ Input,
+ OnInit,
+} from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils';
+import { followLink } from '@dspace/core/shared/follow-link-config.model';
+import { PLACEHOLDER_PARENT_METADATA } from '@dspace/core/shared/form/ds-dynamic-form-constants';
+import { isNotEmpty } from '@dspace/shared/utils/empty.util';
+import {
+ NgbPopoverModule,
+ NgbTooltipModule,
+} from '@ng-bootstrap/ng-bootstrap';
+import {
+ Observable,
+ of,
+} from 'rxjs';
+import {
+ map,
+ switchMap,
+ take,
+} from 'rxjs/operators';
+
+import { ItemDataService } from '../../core/data/item-data.service';
+import { RemoteData } from '../../core/data/remote-data';
+import { Item } from '../../core/shared/item.model';
+import { MetadataValue } from '../../core/shared/metadata.models';
+import { Metadata } from '../../core/shared/metadata.utils';
+import { getFirstCompletedRemoteData } from '../../core/shared/operators';
+import { EntityIconDirective } from '../entity-icon/entity-icon.directive';
+import { VarDirective } from '../utils/var.directive';
+import { MetadataLinkViewPopoverComponent } from './metadata-link-view-popover/metadata-link-view-popover.component';
+import { MetadataView } from './metadata-view.model';
+import { StickyPopoverDirective } from './sticky-popover.directive';
+
+@Component({
+ selector: 'ds-metadata-link-view',
+ templateUrl: './metadata-link-view.component.html',
+ styleUrls: ['./metadata-link-view.component.scss'],
+ imports: [
+ AsyncPipe,
+ EntityIconDirective,
+ MetadataLinkViewPopoverComponent,
+ NgbPopoverModule,
+ NgbTooltipModule,
+ NgTemplateOutlet,
+ RouterLink,
+ StickyPopoverDirective,
+ VarDirective,
+ ],
+})
+export class MetadataLinkViewComponent implements OnInit {
+
+ /**
+ * Metadata value that we need to show in the template
+ */
+ @Input() metadata: MetadataValue;
+
+ /**
+ * Processed metadata to create MetadataOrcid with the information needed to show
+ */
+ metadataView$: Observable;
+
+ /**
+ * Position of the Icon before/after the element
+ */
+ iconPosition = 'after';
+
+ /**
+ * Related item of the metadata value
+ */
+ relatedItem: Item;
+
+ /**
+ * Route of related item page
+ */
+ relatedDsoRoute: string;
+
+ /**
+ * Map all entities with the icons specified in the environment configuration file
+ */
+ constructor(private itemService: ItemDataService) { }
+
+ /**
+ * On init process metadata to get the information and form MetadataOrcid model
+ */
+ ngOnInit(): void {
+ this.metadataView$ = of(this.metadata).pipe(
+ switchMap((metadataValue: MetadataValue) => this.getMetadataView(metadataValue)),
+ take(1),
+ );
+ }
+
+
+ /**
+ * Retrieves the metadata view for a given metadata value.
+ * If the metadata value has a valid authority, it retrieves the item using the authority and creates a metadata view.
+ * If the metadata value does not have a valid authority, it creates a metadata view with null values.
+ *
+ * @param metadataValue The metadata value for which to retrieve the metadata view.
+ * @returns An Observable that emits the metadata view.
+ */
+ private getMetadataView(metadataValue: MetadataValue): Observable {
+ const linksToFollow = [followLink('thumbnail')];
+
+ if (Metadata.hasValidAuthority(metadataValue.authority)) {
+ return this.itemService.findById(metadataValue.authority, true, false, ...linksToFollow).pipe(
+ getFirstCompletedRemoteData(),
+ map((itemRD: RemoteData- ) => this.createMetadataView(itemRD, metadataValue)),
+ );
+ } else {
+ return of({
+ authority: null,
+ value: metadataValue.value,
+ orcidAuthenticated: null,
+ entityType: null,
+ entityStyle: null,
+ });
+ }
+ }
+
+ /**
+ * Creates a MetadataView object based on the provided itemRD and metadataValue.
+ * @param itemRD - The RemoteData object containing the item information.
+ * @param metadataValue - The MetadataValue object containing the metadata information.
+ * @returns The created MetadataView object.
+ */
+ private createMetadataView(itemRD: RemoteData
- , metadataValue: MetadataValue): MetadataView {
+ if (itemRD.hasSucceeded && itemRD.payload) {
+ this.relatedItem = itemRD.payload;
+ this.relatedDsoRoute = this.getItemPageRoute(this.relatedItem);
+ return {
+ authority: metadataValue.authority,
+ value: metadataValue.value,
+ orcidAuthenticated: this.getOrcid(itemRD.payload),
+ entityType: (itemRD.payload as Item)?.entityType,
+ };
+ } else {
+ return {
+ authority: null,
+ value: metadataValue.value,
+ orcidAuthenticated: null,
+ entityType: 'PRIVATE',
+ };
+ }
+ }
+
+ /**
+ * Returns the orcid for given item, or null if there is no metadata authenticated for person
+ *
+ * @param referencedItem Item of the metadata being shown
+ */
+ getOrcid(referencedItem: Item): string {
+ if (referencedItem?.hasMetadata('dspace.orcid.authenticated')) {
+ return referencedItem.firstMetadataValue('person.identifier.orcid');
+ }
+ return null;
+ }
+
+ /**
+ * Normalize value to display
+ *
+ * @param value
+ */
+ normalizeValue(value: string): string {
+ if (isNotEmpty(value) && value.includes(PLACEHOLDER_PARENT_METADATA)) {
+ return '';
+ } else {
+ return value;
+ }
+ }
+
+ getItemPageRoute(item: Item): string {
+ return getItemPageRoute(item);
+ }
+
+}
diff --git a/src/app/shared/metadata-link-view/metadata-view.model.ts b/src/app/shared/metadata-link-view/metadata-view.model.ts
new file mode 100644
index 00000000000..fc5ecf24792
--- /dev/null
+++ b/src/app/shared/metadata-link-view/metadata-view.model.ts
@@ -0,0 +1,6 @@
+export interface MetadataView {
+ authority: string;
+ value: string;
+ orcidAuthenticated: string;
+ entityType: string;
+}
diff --git a/src/app/shared/metadata-link-view/sticky-popover.directive.ts b/src/app/shared/metadata-link-view/sticky-popover.directive.ts
new file mode 100644
index 00000000000..0809fde9a97
--- /dev/null
+++ b/src/app/shared/metadata-link-view/sticky-popover.directive.ts
@@ -0,0 +1,129 @@
+import { DOCUMENT } from '@angular/common';
+import {
+ ApplicationRef,
+ ChangeDetectorRef,
+ Directive,
+ ElementRef,
+ Inject,
+ Injector,
+ Input,
+ NgZone,
+ OnDestroy,
+ OnInit,
+ Renderer2,
+ TemplateRef,
+ ViewContainerRef,
+} from '@angular/core';
+import {
+ NavigationStart,
+ Router,
+} from '@angular/router';
+import {
+ NgbPopover,
+ NgbPopoverConfig,
+} from '@ng-bootstrap/ng-bootstrap';
+import { Subscription } from 'rxjs';
+
+/**
+ * Directive to create a sticky popover using NgbPopover.
+ * The popover remains open when the mouse is over its content and closes when the mouse leaves.
+ */
+@Directive({
+ selector: '[dsStickyPopover]',
+ standalone:true,
+})
+export class StickyPopoverDirective extends NgbPopover implements OnInit, OnDestroy {
+ /** Template for the sticky popover content */
+ @Input() dsStickyPopover: TemplateRef;
+
+ /** Subscriptions to manage router events */
+ subs: Subscription[] = [];
+
+ /** Flag to determine if the popover can be closed */
+ private canClosePopover: boolean;
+
+ /** Reference to the element the directive is applied to */
+ private readonly _elRef;
+
+ /** Renderer to listen to and manipulate DOM elements */
+ private readonly _render;
+
+ constructor(
+ _elementRef: ElementRef,
+ _renderer: Renderer2, injector: Injector,
+ viewContainerRef: ViewContainerRef,
+ config: NgbPopoverConfig,
+ _ngZone: NgZone,
+ @Inject(DOCUMENT) _document: Document,
+ _changeDetector: ChangeDetectorRef,
+ applicationRef: ApplicationRef,
+ private router: Router,
+ ) {
+ super(_elementRef, _renderer, injector, viewContainerRef, config, _ngZone, document, _changeDetector, applicationRef);
+ this._elRef = _elementRef;
+ this._render = _renderer;
+ this.triggers = 'manual';
+ this.container = 'body';
+ }
+
+ /**
+ * Sets up event listeners for mouse enter, mouse leave, and click events.
+ */
+ ngOnInit(): void {
+ super.ngOnInit();
+ this.ngbPopover = this.dsStickyPopover;
+
+ this._render.listen(this._elRef.nativeElement, 'mouseenter', () => {
+ this.canClosePopover = true;
+ this.open();
+ });
+
+ this._render.listen(this._elRef.nativeElement, 'mouseleave', () => {
+ setTimeout(() => {
+ if (this.canClosePopover) {
+ this.close();
+ }
+ }, 100);
+ });
+
+ this._render.listen(this._elRef.nativeElement, 'click', () => {
+ this.close();
+ });
+
+ this.subs.push(
+ this.router.events.subscribe((event) => {
+ if (event instanceof NavigationStart) {
+ this.close();
+ }
+ }),
+ );
+ }
+
+ /**
+ * Opens the popover and sets up event listeners for mouse over and mouse out events on the popover.
+ */
+ open() {
+ super.open();
+ const popover = window.document.querySelector('.popover');
+ this._render.listen(popover, 'mouseover', () => {
+ this.canClosePopover = false;
+ });
+
+ this._render.listen(popover, 'mouseout', () => {
+ this.canClosePopover = true;
+ setTimeout(() => {
+ if (this.canClosePopover) {
+ this.close();
+ }
+ }, 0);
+ });
+ }
+
+ /**
+ * Unsubscribes from all subscriptions when the directive is destroyed.
+ */
+ ngOnDestroy() {
+ super.ngOnDestroy();
+ this.subs.forEach((sub) => sub.unsubscribe());
+ }
+}
diff --git a/src/app/shared/metadata-representation/metadata-representation.decorator.ts b/src/app/shared/metadata-representation/metadata-representation.decorator.ts
index 5fc916daef5..e7cb2a720bd 100644
--- a/src/app/shared/metadata-representation/metadata-representation.decorator.ts
+++ b/src/app/shared/metadata-representation/metadata-representation.decorator.ts
@@ -15,6 +15,7 @@ import {
DEFAULT_THEME,
resolveTheme,
} from '../object-collection/shared/listable-object/listable-object.decorator';
+import { AuthorityLinkMetadataListElementComponent } from '../object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component';
import { BrowseLinkMetadataListElementComponent } from '../object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component';
import { ItemMetadataListElementComponent } from '../object-list/metadata-representation-list-element/item/item-metadata-list-element.component';
import { PlainTextMetadataListElementComponent } from '../object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component';
@@ -34,7 +35,8 @@ export type MetadataRepresentationComponent =
typeof ItemMetadataListElementComponent |
typeof OrgUnitItemMetadataListElementComponent |
typeof PersonItemMetadataListElementComponent |
- typeof ProjectItemMetadataListElementComponent;
+ typeof ProjectItemMetadataListElementComponent |
+ typeof AuthorityLinkMetadataListElementComponent;
export const METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP =
new Map>>>([
@@ -42,21 +44,27 @@ export const METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP =
[MetadataRepresentationType.PlainText, new Map([
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, PlainTextMetadataListElementComponent as any]])]])],
[MetadataRepresentationType.AuthorityControlled, new Map([
- [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, PlainTextMetadataListElementComponent]])]])],
+ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, AuthorityLinkMetadataListElementComponent]])]])],
[MetadataRepresentationType.BrowseLink, new Map([
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, BrowseLinkMetadataListElementComponent]])]])],
[MetadataRepresentationType.Item, new Map([
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, ItemMetadataListElementComponent]])]])],
])],
['Person', new Map([
+ [MetadataRepresentationType.AuthorityControlled, new Map([
+ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, AuthorityLinkMetadataListElementComponent as any]])]])],
[MetadataRepresentationType.Item, new Map([
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, PersonItemMetadataListElementComponent]])]])],
])],
['OrgUnit', new Map([
+ [MetadataRepresentationType.AuthorityControlled, new Map([
+ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, AuthorityLinkMetadataListElementComponent as any]])]])],
[MetadataRepresentationType.Item, new Map([
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, OrgUnitItemMetadataListElementComponent]])]])],
])],
['Project', new Map([
+ [MetadataRepresentationType.AuthorityControlled, new Map([
+ [DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, AuthorityLinkMetadataListElementComponent as any]])]])],
[MetadataRepresentationType.Item, new Map([
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, ProjectItemMetadataListElementComponent]])]])],
])],
diff --git a/src/app/shared/object-list/item-list-element/item-types/item/item-list-element.component.spec.ts b/src/app/shared/object-list/item-list-element/item-types/item/item-list-element.component.spec.ts
index 2eba47d4fb7..1140df6df2a 100644
--- a/src/app/shared/object-list/item-list-element/item-types/item/item-list-element.component.spec.ts
+++ b/src/app/shared/object-list/item-list-element/item-types/item/item-list-element.component.spec.ts
@@ -10,6 +10,7 @@ import { APP_CONFIG } from '@dspace/config/app-config.interface';
import { AuthService } from '@dspace/core/auth/auth.service';
import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service';
import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service';
+import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type';
import { Item } from '@dspace/core/shared/item.model';
import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub';
import { AuthServiceStub } from '@dspace/core/testing/auth-service.stub';
@@ -17,6 +18,7 @@ import { AuthorizationDataServiceStub } from '@dspace/core/testing/authorization
import { DSONameServiceMock } from '@dspace/core/testing/dso-name.service.mock';
import { TruncatableServiceStub } from '@dspace/core/testing/truncatable-service.stub';
import { XSRFService } from '@dspace/core/xsrf/xsrf.service';
+import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
@@ -85,8 +87,6 @@ describe('ItemListElementComponent', () => {
TranslateModule.forRoot(),
TruncatePipe,
],
- declarations: [
- ],
providers: [
{ provide: DSONameService, useValue: new DSONameServiceMock() },
{ provide: APP_CONFIG, useValue: environment },
@@ -96,6 +96,8 @@ describe('ItemListElementComponent', () => {
{ provide: ThemeService, useValue: themeService },
{ provide: TruncatableService, useValue: truncatableService },
{ provide: XSRFService, useValue: {} },
+ { provide: APP_DATA_SERVICES_MAP, useValue: {} },
+ provideMockStore(),
],
}).overrideComponent(ItemListElementComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default },
diff --git a/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.html b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.html
new file mode 100644
index 00000000000..847d69e8e11
--- /dev/null
+++ b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.html
@@ -0,0 +1 @@
+
diff --git a/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.spec.ts b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.spec.ts
new file mode 100644
index 00000000000..5477752846c
--- /dev/null
+++ b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.spec.ts
@@ -0,0 +1,65 @@
+import {
+ ChangeDetectionStrategy,
+ NO_ERRORS_SCHEMA,
+} from '@angular/core';
+import {
+ ComponentFixture,
+ TestBed,
+ waitForAsync,
+} from '@angular/core/testing';
+import { ItemDataService } from '@dspace/core/data/item-data.service';
+import { MetadataRepresentationType } from '@dspace/core/shared/metadata-representation/metadata-representation.model';
+import { MetadatumRepresentation } from '@dspace/core/shared/metadata-representation/metadatum/metadatum-representation.model';
+import { ValueListBrowseDefinition } from '@dspace/core/shared/value-list-browse-definition.model';
+
+import { MetadataLinkViewComponent } from '../../../metadata-link-view/metadata-link-view.component';
+import { AuthorityLinkMetadataListElementComponent } from './authority-link-metadata-list-element.component';
+
+
+const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), {
+ key: 'dc.contributor.author',
+ value: 'Test Author',
+ browseDefinition: Object.assign(new ValueListBrowseDefinition(), {
+ id: 'author',
+ }),
+} as Partial);
+
+const itemService = jasmine.createSpyObj('ItemDataService', {
+ findByIdWithProjections: jasmine.createSpy('findByIdWithProjections'),
+});
+
+describe('AuthorityLinkMetadataListElementComponent', () => {
+ let comp: AuthorityLinkMetadataListElementComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(waitForAsync(() => {
+ void TestBed.configureTestingModule({
+ imports: [AuthorityLinkMetadataListElementComponent, MetadataLinkViewComponent],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ { provide: ItemDataService, useValue: itemService },
+ ],
+ }).overrideComponent(AuthorityLinkMetadataListElementComponent, {
+ set: { changeDetection: ChangeDetectionStrategy.Default },
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(AuthorityLinkMetadataListElementComponent);
+ comp = fixture.componentInstance;
+ });
+
+ describe('with authorithy controlled metadata', () => {
+ beforeEach(() => {
+ comp.mdRepresentation = mockMetadataRepresentation;
+ spyOnProperty(comp.mdRepresentation, 'representationType', 'get').and.returnValue(MetadataRepresentationType.AuthorityControlled);
+ fixture.detectChanges();
+ });
+
+ it('should contain the value', () => {
+ expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value);
+ });
+
+ });
+
+});
diff --git a/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.ts b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.ts
new file mode 100644
index 00000000000..6d4bbea52c8
--- /dev/null
+++ b/src/app/shared/object-list/metadata-representation-list-element/authority-link/authority-link-metadata-list-element.component.ts
@@ -0,0 +1,29 @@
+
+import {
+ Component,
+ OnInit,
+} from '@angular/core';
+import { MetadatumRepresentation } from '@dspace/core/shared/metadata-representation/metadatum/metadatum-representation.model';
+
+import { MetadataLinkViewComponent } from '../../../metadata-link-view/metadata-link-view.component';
+import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component';
+
+@Component({
+ selector: 'ds-authority-link-metadata-list-element',
+ templateUrl: './authority-link-metadata-list-element.component.html',
+ imports: [
+ MetadataLinkViewComponent,
+ ],
+})
+/**
+ * A component for displaying MetadataRepresentation objects with authority in the form of a link
+ * It will simply use the value retrieved from MetadataRepresentation.getValue() to display a link to the item
+ */
+export class AuthorityLinkMetadataListElementComponent extends MetadataRepresentationListElementComponent implements OnInit {
+
+ metadataValue: MetadatumRepresentation;
+
+ ngOnInit() {
+ this.metadataValue = this.mdRepresentation as MetadatumRepresentation;
+ }
+}
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html
index 2c9c7e44bd4..7d8c407e741 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html
+++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html
@@ -30,10 +30,9 @@
{{'mydspace.results.no-authors'
| translate}}
}
- @for (author of item.allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'], undefined, true); track author; let last = $last) {
-
-
+ @for (author of authorMetadataList; track author.uuid; let last = $last) {
+
+
@if (!last) {
;
}
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts
index bc8d916f7f8..1e16028c1a7 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts
+++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts
@@ -19,6 +19,7 @@ import {
import { of } from 'rxjs';
import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component';
+import { MetadataLinkViewComponent } from '../../../metadata-link-view/metadata-link-view.component';
import { ThemedBadgesComponent } from '../../../object-collection/shared/badges/themed-badges.component';
import { ItemCollectionComponent } from '../../../object-collection/shared/mydspace-item-collection/item-collection.component';
import { ItemSubmitterComponent } from '../../../object-collection/shared/mydspace-item-submitter/item-submitter.component';
@@ -119,6 +120,7 @@ describe('ItemListPreviewComponent', () => {
ThemedThumbnailComponent, ThemedBadgesComponent,
TruncatableComponent, TruncatablePartComponent,
ItemSubmitterComponent, ItemCollectionComponent,
+ MetadataLinkViewComponent,
],
},
}).compileComponents();
@@ -127,18 +129,12 @@ describe('ItemListPreviewComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemListPreviewComponent);
component = fixture.componentInstance;
-
- }));
-
- beforeEach(() => {
component.object = { hitHighlights: {} } as any;
- });
+ component.item = mockItemWithAuthorAndDate;
+ fixture.detectChanges();
+ }));
describe('When showThumbnails is true', () => {
- beforeEach(() => {
- component.item = mockItemWithAuthorAndDate;
- fixture.detectChanges();
- });
it('should add the thumbnail element', () => {
const thumbnail = fixture.debugElement.query(By.css('ds-thumbnail'));
expect(thumbnail).toBeTruthy();
@@ -146,35 +142,24 @@ describe('ItemListPreviewComponent', () => {
});
describe('When the item has an author', () => {
- beforeEach(() => {
- component.item = mockItemWithAuthorAndDate;
- fixture.detectChanges();
- });
-
it('should show the author paragraph', () => {
- const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors'));
+ const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors ds-metadata-link-view'));
expect(itemAuthorField).not.toBeNull();
});
});
describe('When the item has no author', () => {
- beforeEach(() => {
+ beforeEach(waitForAsync(() => {
component.item = mockItemWithoutAuthorAndDate;
fixture.detectChanges();
- });
-
+ }));
it('should not show the author paragraph', () => {
- const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors'));
+ const itemAuthorField = fixture.debugElement.query(By.css('span.item-list-authors ds-metadata-link-view'));
expect(itemAuthorField).toBeNull();
});
});
describe('When the item has an issuedate', () => {
- beforeEach(() => {
- component.item = mockItemWithAuthorAndDate;
- fixture.detectChanges();
- });
-
it('should show the issuedate span', () => {
const dateField = fixture.debugElement.query(By.css('span.item-list-date'));
expect(dateField).not.toBeNull();
@@ -182,11 +167,6 @@ describe('ItemListPreviewComponent', () => {
});
describe('When the item has no issuedate', () => {
- beforeEach(() => {
- component.item = mockItemWithoutAuthorAndDate;
- fixture.detectChanges();
- });
-
it('should show the issuedate empty placeholder', () => {
const dateField = fixture.debugElement.query(By.css('span.item-list-date'));
expect(dateField).not.toBeNull();
@@ -205,54 +185,3 @@ describe('ItemListPreviewComponent', () => {
});
});
});
-
-describe('ItemListPreviewComponent', () => {
- beforeEach(waitForAsync(() => {
- TestBed.configureTestingModule({
- imports: [
- TranslateModule.forRoot({
- loader: {
- provide: TranslateLoader,
- useClass: TranslateLoaderMock,
- },
- }),
- NoopAnimationsModule,
- ItemListPreviewComponent, TruncatePipe,
- ],
- providers: [
- { provide: 'objectElementProvider', useValue: { mockItemWithAuthorAndDate } },
- { provide: APP_CONFIG, useValue: enviromentNoThumbs },
- ],
- schemas: [NO_ERRORS_SCHEMA],
- }).overrideComponent(ItemListPreviewComponent, {
- add: { changeDetection: ChangeDetectionStrategy.Default },
- remove: {
- imports: [
- ThemedThumbnailComponent, ThemedBadgesComponent,
- TruncatableComponent, TruncatablePartComponent,
- ItemSubmitterComponent, ItemCollectionComponent,
- ],
- },
- }).compileComponents();
- }));
- beforeEach(waitForAsync(() => {
- fixture = TestBed.createComponent(ItemListPreviewComponent);
- component = fixture.componentInstance;
-
- }));
-
- beforeEach(() => {
- component.object = { hitHighlights: {} } as any;
- });
-
- describe('When showThumbnails is true', () => {
- beforeEach(() => {
- component.item = mockItemWithAuthorAndDate;
- fixture.detectChanges();
- });
- it('should add the thumbnail element', () => {
- const thumbnail = fixture.debugElement.query(By.css('ds-thumbnail'));
- expect(thumbnail).toBeFalsy();
- });
- });
-});
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts
index 0a60c7aa878..fab8d15cf98 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts
+++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts
@@ -15,12 +15,14 @@ import {
import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service';
import { Context } from '@dspace/core/shared/context.model';
import { Item } from '@dspace/core/shared/item.model';
+import { MetadataValue } from '@dspace/core/shared/metadata.models';
import { SearchResult } from '@dspace/core/shared/search/models/search-result.model';
import { WorkflowItem } from '@dspace/core/submission/models/workflowitem.model';
import { TranslateModule } from '@ngx-translate/core';
import { ThemedThumbnailComponent } from '../../../../thumbnail/themed-thumbnail.component';
import { fadeInOut } from '../../../animations/fade';
+import { MetadataLinkViewComponent } from '../../../metadata-link-view/metadata-link-view.component';
import { ThemedBadgesComponent } from '../../../object-collection/shared/badges/themed-badges.component';
import { ItemCollectionComponent } from '../../../object-collection/shared/mydspace-item-collection/item-collection.component';
import { ItemSubmitterComponent } from '../../../object-collection/shared/mydspace-item-submitter/item-submitter.component';
@@ -39,6 +41,7 @@ import { TruncatablePartComponent } from '../../../truncatable/truncatable-part/
AsyncPipe,
ItemCollectionComponent,
ItemSubmitterComponent,
+ MetadataLinkViewComponent,
NgClass,
ThemedBadgesComponent,
ThemedThumbnailComponent,
@@ -81,6 +84,8 @@ export class ItemListPreviewComponent implements OnInit {
dsoTitle: string;
+ authorMetadataList: MetadataValue[] = [];
+
constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
public dsoNameService: DSONameService,
@@ -90,6 +95,7 @@ export class ItemListPreviewComponent implements OnInit {
ngOnInit(): void {
this.showThumbnails = this.appConfig.browseBy.showThumbnails;
this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.item, true);
+ this.authorMetadataList = this.item.allMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'], undefined, true);
}
diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html
index ba12d01189a..3a2f5ed03af 100644
--- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html
+++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html
@@ -23,45 +23,60 @@
}
- @if (object !== undefined && object !== null) {
-