1
0

Merge pull request #1341 from atmire/w2p-83783_show-all-collections-on-item-page-rebased

Show owning and mapped collections on item page
This commit is contained in:
Tim Donohue
2021-10-07 11:38:33 -05:00
committed by GitHub
5 changed files with 393 additions and 80 deletions

View File

@@ -208,10 +208,20 @@ export class CollectionDataService extends ComColDataService<Collection> {
}
/**
* 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<RemoteData<Collection>> {
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<RemoteData<PaginatedList<Collection>>> {
return this.findAllByHref(item._links.mappedCollections.href, findListOptions);
}
}

View File

@@ -1,7 +1,21 @@
<ds-metadata-field-wrapper *ngIf="(this.collectionsRD$ | async)?.hasSucceeded" [label]="label | translate">
<ds-metadata-field-wrapper [label]="label | translate">
<div class="collections">
<a *ngFor="let collection of (this.collectionsRD$ | async)?.payload?.page; let last=last;" [routerLink]="['/collections', collection.id]">
<a *ngFor="let collection of (this.collections$ | async); let last=last;" [routerLink]="['/collections', collection.id]">
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
</a>
</div>
<div *ngIf="isLoading$ | async">
{{'item.page.collections.loading' | translate}}
</div>
<a
*ngIf="!(isLoading$ | async) && (hasMore$ | async)"
(click)="$event.preventDefault(); handleLoadMore()"
class="load-more-btn btn btn-sm btn-outline-secondary"
role="button"
href="#"
>
{{'item.page.collections.load-more' | translate}}
</a>
</ds-metadata-field-wrapper>

View File

@@ -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<CollectionsComponent>;
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<CollectionsComponent>;
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 = '<br/>';
component = fixture.componentInstance;
component.item = mockItem;
component.label = 'test.test';
component.separator = '<br/>';
component.pageSize = 2;
}));
describe('When the requested item request has succeeded', () => {
describe('when the item has only an owning collection', () => {
let mockPage1: PaginatedList<Collection>;
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<Collection>;
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<Collection>;
let mockPage2: PaginatedList<Collection>;
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<Collection>;
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();
});
});
});

View File

@@ -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 = '<br/>';
collectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
/**
* Amount of mapped collections that should be fetched at once.
*/
pageSize = 5;
/**
* Last page of the mapped collections that has been fetched.
*/
lastPage$: BehaviorSubject<number> = new BehaviorSubject<number>(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<void> = new BehaviorSubject(undefined);
/**
* Whether or not a page of mapped collections is currently being loaded.
*/
isLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
/**
* Whether or not more pages of mapped collections are available.
*/
hasMore$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(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<Collection[]>;
constructor(private cds: CollectionDataService) {
}
ngOnInit(): void {
// this.collections = this.item.parents.payload;
const owningCollection$: Observable<Collection> = 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<Collection>) => {
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<Collection[]> = 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<PaginatedList<Collection>>(),
// update isLoading$
tap(() => this.isLoading$.next(false)),
getAllSucceededRemoteDataPayload(),
// update hasMore$
tap((response: PaginatedList<Collection>) => this.hasMore$.next(response.currentPage < response.totalPages)),
// update lastPage$
tap((response: PaginatedList<Collection>) => this.lastPage$.next(response.currentPage)),
getPaginatedListPayload<Collection>(),
// add current batch to list of collections
scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []),
startWith([]),
) as Observable<Collection[]>;
this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe(
map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => {
return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection));
}),
);
}
handleLoadMore() {
this.loadMore$.next();
}
}

View File

@@ -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",