diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index f58f36450f..12bd12ec59 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -208,10 +208,20 @@ export class CollectionDataService extends ComColDataService { } /** - * Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item + * Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item * @param item Item we want the owning collection of */ findOwningCollectionFor(item: Item): Observable> { return this.findByHref(item._links.owningCollection.href); } + + /** + * Get a list of mapped collections for the given item. + * @param item Item for which the mapped collections should be retrieved. + * @param findListOptions Pagination and search options. + */ + findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable>> { + return this.findAllByHref(item._links.mappedCollections.href, findListOptions); + } + } diff --git a/src/app/item-page/field-components/collections/collections.component.html b/src/app/item-page/field-components/collections/collections.component.html index e0f963b5bc..e8f682a182 100644 --- a/src/app/item-page/field-components/collections/collections.component.html +++ b/src/app/item-page/field-components/collections/collections.component.html @@ -1,7 +1,21 @@ - + + +
+ {{'item.page.collections.loading' | translate}} +
+ + + {{'item.page.collections.load-more' | translate}} +
diff --git a/src/app/item-page/field-components/collections/collections.component.spec.ts b/src/app/item-page/field-components/collections/collections.component.spec.ts index 70ce5db760..d5278706da 100644 --- a/src/app/item-page/field-components/collections/collections.component.spec.ts +++ b/src/app/item-page/field-components/collections/collections.component.spec.ts @@ -9,46 +9,45 @@ import { Item } from '../../../core/shared/item.model'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { CollectionsComponent } from './collections.component'; +import { FindListOptions } from '../../../core/data/request.models'; +import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; -let collectionsComponent: CollectionsComponent; -let fixture: ComponentFixture; - -let collectionDataServiceStub; - -const mockCollection1: Collection = Object.assign(new Collection(), { - metadata: { - 'dc.description.abstract': [ - { - language: 'en_US', - value: 'Short description' - } - ] - }, - _links: { - self: { href: 'collection-selflink' } - } +const createMockCollection = (id: string) => Object.assign(new Collection(), { + id: id, + name: `collection-${id}`, }); -const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)}); -const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)}); +const mockItem: Item = new Item(); describe('CollectionsComponent', () => { - collectionDataServiceStub = { - findOwningCollectionFor(item: Item) { - if (item === succeededMockItem) { - return createSuccessfulRemoteDataObject$(mockCollection1); - } else { - return createFailedRemoteDataObject$('error', 500); - } - } - }; + let collectionDataService; + + let mockCollection1: Collection; + let mockCollection2: Collection; + let mockCollection3: Collection; + let mockCollection4: Collection; + + let component: CollectionsComponent; + let fixture: ComponentFixture; + beforeEach(waitForAsync(() => { + collectionDataService = jasmine.createSpyObj([ + 'findOwningCollectionFor', + 'findMappedCollectionsFor', + ]); + + mockCollection1 = createMockCollection('c1'); + mockCollection2 = createMockCollection('c2'); + mockCollection3 = createMockCollection('c3'); + mockCollection4 = createMockCollection('c4'); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [ CollectionsComponent ], providers: [ { provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()}, - { provide: CollectionDataService, useValue: collectionDataServiceStub }, + { provide: CollectionDataService, useValue: collectionDataService }, ], schemas: [ NO_ERRORS_SCHEMA ] @@ -59,33 +58,264 @@ describe('CollectionsComponent', () => { beforeEach(waitForAsync(() => { fixture = TestBed.createComponent(CollectionsComponent); - collectionsComponent = fixture.componentInstance; - collectionsComponent.label = 'test.test'; - collectionsComponent.separator = '
'; - + component = fixture.componentInstance; + component.item = mockItem; + component.label = 'test.test'; + component.separator = '
'; + component.pageSize = 2; })); - describe('When the requested item request has succeeded', () => { + describe('when the item has only an owning collection', () => { + let mockPage1: PaginatedList; + beforeEach(() => { - collectionsComponent.item = succeededMockItem; + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 0, + totalElements: 0, + }), []); + + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1)); fixture.detectChanges(); }); - it('should show the collection', () => { - const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections')); - expect(collectionField).not.toBeNull(); + it('should display the owning collection', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(1); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + + expect(component.lastPage$.getValue()).toBe(1); + expect(component.hasMore$.getValue()).toBe(false); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeNull(); }); }); - describe('When the requested item request has failed', () => { + describe('when the item has an owning collection and one mapped collection', () => { + let mockPage1: PaginatedList; + beforeEach(() => { - collectionsComponent.item = failedMockItem; + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 1, + totalElements: 1, + }), [mockCollection2]); + + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1)); fixture.detectChanges(); }); - it('should not show the collection', () => { - const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections')); - expect(collectionField).toBeNull(); + it('should display the owning collection and the mapped collection', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(2); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2'); + + expect(component.lastPage$.getValue()).toBe(1); + expect(component.hasMore$.getValue()).toBe(false); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeNull(); }); }); + + describe('when the item has an owning collection and multiple mapped collections', () => { + let mockPage1: PaginatedList; + let mockPage2: PaginatedList; + + beforeEach(() => { + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 2, + totalElements: 3, + }), [mockCollection2, mockCollection3]); + + mockPage2 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 2, + elementsPerPage: 2, + totalPages: 2, + totalElements: 1, + }), [mockCollection4]); + + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValues( + createSuccessfulRemoteDataObject$(mockPage1), + createSuccessfulRemoteDataObject$(mockPage2), + ); + fixture.detectChanges(); + }); + + it('should display the owning collection, two mapped collections and a load more button', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(3); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2'); + expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3'); + + expect(component.lastPage$.getValue()).toBe(1); + expect(component.hasMore$.getValue()).toBe(true); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeTruthy(); + }); + + describe('when the load more button is clicked', () => { + beforeEach(() => { + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + loadMoreBtn.nativeElement.click(); + fixture.detectChanges(); + }); + + it('should display the owning collection and three mapped collections', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledTimes(2); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 2, + })); + + expect(collectionFields.length).toBe(4); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2'); + expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3'); + expect(collectionFields[3].nativeElement.textContent).toEqual('collection-c4'); + + expect(component.lastPage$.getValue()).toBe(2); + expect(component.hasMore$.getValue()).toBe(false); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeNull(); + }); + }); + }); + + describe('when the request for the owning collection fails', () => { + let mockPage1: PaginatedList; + + beforeEach(() => { + mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), { + currentPage: 1, + elementsPerPage: 2, + totalPages: 1, + totalElements: 1, + }), [mockCollection2]); + + collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$()); + collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1)); + fixture.detectChanges(); + }); + + it('should display the mapped collection only', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(1); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c2'); + + expect(component.lastPage$.getValue()).toBe(1); + expect(component.hasMore$.getValue()).toBe(false); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeNull(); + }); + }); + + describe('when the request for the mapped collections fails', () => { + beforeEach(() => { + collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1)); + collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$()); + fixture.detectChanges(); + }); + + it('should display the owning collection only', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(1); + expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1'); + + expect(component.lastPage$.getValue()).toBe(0); + expect(component.hasMore$.getValue()).toBe(true); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeTruthy(); + }); + }); + + describe('when both requests fail', () => { + beforeEach(() => { + collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$()); + collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$()); + fixture.detectChanges(); + }); + + it('should display no collections', () => { + const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a')); + const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn')); + + expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem); + expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), { + elementsPerPage: 2, + currentPage: 1, + })); + + expect(collectionFields.length).toBe(0); + + expect(component.lastPage$.getValue()).toBe(0); + expect(component.hasMore$.getValue()).toBe(true); + expect(component.isLoading$.getValue()).toBe(false); + + expect(loadMoreBtn).toBeTruthy(); + }); + }); + }); diff --git a/src/app/item-page/field-components/collections/collections.component.ts b/src/app/item-page/field-components/collections/collections.component.ts index 32dc8dfb73..23aff80160 100644 --- a/src/app/item-page/field-components/collections/collections.component.ts +++ b/src/app/item-page/field-components/collections/collections.component.ts @@ -1,14 +1,19 @@ import { Component, Input, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model'; -import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; import { Collection } from '../../../core/shared/collection.model'; import { Item } from '../../../core/shared/item.model'; -import { PageInfo } from '../../../core/shared/page-info.model'; import { hasValue } from '../../../shared/empty.util'; +import { FindListOptions } from '../../../core/data/request.models'; +import { + getAllCompletedRemoteData, + getAllSucceededRemoteDataPayload, + getFirstSucceededRemoteDataPayload, + getPaginatedListPayload, +} from '../../../core/shared/operators'; /** * This component renders the parent collections section of the item @@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit { separator = '
'; - collectionsRD$: Observable>>; + /** + * Amount of mapped collections that should be fetched at once. + */ + pageSize = 5; + + /** + * Last page of the mapped collections that has been fetched. + */ + lastPage$: BehaviorSubject = new BehaviorSubject(0); + + /** + * Push an event to this observable to fetch the next page of mapped collections. + * Because this observable is a behavior subject, the first page will be requested + * immediately after subscription. + */ + loadMore$: BehaviorSubject = new BehaviorSubject(undefined); + + /** + * Whether or not a page of mapped collections is currently being loaded. + */ + isLoading$: BehaviorSubject = new BehaviorSubject(true); + + /** + * Whether or not more pages of mapped collections are available. + */ + hasMore$: BehaviorSubject = new BehaviorSubject(true); + + /** + * All collections that have been retrieved so far. This includes the owning collection, + * as well as any number of pages of mapped collections. + */ + collections$: Observable; constructor(private cds: CollectionDataService) { } ngOnInit(): void { - // this.collections = this.item.parents.payload; + const owningCollection$: Observable = this.cds.findOwningCollectionFor(this.item).pipe( + getFirstSucceededRemoteDataPayload(), + startWith(null as Collection), + ); - // TODO: this should use parents, but the collections - // for an Item aren't returned by the REST API yet, - // only the owning collection - this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe( - map((rd: RemoteData) => { - if (hasValue(rd.payload)) { - return new RemoteData( - rd.timeCompleted, - rd.msToLive, - rd.lastUpdated, - rd.state, - rd.errorMessage, - buildPaginatedList({ - elementsPerPage: 10, - totalPages: 1, - currentPage: 1, - totalElements: 1, - _links: { - self: rd.payload._links.self - } - } as PageInfo, [rd.payload]), - rd.statusCode - ); - } else { - return rd as any; - } - }) + const mappedCollections$: Observable = this.loadMore$.pipe( + // update isLoading$ + tap(() => this.isLoading$.next(true)), + + // request next batch of mapped collections + withLatestFrom(this.lastPage$), + switchMap(([_, lastPage]: [void, number]) => { + return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), { + elementsPerPage: this.pageSize, + currentPage: lastPage + 1, + })); + }), + + getAllCompletedRemoteData>(), + + // update isLoading$ + tap(() => this.isLoading$.next(false)), + + getAllSucceededRemoteDataPayload(), + + // update hasMore$ + tap((response: PaginatedList) => this.hasMore$.next(response.currentPage < response.totalPages)), + + // update lastPage$ + tap((response: PaginatedList) => this.lastPage$.next(response.currentPage)), + + getPaginatedListPayload(), + + // add current batch to list of collections + scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []), + + startWith([]), + ) as Observable; + + this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe( + map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => { + return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection)); + }), ); } + + handleLoadMore() { + this.loadMore$.next(); + } + } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index aa76d2e422..1bacd39bbc 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1874,6 +1874,10 @@ "item.page.collections": "Collections", + "item.page.collections.loading": "Loading...", + + "item.page.collections.load-more": "Load more", + "item.page.date": "Date", "item.page.edit": "Edit this item",