mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
[CST-4591] Create entity scrollable dropdown
This commit is contained in:
@@ -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>
|
@@ -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);
|
||||
}
|
167
src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts
Normal file
167
src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
207
src/app/shared/entity-dropdown/entity-dropdown.component.ts
Normal file
207
src/app/shared/entity-dropdown/entity-dropdown.component.ts
Normal 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());
|
||||
}
|
||||
}
|
@@ -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,
|
||||
|
Reference in New Issue
Block a user