Merge pull request #1247 from atmire/w2p-80195_Fix-move-item-page

Fix move item page
This commit is contained in:
Tim Donohue
2021-07-01 10:13:00 -05:00
committed by GitHub
15 changed files with 337 additions and 270 deletions

View File

@@ -5,19 +5,16 @@
<p>{{'item.edit.move.description' | translate}}</p> <p>{{'item.edit.move.description' | translate}}</p>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<ds-dso-input-suggestions #f id="search-form" <div class="card mb-3">
[suggestions]="(collectionSearchResults | async)" <div class="card-header">{{'dso-selector.placeholder' | translate: { type: 'collection' } }}</div>
[placeholder]="'item.edit.move.search.placeholder'| translate" <div class="card-body">
[action]="getCurrentUrl()" <ds-authorized-collection-selector [types]="COLLECTIONS"
[name]="'item-move'" [currentDSOId]="selectedCollection ? selectedCollection.id : originalCollection.id"
[(ngModel)]="selectedCollectionName" (onSelect)="selectDso($event)">
(clickSuggestion)="onClick($event)" </ds-authorized-collection-selector>
(typeSuggestion)="resetCollection($event)" </div>
(findSuggestions)="findSuggestions($event)" <div></div>
(click)="f.open()" </div>
ngDefaultControl>
</ds-dso-input-suggestions>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@@ -33,16 +30,24 @@
</div> </div>
</div> </div>
<button (click)="moveCollection()" class="btn btn-primary" [disabled]=!canSubmit> <div class="button-row bottom">
<span *ngIf="!processing"> {{'item.edit.move.move' | translate}}</span> <div class="float-right">
<span *ngIf="processing"><i class='fas fa-circle-notch fa-spin'></i> <button [routerLink]="[(itemPageRoute$ | async), 'edit']" class="btn btn-outline-secondary">
{{'item.edit.move.processing' | translate}} <i class="fas fa-arrow-left"></i> {{'item.edit.move.cancel' | translate}}
</span> </button>
</button> <button class="btn btn-primary mr-0" [disabled]="!canMove" (click)="moveToCollection()">
<button [routerLink]="[(itemPageRoute$ | async), 'edit']" <span *ngIf="!processing">
class="btn btn-outline-secondary"> <i class="fas fa-save"></i> {{'item.edit.move.save-button' | translate}}
{{'item.edit.move.cancel' | translate}} </span>
</button> <span *ngIf="processing">
<i class="fas fa-circle-notch fa-spin"></i> {{'item.edit.move.processing' | translate}}
</span>
</button>
<button class="btn btn-danger" [disabled]="!canSubmit" (click)="discard()">
<i class="fas fa-times"></i> {{"item.edit.move.discard-button" | translate}}
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -21,6 +21,8 @@ import {
createSuccessfulRemoteDataObject$ createSuccessfulRemoteDataObject$
} from '../../../shared/remote-data.utils'; } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RequestService } from '../../../core/data/request.service';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
describe('ItemMoveComponent', () => { describe('ItemMoveComponent', () => {
let comp: ItemMoveComponent; let comp: ItemMoveComponent;
@@ -47,18 +49,25 @@ describe('ItemMoveComponent', () => {
name: 'Test collection 2' name: 'Test collection 2'
}); });
const mockItemDataService = jasmine.createSpyObj({ let itemDataService;
moveToCollection: createSuccessfulRemoteDataObject$(collection1)
const mockItemDataServiceSuccess = jasmine.createSpyObj({
moveToCollection: createSuccessfulRemoteDataObject$(collection1),
findById: createSuccessfulRemoteDataObject$(mockItem),
}); });
const mockItemDataServiceFail = jasmine.createSpyObj({ const mockItemDataServiceFail = jasmine.createSpyObj({
moveToCollection: createFailedRemoteDataObject$('Internal server error', 500) moveToCollection: createFailedRemoteDataObject$('Internal server error', 500),
findById: createSuccessfulRemoteDataObject$(mockItem),
}); });
const routeStub = { const routeStub = {
data: observableOf({ data: observableOf({
dso: createSuccessfulRemoteDataObject(Object.assign(new Item(), { dso: createSuccessfulRemoteDataObject(Object.assign(new Item(), {
id: 'item1' id: 'item1',
owningCollection: createSuccessfulRemoteDataObject$(Object.assign(new Collection(), {
id: 'originalOwningCollection',
}))
})) }))
}) })
}; };
@@ -79,43 +88,40 @@ describe('ItemMoveComponent', () => {
const notificationsServiceStub = new NotificationsServiceStub(); const notificationsServiceStub = new NotificationsServiceStub();
const init = (mockItemDataService) => {
itemDataService = mockItemDataService;
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemMoveComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Router, useValue: routerStub },
{ provide: ItemDataService, useValue: mockItemDataService },
{ provide: NotificationsService, useValue: notificationsServiceStub },
{ provide: SearchService, useValue: mockSearchService },
{ provide: RequestService, useValue: getMockRequestService() },
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
fixture = TestBed.createComponent(ItemMoveComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
};
describe('ItemMoveComponent success', () => { describe('ItemMoveComponent success', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ init(mockItemDataServiceSuccess);
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemMoveComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Router, useValue: routerStub },
{ provide: ItemDataService, useValue: mockItemDataService },
{ provide: NotificationsService, useValue: notificationsServiceStub },
{ provide: SearchService, useValue: mockSearchService },
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
fixture = TestBed.createComponent(ItemMoveComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
}); });
it('should load suggestions', () => {
const expected = [
collection1,
collection2
];
comp.collectionSearchResults.subscribe((value) => {
expect(value).toEqual(expected);
}
);
});
it('should get current url ', () => { it('should get current url ', () => {
expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit'); expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit');
}); });
it('should on click select the correct collection name and id', () => { it('should select the correct collection name and id on click', () => {
const data = collection1; const data = collection1;
comp.onClick(data); comp.selectDso(data);
expect(comp.selectedCollectionName).toEqual('Test collection 1'); expect(comp.selectedCollectionName).toEqual('Test collection 1');
expect(comp.selectedCollection).toEqual(collection1); expect(comp.selectedCollection).toEqual(collection1);
@@ -128,12 +134,12 @@ describe('ItemMoveComponent', () => {
}); });
comp.selectedCollectionName = 'selected-collection-id'; comp.selectedCollectionName = 'selected-collection-id';
comp.selectedCollection = collection1; comp.selectedCollection = collection1;
comp.moveCollection(); comp.moveToCollection();
expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1); expect(itemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1);
}); });
it('should call notificationsService success message on success', () => { it('should call notificationsService success message on success', () => {
comp.moveCollection(); comp.moveToCollection();
expect(notificationsServiceStub.success).toHaveBeenCalled(); expect(notificationsServiceStub.success).toHaveBeenCalled();
}); });
@@ -142,26 +148,11 @@ describe('ItemMoveComponent', () => {
describe('ItemMoveComponent fail', () => { describe('ItemMoveComponent fail', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ init(mockItemDataServiceFail);
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemMoveComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Router, useValue: routerStub },
{ provide: ItemDataService, useValue: mockItemDataServiceFail },
{ provide: NotificationsService, useValue: notificationsServiceStub },
{ provide: SearchService, useValue: mockSearchService },
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
fixture = TestBed.createComponent(ItemMoveComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
}); });
it('should call notificationsService error message on fail', () => { it('should call notificationsService error message on fail', () => {
comp.moveCollection(); comp.moveToCollection();
expect(notificationsServiceStub.error).toHaveBeenCalled(); expect(notificationsServiceStub.error).toHaveBeenCalled();
}); });

View File

@@ -1,25 +1,21 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { first, map } from 'rxjs/operators'; import { map, switchMap } from 'rxjs/operators';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
getFirstSucceededRemoteData, getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload,
getFirstCompletedRemoteData, getAllSucceededRemoteDataPayload
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { ItemDataService } from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
import { Observable, of as observableOf } from 'rxjs'; import { Observable } from 'rxjs';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchService } from '../../../core/shared/search/search.service'; import { SearchService } from '../../../core/shared/search/search.service';
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
import { SearchResult } from '../../../shared/search/search-result.model';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { RequestService } from '../../../core/data/request.service';
@Component({ @Component({
selector: 'ds-item-move', selector: 'ds-item-move',
@@ -38,7 +34,8 @@ export class ItemMoveComponent implements OnInit {
inheritPolicies = false; inheritPolicies = false;
itemRD$: Observable<RemoteData<Item>>; itemRD$: Observable<RemoteData<Item>>;
collectionSearchResults: Observable<any[]> = observableOf([]); originalCollection: Collection;
selectedCollectionName: string; selectedCollectionName: string;
selectedCollection: Collection; selectedCollection: Collection;
canSubmit = false; canSubmit = false;
@@ -46,23 +43,26 @@ export class ItemMoveComponent implements OnInit {
item: Item; item: Item;
processing = false; processing = false;
pagination = new PaginationComponentOptions();
/** /**
* Route to the item's page * Route to the item's page
*/ */
itemPageRoute$: Observable<string>; itemPageRoute$: Observable<string>;
COLLECTIONS = [DSpaceObjectType.COLLECTION];
constructor(private route: ActivatedRoute, constructor(private route: ActivatedRoute,
private router: Router, private router: Router,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private itemDataService: ItemDataService, private itemDataService: ItemDataService,
private searchService: SearchService, private searchService: SearchService,
private translateService: TranslateService) { private translateService: TranslateService,
} private requestService: RequestService,
) {}
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(map((data) => data.dso), getFirstSucceededRemoteData()) as Observable<RemoteData<Item>>; this.itemRD$ = this.route.data.pipe(
map((data) => data.dso), getFirstSucceededRemoteData()
) as Observable<RemoteData<Item>>;
this.itemPageRoute$ = this.itemRD$.pipe( this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(), getAllSucceededRemoteDataPayload(),
map((item) => getItemPageRoute(item)) map((item) => getItemPageRoute(item))
@@ -71,43 +71,22 @@ export class ItemMoveComponent implements OnInit {
this.item = rd.payload; this.item = rd.payload;
} }
); );
this.pagination.pageSize = 5; this.itemRD$.pipe(
this.loadSuggestions(''); getFirstSucceededRemoteData(),
} getRemoteDataPayload(),
switchMap((item) => item.owningCollection),
/** getFirstSucceededRemoteData(),
* Find suggestions based on entered query getRemoteDataPayload(),
* @param query - Search query ).subscribe((collection) => {
*/ this.originalCollection = collection;
findSuggestions(query): void { });
this.loadSuggestions(query);
}
/**
* Load all available collections to move the item to.
* TODO: When the API support it, only fetch collections where user has ADD rights to.
*/
loadSuggestions(query): void {
this.collectionSearchResults = this.searchService.search(new PaginatedSearchOptions({
pagination: this.pagination,
dsoTypes: [DSpaceObjectType.COLLECTION],
query: query
})).pipe(
first(),
map((rd: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>) => {
return rd.payload.page.map((searchResult) => {
return searchResult.indexableObject;
});
}) ,
);
} }
/** /**
* Set the collection name and id based on the selected value * Set the collection name and id based on the selected value
* @param data - obtained from the ds-input-suggestions component * @param data - obtained from the ds-input-suggestions component
*/ */
onClick(data: any): void { selectDso(data: any): void {
this.selectedCollection = data; this.selectedCollection = data;
this.selectedCollectionName = data.name; this.selectedCollectionName = data.name;
this.canSubmit = true; this.canSubmit = true;
@@ -123,26 +102,41 @@ export class ItemMoveComponent implements OnInit {
/** /**
* Moves the item to a new collection based on the selected collection * Moves the item to a new collection based on the selected collection
*/ */
moveCollection() { moveToCollection() {
this.processing = true; this.processing = true;
this.itemDataService.moveToCollection(this.item.id, this.selectedCollection).pipe(getFirstCompletedRemoteData()).subscribe( const move$ = this.itemDataService.moveToCollection(this.item.id, this.selectedCollection)
(response: RemoteData<Collection>) => { .pipe(getFirstCompletedRemoteData());
this.router.navigate([getItemEditRoute(this.item)]);
if (response.hasSucceeded) { move$.subscribe((response: RemoteData<any>) => {
this.notificationsService.success(this.translateService.get('item.edit.move.success')); if (response.hasSucceeded) {
} else { this.notificationsService.success(this.translateService.get('item.edit.move.success'));
this.notificationsService.error(this.translateService.get('item.edit.move.error')); } else {
} this.notificationsService.error(this.translateService.get('item.edit.move.error'));
this.processing = false;
} }
); });
move$.pipe(
switchMap(() => this.requestService.setStaleByHrefSubstring(this.item.id)),
switchMap(() =>
this.itemDataService.findById(
this.item.id,
false,
true,
followLink('owningCollection')
)),
getFirstCompletedRemoteData()
).subscribe(() => {
this.processing = false;
this.router.navigate([getItemEditRoute(this.item)]);
});
} }
/** discard(): void {
* Resets the can submit when the user changes the content of the input field this.selectedCollection = null;
* @param data
*/
resetCollection(data: any) {
this.canSubmit = false; this.canSubmit = false;
} }
get canMove(): boolean {
return this.canSubmit && this.selectedCollection?.id !== this.originalCollection.id;
}
} }

View File

@@ -23,14 +23,7 @@ import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { PaginatedList } from './paginated-list.model'; import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { import { DeleteRequest, FindListOptions, GetRequest, PostRequest, PutRequest, RestRequest } from './request.models';
DeleteRequest,
FindListOptions,
GetRequest,
PostRequest,
PutRequest,
RestRequest
} from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { Bundle } from '../shared/bundle.model'; import { Bundle } from '../shared/bundle.model';
@@ -38,6 +31,9 @@ import { MetadataMap } from '../shared/metadata.models';
import { BundleDataService } from './bundle-data.service'; import { BundleDataService } from './bundle-data.service';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { NoContent } from '../shared/NoContent.model'; import { NoContent } from '../shared/NoContent.model';
import { GenericConstructor } from '../shared/generic-constructor';
import { ResponseParsingService } from './parsing.service';
import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service';
@Injectable() @Injectable()
@dataService(ITEM) @dataService(ITEM)
@@ -229,7 +225,7 @@ export class ItemDataService extends DataService<Item> {
* @param itemId * @param itemId
* @param collection * @param collection
*/ */
public moveToCollection(itemId: string, collection: Collection): Observable<RemoteData<Collection>> { public moveToCollection(itemId: string, collection: Collection): Observable<RemoteData<any>> {
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list'); headers = headers.append('Content-Type', 'text/uri-list');
@@ -242,9 +238,17 @@ export class ItemDataService extends DataService<Item> {
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
map((href: string) => { map((href: string) => {
const request = new PutRequest(requestId, href, collection._links.self.href, options); const request = new PutRequest(requestId, href, collection._links.self.href, options);
this.requestService.send(request); Object.assign(request, {
// TODO: for now, the move Item endpoint returns a malformed collection -- only look at the status code
getResponseParser(): GenericConstructor<ResponseParsingService> {
return StatusCodeOnlyResponseParsingService;
}
});
return request;
}) })
).subscribe(); ).subscribe((request) => {
this.requestService.send(request);
});
return this.rdbService.buildFromRequestUUID(requestId); return this.rdbService.buildFromRequestUUID(requestId);
} }

View File

@@ -14,12 +14,12 @@
[infiniteScrollContainer]="'.scrollable-menu'" [infiniteScrollContainer]="'.scrollable-menu'"
[fromRoot]="true" [fromRoot]="true"
(scrolled)="onScrollDown()"> (scrolled)="onScrollDown()">
<ng-container *ngIf="listEntries"> <ng-container *ngIf="listEntries$ | async">
<button class="list-group-item list-group-item-action border-0 disabled" <button class="list-group-item list-group-item-action border-0 disabled"
*ngIf="listEntries.length == 0"> *ngIf="(listEntries$ | async).length == 0">
{{'dso-selector.no-results' | translate: { type: typesString } }} {{'dso-selector.no-results' | translate: { type: typesString } }}
</button> </button>
<button *ngFor="let listEntry of listEntries" <button *ngFor="let listEntry of (listEntries$ | async)"
class="list-group-item list-group-item-action border-0 list-entry" class="list-group-item list-group-item-action border-0 list-entry"
[ngClass]="{'bg-primary': listEntry.indexableObject.id === currentDSOId}" [ngClass]="{'bg-primary': listEntry.indexableObject.id === currentDSOId}"
title="{{ listEntry.indexableObject.name }}" title="{{ listEntry.indexableObject.name }}"

View File

@@ -92,12 +92,18 @@ describe('DSOSelectorComponent', () => {
}); });
describe('populating listEntries', () => { describe('populating listEntries', () => {
it('should not be empty', () => { it('should not be empty', (done) => {
expect(component.listEntries.length).toBeGreaterThan(0); component.listEntries$.subscribe((listEntries) => {
expect(listEntries.length).toBeGreaterThan(0);
done();
});
}); });
it('should contain a combination of the current DSO and first page results', () => { it('should contain a combination of the current DSO and first page results', (done) => {
expect(component.listEntries).toEqual([searchResult, ...firstPageResults]); component.listEntries$.subscribe((listEntries) => {
expect(listEntries).toEqual([searchResult, ...firstPageResults]);
done();
});
}); });
describe('when current page increases', () => { describe('when current page increases', () => {
@@ -105,8 +111,11 @@ describe('DSOSelectorComponent', () => {
component.currentPage$.next(2); component.currentPage$.next(2);
}); });
it('should contain a combination of the current DSO, as well as first and second page results', () => { it('should contain a combination of the current DSO, as well as first and second page results', (done) => {
expect(component.listEntries).toEqual([searchResult, ...firstPageResults, ...nextPageResults]); component.listEntries$.subscribe((listEntries) => {
expect(listEntries).toEqual([searchResult, ...firstPageResults, ...nextPageResults]);
done();
});
}); });
}); });
}); });

