[CST-4591] Create entity scrollable dropdown

This commit is contained in:
Giuseppe Digilio
2021-09-22 17:36:18 +02:00
parent 51ada1c933
commit 2e0095a587
5 changed files with 425 additions and 1 deletions

View File

@@ -0,0 +1,28 @@
<div
class="scrollable-menu"
aria-labelledby="dropdownMenuButton"
(scroll)="onScroll($event)"
infiniteScroll
[infiniteScrollDistance]="5"
[infiniteScrollThrottle]="300"
[infiniteScrollUpDistance]="1.5"
[fromRoot]="true"
[scrollWindow]="false"
(scrolled)="onScrollDown()">
<button class="dropdown-item disabled" *ngIf="searchListEntity?.length == 0 && !(isLoadingList | async)">
{{'submission.sections.general.no-entity' | translate}}
</button>
<button *ngFor="let listItem of searchListEntity"
class="dropdown-item entity-item"
title="{{ listItem.label }}"
(click)="onSelect(listItem)">
<ul class="list-unstyled mb-0">
<li class="list-item text-truncate text-primary font-weight-bold">{{ listItem.label.toLowerCase() + '.listelement.badge' | translate }}</li>
</ul>
</button>
<button class="dropdown-item disabled" *ngIf="(isLoadingList | async)" >
<ds-loading message="{{'loading.default' | translate}}">
</ds-loading>
</button>
</div>

View File

@@ -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);
}

View File

@@ -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<EntityDropdownComponent>;
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();
});
});

View File

@@ -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<ItemType[]>}
*/
public searchListEntity$: Observable<ItemType[]>;
/**
* 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<ItemType>();
/**
* A boolean representing if the loader is visible or not
*/
public isLoadingList: BehaviorSubject<boolean> = 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<PaginatedList<ItemType>>) => {
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());
}
}

View File

@@ -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 { 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 { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component';
import { import {
DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn, DsDynamicFormControlContainerComponent,
dsDynamicFormControlMapFn,
} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; } 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 { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component';
import { DragClickDirective } from './utils/drag-click.directive'; 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 { 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 { FileDownloadLinkComponent } from './file-download-link/file-download-link.component';
import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.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 { DsSelectComponent } from './ds-select/ds-select.component';
import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component'; import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component';
import { CurationFormComponent } from '../curation-form/curation-form.component'; import { CurationFormComponent } from '../curation-form/curation-form.component';
@@ -432,6 +434,7 @@ const COMPONENTS = [
FileDownloadLinkComponent, FileDownloadLinkComponent,
BitstreamDownloadPageComponent, BitstreamDownloadPageComponent,
CollectionDropdownComponent, CollectionDropdownComponent,
EntityDropdownComponent,
ExportMetadataSelectorComponent, ExportMetadataSelectorComponent,
ConfirmationModalComponent, ConfirmationModalComponent,
VocabularyTreeviewComponent, VocabularyTreeviewComponent,