From 2e0095a587378f30ab707bab48b02c731f8e44cf Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Wed, 22 Sep 2021 17:36:18 +0200 Subject: [PATCH] [CST-4591] Create entity scrollable dropdown --- .../entity-dropdown.component.html | 28 +++ .../entity-dropdown.component.scss | 19 ++ .../entity-dropdown.component.spec.ts | 167 ++++++++++++++ .../entity-dropdown.component.ts | 207 ++++++++++++++++++ src/app/shared/shared.module.ts | 5 +- 5 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 src/app/shared/entity-dropdown/entity-dropdown.component.html create mode 100644 src/app/shared/entity-dropdown/entity-dropdown.component.scss create mode 100644 src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts create mode 100644 src/app/shared/entity-dropdown/entity-dropdown.component.ts 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,