View File

@@ -81,7 +81,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
/** /**
* List with search results of DSpace objects for the current query * List with search results of DSpace objects for the current query
*/ */
listEntries: SearchResult<DSpaceObject>[] = null; listEntries$: BehaviorSubject<SearchResult<DSpaceObject>[]> = new BehaviorSubject(null);
/** /**
* The current page to load * The current page to load
@@ -160,7 +160,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
this.loading = true; this.loading = true;
if (page === 1) { if (page === 1) {
// The first page is loading, this means we should reset the list instead of adding to it // The first page is loading, this means we should reset the list instead of adding to it
this.listEntries = null; this.listEntries$.next(null);
} }
return this.search(query, page).pipe( return this.search(query, page).pipe(
map((rd) => { map((rd) => {
@@ -181,15 +181,16 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
).subscribe((rd) => { ).subscribe((rd) => {
this.loading = false; this.loading = false;
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
if (hasNoValue(this.listEntries)) { const currentEntries = this.listEntries$.getValue();
this.listEntries = rd.payload.page; if (hasNoValue(currentEntries)) {
this.listEntries$.next(rd.payload.page);
} else { } else {
this.listEntries.push(...rd.payload.page); this.listEntries$.next([...currentEntries, ...rd.payload.page]);
} }
// Check if there are more pages available after the current one // Check if there are more pages available after the current one
this.hasNextPage = rd.payload.totalElements > this.listEntries.length; this.hasNextPage = rd.payload.totalElements > this.listEntries$.getValue().length;
} else { } else {
this.listEntries = null; this.listEntries$.next(null);
this.hasNextPage = false; this.hasNextPage = false;
} }
})); }));

View File

@@ -1,5 +1,5 @@
import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { ChangeDetectionStrategy, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ListableObjectComponentLoaderComponent } from './listable-object-component-loader.component'; import { ListableObjectComponentLoaderComponent } from './listable-object-component-loader.component';
import { ListableObject } from '../listable-object.model'; import { ListableObject } from '../listable-object.model';
import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { GenericConstructor } from '../../../../core/shared/generic-constructor';
@@ -117,17 +117,33 @@ describe('ListableObjectComponentLoaderComponent', () => {
}); });
describe('When a reloadedObject is emitted', () => { describe('When a reloadedObject is emitted', () => {
let listableComponent;
let reloadedObject: any;
it('should re-instantiate the listable component ', fakeAsync(() => { beforeEach(() => {
spyOn((comp as any), 'connectInputsAndOutputs').and.returnValue(null);
spyOn((comp as any).contentChange, 'emit').and.returnValue(null);
spyOn((comp as any), 'instantiateComponent').and.returnValue(null); listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
reloadedObject = 'object';
});
it('should pass it on connectInputsAndOutputs', fakeAsync(() => {
expect((comp as any).connectInputsAndOutputs).not.toHaveBeenCalled();
const listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
const reloadedObject: any = 'object';
(listableComponent as any).reloadedObject.emit(reloadedObject); (listableComponent as any).reloadedObject.emit(reloadedObject);
tick(); tick();
expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject); expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
}));
it('should re-emit it as a contentChange', fakeAsync(() => {
expect((comp as any).contentChange.emit).not.toHaveBeenCalled();
(listableComponent as any).reloadedObject.emit(reloadedObject);
tick();
expect((comp as any).contentChange.emit).toHaveBeenCalledWith(reloadedObject);
})); }));
}); });

View File

@@ -3,10 +3,14 @@ import {
ComponentFactoryResolver, ComponentFactoryResolver,
ElementRef, ElementRef,
Input, Input,
OnDestroy, OnInit, OnDestroy,
Output, ViewChild OnInit,
, Output,
EventEmitter ViewChild,
EventEmitter,
SimpleChanges,
OnChanges,
ComponentRef
} from '@angular/core'; } from '@angular/core';
import { ListableObject } from '../listable-object.model'; import { ListableObject } from '../listable-object.model';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
@@ -15,7 +19,7 @@ import { getListableObjectComponent } from './listable-object.decorator';
import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { ListableObjectDirective } from './listable-object.directive'; import { ListableObjectDirective } from './listable-object.directive';
import { CollectionElementLinkType } from '../../collection-element-link.type'; import { CollectionElementLinkType } from '../../collection-element-link.type';
import { hasValue } from '../../../empty.util'; import { hasValue, isNotEmpty } from '../../../empty.util';
import { Subscription } from 'rxjs/internal/Subscription'; import { Subscription } from 'rxjs/internal/Subscription';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
@@ -29,7 +33,7 @@ import { ThemeService } from '../../../theme-support/theme.service';
/** /**
* Component for determining what component to use depending on the item's entity type (dspace.entity.type) * Component for determining what component to use depending on the item's entity type (dspace.entity.type)
*/ */
export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy { export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges, OnDestroy {
/** /**
* The item or metadata to determine the component for * The item or metadata to determine the component for
*/ */
@@ -107,6 +111,25 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy
*/ */
protected subs: Subscription[] = []; protected subs: Subscription[] = [];
/**
* The reference to the dynamic component
*/
protected compRef: ComponentRef<Component>;
/**
* The list of input and output names for the dynamic component
*/
protected inAndOutputNames: string[] = [
'object',
'index',
'linkType',
'listID',
'showLabel',
'context',
'viewMode',
'value',
];
constructor( constructor(
private componentFactoryResolver: ComponentFactoryResolver, private componentFactoryResolver: ComponentFactoryResolver,
private themeService: ThemeService private themeService: ThemeService
@@ -120,6 +143,15 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy
this.instantiateComponent(this.object); this.instantiateComponent(this.object);
} }
/**
* Whenever the inputs change, update the inputs of the dynamic component
*/
ngOnChanges(changes: SimpleChanges): void {
if (this.inAndOutputNames.some((name: any) => hasValue(changes[name]))) {
this.connectInputsAndOutputs();
}
}
ngOnDestroy() { ngOnDestroy() {
this.subs this.subs
.filter((subscription) => hasValue(subscription)) .filter((subscription) => hasValue(subscription))
@@ -137,28 +169,22 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy
const viewContainerRef = this.listableObjectDirective.viewContainerRef; const viewContainerRef = this.listableObjectDirective.viewContainerRef;
viewContainerRef.clear(); viewContainerRef.clear();
const componentRef = viewContainerRef.createComponent( this.compRef = viewContainerRef.createComponent(
componentFactory, componentFactory,
0, 0,
undefined, undefined,
[ [
[this.badges.nativeElement], [this.badges.nativeElement],
]); ]);
(componentRef.instance as any).object = object;
(componentRef.instance as any).index = this.index;
(componentRef.instance as any).linkType = this.linkType;
(componentRef.instance as any).listID = this.listID;
(componentRef.instance as any).showLabel = this.showLabel;
(componentRef.instance as any).context = this.context;
(componentRef.instance as any).viewMode = this.viewMode;
(componentRef.instance as any).value = this.value;
if ((componentRef.instance as any).reloadedObject) { this.connectInputsAndOutputs();
(componentRef.instance as any).reloadedObject.pipe(take(1)).subscribe((reloadedObject: DSpaceObject) => {
if ((this.compRef.instance as any).reloadedObject) {
(this.compRef.instance as any).reloadedObject.pipe(take(1)).subscribe((reloadedObject: DSpaceObject) => {
if (reloadedObject) { if (reloadedObject) {
componentRef.destroy(); this.compRef.destroy();
this.object = reloadedObject; this.object = reloadedObject;
this.instantiateComponent(reloadedObject); this.connectInputsAndOutputs();
this.contentChange.emit(reloadedObject); this.contentChange.emit(reloadedObject);
} }
}); });
@@ -187,4 +213,17 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy
context: Context): GenericConstructor<Component> { context: Context): GenericConstructor<Component> {
return getListableObjectComponent(renderTypes, viewMode, context, this.themeService.getThemeName()); return getListableObjectComponent(renderTypes, viewMode, context, this.themeService.getThemeName());
} }
/**
* Connect the in and outputs of this component to the dynamic component,
* to ensure they're in sync
*/
protected connectInputsAndOutputs(): void {
if (isNotEmpty(this.inAndOutputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) {
this.inAndOutputNames.forEach((name: any) => {
this.compRef.instance[name] = this[name];
});
}
}
} }

View File

@@ -1,79 +0,0 @@
//TODO switch to css variables
@mixin clamp($lines, $bg, $size-factor: 1, $line-height: $line-height-base) {
$height: $line-height * $font-size-base * $size-factor;
&.fixedHeight {
height: $lines * $height;
}
.content {
max-height: $lines * $height;
position: relative;
overflow: hidden;
line-height: $line-height;
overflow-wrap: break-word;
&:after {
content: "";
position: absolute;
padding-right: 15px;
top: ($lines - 1) * $height;
right: 0;
width: 30%;
min-width: 75px;
max-width: 150px;
height: $height;
background: linear-gradient(to right, rgba(255, 255, 255, 0), $bg 70%);
pointer-events: none;
}
}
}
@mixin min($lines, $size-factor: 1, $line-height: $line-height-base) {
$height: $line-height * $font-size-base * $size-factor;
min-height: $lines * $height;
}
$h4-factor: strip-unit($h4-font-size);
@mixin clamp-with-titles($i, $bg) {
transition: height 1s;
@include clamp($i, $bg);
&.title {
@include clamp($i, $bg, 1.25);
}
&.h4 {
@include clamp($i, $bg, $h4-factor, $headings-line-height);
}
}
@for $i from 1 through 15 {
.clamp-default-#{$i} {
@include clamp-with-titles($i, $body-bg);
}
:host-context(.ds-hover) .clamp-default-#{$i} {
@include clamp-with-titles($i, $list-group-hover-bg);
}
.clamp-primary-#{$i} {
@include clamp-with-titles($i, $primary);
}
:host-context(.ds-hover) .clamp-primary-#{$i} {
@include clamp-with-titles($i, darken($primary, 10%));
}
}
.clamp-none {
overflow: hidden;
@for $i from 1 through 15 {
&.fixedHeight.min-#{$i} {
transition: height 1s;
@include min($i);
&.title {
@include min($i, 1.25);
}
&.h4 {
@include min($i, $h4-factor, $headings-line-height);
}
}
}
}

