diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.html b/src/app/shared/entity-dropdown/entity-dropdown.component.html
new file mode 100644
index 0000000000..59c242ef97
--- /dev/null
+++ b/src/app/shared/entity-dropdown/entity-dropdown.component.html
@@ -0,0 +1,28 @@
+
diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.scss b/src/app/shared/entity-dropdown/entity-dropdown.component.scss
new file mode 100644
index 0000000000..a5f43f359b
--- /dev/null
+++ b/src/app/shared/entity-dropdown/entity-dropdown.component.scss
@@ -0,0 +1,19 @@
+.list-item:active {
+ color: white !important;
+}
+
+.scrollable-menu {
+ height: auto;
+ max-height: var(--ds-dropdown-menu-max-height);
+ overflow-x: hidden;
+}
+
+.entity-item {
+ border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);
+}
+
+#entityControlsDropdownMenu {
+ outline: 0;
+ left: 0 !important;
+ box-shadow: var(--bs-btn-focus-box-shadow);
+}
diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts b/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts
new file mode 100644
index 0000000000..0cc14cae22
--- /dev/null
+++ b/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts
@@ -0,0 +1,167 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { EntityDropdownComponent } from './entity-dropdown.component';
+import { getTestScheduler } from 'jasmine-marbles';
+import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
+import { ItemType } from '../../core/shared/item-relationships/item-type.model';
+import { ChangeDetectorRef, NO_ERRORS_SCHEMA, Pipe, PipeTransform } from '@angular/core';
+import { EntityTypeService } from '../../core/data/entity-type.service';
+import { TestScheduler } from 'rxjs/testing';
+import { By } from '@angular/platform-browser';
+import { createPaginatedList } from '../testing/utils.test';
+
+// tslint:disable-next-line:pipe-prefix
+@Pipe({ name: 'translate' })
+class MockTranslatePipe implements PipeTransform {
+ transform(value: string): string {
+ return value;
+ }
+}
+
+const entities: ItemType[] = [
+ Object.assign(new ItemType(), {
+ id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88',
+ label: 'Entity_1',
+ uuid: 'UUID-ce64f48e-2c9b-411a-ac36-ee429c0e6a88'
+ }),
+ Object.assign(new ItemType(), {
+ id: '59ee713b-ee53-4220-8c3f-9860dc84fe33',
+ label: 'Entity_2',
+ uuid: 'UUID-59ee713b-ee53-4220-8c3f-9860dc84fe33'
+ }),
+ Object.assign(new ItemType(), {
+ id: 'e9dbf393-7127-415f-8919-55be34a6e9ed',
+ label: 'Entity_3',
+ uuid: 'UUID-7127-415f-8919-55be34a6e9ed'
+ }),
+ Object.assign(new ItemType(), {
+ id: '59da2ff0-9bf4-45bf-88be-e35abd33f304',
+ label: 'Entity_4',
+ uuid: 'UUID-59da2ff0-9bf4-45bf-88be-e35abd33f304'
+ }),
+ Object.assign(new ItemType(), {
+ id: 'a5159760-f362-4659-9e81-e3253ad91ede',
+ label: 'Entity_5',
+ uuid: 'UUID-a5159760-f362-4659-9e81-e3253ad91ede'
+ }),
+];
+
+const listElementMock: ItemType = Object.assign(
+ new ItemType(), {
+ id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88',
+ label: 'Entity_1',
+ uuid: 'UUID-ce64f48e-2c9b-411a-ac36-ee429c0e6a88'
+}
+);
+
+describe('EntityDropdownComponent', () => {
+ let component: EntityDropdownComponent;
+ let componentAsAny: any;
+ let fixture: ComponentFixture;
+ let scheduler: TestScheduler;
+
+ const entityTypeServiceMock: any = jasmine.createSpyObj('EntityTypeService', {
+ getAllAuthorizedRelationshipType: jasmine.createSpy('getAllAuthorizedRelationshipType'),
+ getAllAuthorizedRelationshipTypeImport: jasmine.createSpy('getAllAuthorizedRelationshipTypeImport')
+ });
+
+
+ let translatePipeSpy: jasmine.Spy;
+
+ const paginatedEntities = createPaginatedList(entities);
+ const paginatedEntitiesRD$ = createSuccessfulRemoteDataObject$(paginatedEntities);
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [],
+ declarations: [EntityDropdownComponent, MockTranslatePipe],
+ providers: [
+ { provide: EntityTypeService, useValue: entityTypeServiceMock },
+ ChangeDetectorRef
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+ fixture = TestBed.createComponent(EntityDropdownComponent);
+ component = fixture.componentInstance;
+ componentAsAny = fixture.componentInstance;
+ componentAsAny.entityTypeService.getAllAuthorizedRelationshipType.and.returnValue(paginatedEntitiesRD$);
+ componentAsAny.entityTypeService.getAllAuthorizedRelationshipTypeImport.and.returnValue(paginatedEntitiesRD$);
+ component.isSubmission = true;
+
+ translatePipeSpy = spyOn(MockTranslatePipe.prototype, 'transform');
+ });
+
+ it('should translate entries', () => {
+ scheduler.schedule(() => fixture.detectChanges());
+ scheduler.flush();
+
+ expect(translatePipeSpy).toHaveBeenCalledWith('entity_1.listelement.badge');
+ });
+
+ it('should init component with entities list', () => {
+ spyOn(component.subs, 'push');
+ spyOn(component, 'resetPagination');
+ spyOn(component, 'populateEntityList').and.callThrough();
+
+ scheduler.schedule(() => fixture.detectChanges());
+ scheduler.flush();
+ const elements = fixture.debugElement.queryAll(By.css('.entity-item'));
+
+ expect(elements.length).toEqual(5);
+ expect(component.subs.push).toHaveBeenCalled();
+ expect(component.resetPagination).toHaveBeenCalled();
+ expect(component.populateEntityList).toHaveBeenCalled();
+ expect((component as any).entityTypeService.getAllAuthorizedRelationshipType).toHaveBeenCalled();
+ });
+
+ it('should trigger onSelect method when select a new entity from list', () => {
+ scheduler.schedule(() => fixture.detectChanges());
+ scheduler.flush();
+
+ spyOn(component, 'onSelect');
+ const entityItem = fixture.debugElement.query(By.css('.entity-item:nth-child(2)'));
+ entityItem.triggerEventHandler('click', null);
+
+ scheduler.schedule(() => fixture.detectChanges());
+ scheduler.flush();
+
+ expect(component.onSelect).toHaveBeenCalled();
+ });
+
+ it('should emit selectionChange event when selecting a new entity', () => {
+ spyOn(component.selectionChange, 'emit').and.callThrough();
+ component.ngOnInit();
+ component.onSelect(listElementMock as any);
+ fixture.detectChanges();
+
+ expect(component.selectionChange.emit).toHaveBeenCalledWith(listElementMock as any);
+ });
+
+ it('should change loader status', () => {
+ spyOn(component.isLoadingList, 'next').and.callThrough();
+ component.hideShowLoader(true);
+
+ expect(component.isLoadingList.next).toHaveBeenCalledWith(true);
+ });
+
+ it('reset pagination fields', () => {
+ component.resetPagination();
+
+ expect(component.currentPage).toEqual(1);
+ expect(component.hasNextPage).toEqual(true);
+ expect(component.searchListEntity).toEqual([]);
+ });
+
+ it('should invoke the method getAllAuthorizedRelationshipTypeImport of EntityTypeService when isSubmission is false', () => {
+ component.isSubmission = false;
+
+ scheduler.schedule(() => fixture.detectChanges());
+ scheduler.flush();
+
+ expect((component as any).entityTypeService.getAllAuthorizedRelationshipTypeImport).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.ts b/src/app/shared/entity-dropdown/entity-dropdown.component.ts
new file mode 100644
index 0000000000..13d50a8b79
--- /dev/null
+++ b/src/app/shared/entity-dropdown/entity-dropdown.component.ts
@@ -0,0 +1,207 @@
+import {
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ EventEmitter,
+ HostListener,
+ Input,
+ OnDestroy,
+ OnInit,
+ Output
+} from '@angular/core';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { hasValue } from '../empty.util';
+import { reduce, startWith, switchMap } from 'rxjs/operators';
+import { RemoteData } from '../../core/data/remote-data';
+import { FindListOptions } from '../../core/data/request.models';
+import { PaginatedList } from '../../core/data/paginated-list.model';
+import { EntityTypeService } from '../../core/data/entity-type.service';
+import { ItemType } from '../../core/shared/item-relationships/item-type.model';
+import { getFirstSucceededRemoteWithNotEmptyData } from '../../core/shared/operators';
+
+@Component({
+ selector: 'ds-entity-dropdown',
+ templateUrl: './entity-dropdown.component.html',
+ styleUrls: ['./entity-dropdown.component.scss']
+})
+export class EntityDropdownComponent implements OnInit, OnDestroy {
+ /**
+ * The entity list obtained from a search
+ * @type {Observable}
+ */
+ public searchListEntity$: Observable;
+
+ /**
+ * A boolean representing if dropdown list is scrollable to the bottom
+ * @type {boolean}
+ */
+ private scrollableBottom = false;
+
+ /**
+ * A boolean representing if dropdown list is scrollable to the top
+ * @type {boolean}
+ */
+ private scrollableTop = false;
+
+ /**
+ * The list of entity to render
+ */
+ public searchListEntity: ItemType[] = [];
+
+ /**
+ * TRUE if the parent operation is a 'new submission' operation, FALSE otherwise (eg.: is an 'Import metadata from an external source' operation).
+ */
+ @Input() isSubmission: boolean;
+
+ /**
+ * The entity to output to the parent component
+ */
+ @Output() selectionChange = new EventEmitter();
+
+ /**
+ * A boolean representing if the loader is visible or not
+ */
+ public isLoadingList: BehaviorSubject = new BehaviorSubject(false);
+
+ /**
+ * A numeric representig current page
+ */
+ public currentPage: number;
+
+ /**
+ * A boolean representing if exist another page to render
+ */
+ public hasNextPage: boolean;
+
+ /**
+ * Array to track all subscriptions and unsubscribe them onDestroy
+ * @type {Array}
+ */
+ public subs: Subscription[] = [];
+
+ /**
+ * Initialize instance variables
+ *
+ * @param {ChangeDetectorRef} changeDetectorRef
+ * @param {EntityTypeService} entityTypeService
+ * @param {ElementRef} el
+ */
+ constructor(
+ private changeDetectorRef: ChangeDetectorRef,
+ private entityTypeService: EntityTypeService,
+ private el: ElementRef
+ ) { }
+
+ /**
+ * Method called on mousewheel event, it prevent the page scroll
+ * when arriving at the top/bottom of dropdown menu
+ *
+ * @param event
+ * mousewheel event
+ */
+ @HostListener('mousewheel', ['$event']) onMousewheel(event) {
+ if (event.wheelDelta > 0 && this.scrollableTop) {
+ event.preventDefault();
+ }
+ if (event.wheelDelta < 0 && this.scrollableBottom) {
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * Initialize entity list
+ */
+ ngOnInit() {
+ this.resetPagination();
+ this.populateEntityList(this.currentPage);
+ }
+
+ /**
+ * Check if dropdown scrollbar is at the top or bottom of the dropdown list
+ *
+ * @param event
+ */
+ public onScroll(event) {
+ this.scrollableBottom = (event.target.scrollTop + event.target.clientHeight === event.target.scrollHeight);
+ this.scrollableTop = (event.target.scrollTop === 0);
+ }
+
+ /**
+ * Method used from infitity scroll for retrive more data on scroll down
+ */
+ public onScrollDown() {
+ if ( this.hasNextPage ) {
+ this.populateEntityList(++this.currentPage);
+ }
+ }
+
+ /**
+ * Emit a [selectionChange] event when a new entity is selected from list
+ *
+ * @param event
+ * the selected [ItemType]
+ */
+ public onSelect(event: ItemType) {
+ this.selectionChange.emit(event);
+ }
+
+ /**
+ * Method called for populate the entity list
+ * @param page page number
+ */
+ public populateEntityList(page: number) {
+ this.isLoadingList.next(true);
+ // Set the pagination info
+ const findOptions: FindListOptions = {
+ elementsPerPage: 10,
+ currentPage: page
+ };
+ let searchListEntity$;
+ if (this.isSubmission) {
+ searchListEntity$ = this.entityTypeService.getAllAuthorizedRelationshipType(findOptions);
+ } else {
+ searchListEntity$ = this.entityTypeService.getAllAuthorizedRelationshipTypeImport(findOptions);
+ }
+ this.searchListEntity$ = searchListEntity$.pipe(
+ getFirstSucceededRemoteWithNotEmptyData(),
+ switchMap((entityType: RemoteData>) => {
+ if ( (this.searchListEntity.length + findOptions.elementsPerPage) >= entityType.payload.totalElements ) {
+ this.hasNextPage = false;
+ }
+ return entityType.payload.page;
+ }),
+ reduce((acc: any, value: any) => [...acc, value], []),
+ startWith([])
+ );
+ this.subs.push(
+ this.searchListEntity$.subscribe(
+ (next) => { this.searchListEntity.push(...next); }, undefined,
+ () => { this.hideShowLoader(false); this.changeDetectorRef.detectChanges(); }
+ )
+ );
+ }
+
+ /**
+ * Reset pagination values
+ */
+ public resetPagination() {
+ this.currentPage = 1;
+ this.hasNextPage = true;
+ this.searchListEntity = [];
+ }
+
+ /**
+ * Hide/Show the entity list loader
+ * @param hideShow true for show, false otherwise
+ */
+ public hideShowLoader(hideShow: boolean) {
+ this.isLoadingList.next(hideShow);
+ }
+
+ /**
+ * Unsubscribe from all subscriptions
+ */
+ ngOnDestroy(): void {
+ this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
+ }
+}
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index 9b993e551f..b3120006a5 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -53,7 +53,8 @@ import { FormComponent } from './form/form.component';
import { DsDynamicOneboxComponent } from './form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component';
import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component';
import {
- DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn,
+ DsDynamicFormControlContainerComponent,
+ dsDynamicFormControlMapFn,
} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component';
import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component';
import { DragClickDirective } from './utils/drag-click.directive';
@@ -202,6 +203,7 @@ import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-grou
import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component';
import { FileDownloadLinkComponent } from './file-download-link/file-download-link.component';
import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.component';
+import { EntityDropdownComponent } from './entity-dropdown/entity-dropdown.component';
import { DsSelectComponent } from './ds-select/ds-select.component';
import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component';
import { CurationFormComponent } from '../curation-form/curation-form.component';
@@ -432,6 +434,7 @@ const COMPONENTS = [
FileDownloadLinkComponent,
BitstreamDownloadPageComponent,
CollectionDropdownComponent,
+ EntityDropdownComponent,
ExportMetadataSelectorComponent,
ConfirmationModalComponent,
VocabularyTreeviewComponent,