View File

@@ -1635,7 +1635,11 @@
"item.edit.move.cancel": "Cancel", "item.edit.move.cancel": "Back",
"item.edit.move.save-button": "Save",
"item.edit.move.discard-button": "Discard",
"item.edit.move.description": "Select the collection you wish to move this item to. To narrow down the list of displayed collections, you can enter a search query in the box.", "item.edit.move.description": "Select the collection you wish to move this item to. To narrow down the list of displayed collections, you can enter a search query in the box.",

View File

@@ -0,0 +1,80 @@
@mixin clamp($lines, $bg, $size-factor: 1, $line-height: $line-height-base) {
$height: $line-height * $font-size-base * $size-factor;
&.fixedHeight {
height: $lines * $height;
}
.content {
max-height: $lines * $height;
position: relative;
overflow: hidden;
line-height: $line-height;
overflow-wrap: break-word;
&:after {
content: "";
position: absolute;
padding-right: 15px;
top: ($lines - 1) * $height;
right: 0;
width: 30%;
min-width: 75px;
max-width: 150px;
height: $height;
background: linear-gradient(to right, rgba(255, 255, 255, 0), $bg 70%);
pointer-events: none;
}
}
}
@mixin min($lines, $size-factor: 1, $line-height: $line-height-base) {
$height: $line-height * $font-size-base * $size-factor;
min-height: $lines * $height;
}
$h4-factor: strip-unit($h4-font-size);
@mixin clamp-with-titles($i, $bg) {
transition: height 1s;
@include clamp($i, $bg);
&.title {
@include clamp($i, $bg, 1.25);
}
&.h4 {
@include clamp($i, $bg, $h4-factor, $headings-line-height);
}
}
@for $i from 1 through 15 {
.clamp-default-#{$i} {
@include clamp-with-titles($i, $body-bg);
}
:focus .clamp-default-#{$i},
.ds-hover .clamp-default-#{$i} {
@include clamp-with-titles($i, $list-group-hover-bg);
}
.clamp-primary-#{$i} {
@include clamp-with-titles($i, $primary);
}
:focus .clamp-primary-#{$i},
.ds-hover .clamp-primary-#{$i} {
@include clamp-with-titles($i, darken($primary, 10%));
}
}
.clamp-none {
overflow: hidden;
@for $i from 1 through 15 {
&.fixedHeight.min-#{$i} {
transition: height 1s;
@include min($i);
&.title {
@include min($i, 1.25);
}
&.h4 {
@include min($i, $h4-factor, $headings-line-height);
}
}
}
}

View File

@@ -3,4 +3,5 @@
@import '../../node_modules/nouislider/distribute/nouislider.min'; @import '../../node_modules/nouislider/distribute/nouislider.min';
@import './_custom_variables.scss'; @import './_custom_variables.scss';
@import './bootstrap_variables_mapping.scss'; @import './bootstrap_variables_mapping.scss';
@import './_truncatable-part.component.scss';
@import './_global-styles.scss'; @import './_global-styles.scss';

View File

@@ -9,4 +9,5 @@
@import '../../../styles/_custom_variables.scss'; @import '../../../styles/_custom_variables.scss';
@import './_theme_css_variable_overrides.scss'; @import './_theme_css_variable_overrides.scss';
@import '../../../styles/bootstrap_variables_mapping.scss'; @import '../../../styles/bootstrap_variables_mapping.scss';
@import '../../../styles/_truncatable-part.component.scss';
@import './_global-styles.scss'; @import './_global-styles.scss';

View File

@@ -9,4 +9,5 @@
@import '../../../styles/_custom_variables.scss'; @import '../../../styles/_custom_variables.scss';
@import './_theme_css_variable_overrides.scss'; @import './_theme_css_variable_overrides.scss';
@import '../../../styles/bootstrap_variables_mapping.scss'; @import '../../../styles/bootstrap_variables_mapping.scss';
@import '../../../styles/_truncatable-part.component.scss';
@import './_global-styles.scss'; @import './_global-styles.scss';