Merge remote-tracking branch 'origin/main' into #1206

# Conflicts:
#	src/app/submission/edit/submission-edit.component.ts
This commit is contained in:
Giuseppe Digilio
2021-07-02 13:12:04 +02:00
117 changed files with 1724 additions and 782 deletions

View File

@@ -0,0 +1,10 @@
<div class="container">
<ds-resource-policies [resourceType]="'bitstream'" [resourceUUID]="(dsoRD$ | async)?.payload?.id"></ds-resource-policies>
<div class="button-row bottom">
<div class="text-right">
<a [routerLink]="['/bitstreams', (dsoRD$ | async)?.payload?.id, 'edit']" role="button" class="btn btn-outline-secondary mr-1">
<i class="fas fa-arrow-left"></i> {{'bitstream.edit.return' | translate}}
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,84 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { cold } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations.component';
import { Bitstream } from '../../core/shared/bitstream.model';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
describe('BitstreamAuthorizationsComponent', () => {
let comp: BitstreamAuthorizationsComponent<DSpaceObject>;
let fixture: ComponentFixture<BitstreamAuthorizationsComponent<any>>;
const bitstream = Object.assign(new Bitstream(), {
sizeBytes: 10000,
metadata: {
'dc.title': [
{
value: 'file name',
language: null
}
]
},
_links: {
content: { href: 'file-selflink' }
}
});
const bitstreamRD = createSuccessfulRemoteDataObject(bitstream);
const routeStub = {
data: observableOf({
bitstream: bitstreamRD
})
};
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
CommonModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})
],
declarations: [BitstreamAuthorizationsComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
ChangeDetectorRef,
BitstreamAuthorizationsComponent,
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamAuthorizationsComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
comp = null;
fixture.destroy();
});
it('should create', () => {
expect(comp).toBeTruthy();
});
it('should init dso remote data properly', (done) => {
const expected = cold('(a|)', { a: bitstreamRD });
expect(comp.dsoRD$).toBeObservable(expected);
done();
});
});

View File

@@ -0,0 +1,40 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
@Component({
selector: 'ds-collection-authorizations',
templateUrl: './bitstream-authorizations.component.html',
})
/**
* Component that handles the Collection Authorizations
*/
export class BitstreamAuthorizationsComponent<TDomain extends DSpaceObject> implements OnInit {
/**
* The initial DSO object
*/
public dsoRD$: Observable<RemoteData<TDomain>>;
/**
* Initialize instance variables
*
* @param {ActivatedRoute} route
*/
constructor(
private route: ActivatedRoute
) {
}
/**
* Initialize the component, setting up the collection
*/
ngOnInit(): void {
this.dsoRD$ = this.route.data.pipe(first(), map((data) => data.bitstream));
}
}

View File

@@ -4,8 +4,14 @@ import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream
import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { BitstreamPageResolver } from './bitstream-page.resolver'; import { BitstreamPageResolver } from './bitstream-page.resolver';
import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component'; import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component';
import { ResourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver';
import { ResourcePolicyCreateComponent } from '../shared/resource-policies/create/resource-policy-create.component';
import { ResourcePolicyResolver } from '../shared/resource-policies/resolvers/resource-policy.resolver';
import { ResourcePolicyEditComponent } from '../shared/resource-policies/edit/resource-policy-edit.component';
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
const EDIT_BITSTREAM_PATH = ':id/edit'; const EDIT_BITSTREAM_PATH = ':id/edit';
const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations';
/** /**
* Routing module to help navigate Bitstream pages * Routing module to help navigate Bitstream pages
@@ -27,6 +33,36 @@ const EDIT_BITSTREAM_PATH = ':id/edit';
bitstream: BitstreamPageResolver bitstream: BitstreamPageResolver
}, },
canActivate: [AuthenticatedGuard] canActivate: [AuthenticatedGuard]
},
{
path: EDIT_BITSTREAM_AUTHORIZATIONS_PATH,
children: [
{
path: 'create',
resolve: {
resourcePolicyTarget: ResourcePolicyTargetResolver
},
component: ResourcePolicyCreateComponent,
data: { title: 'resource-policies.create.page.title', showBreadcrumbs: true }
},
{
path: 'edit',
resolve: {
resourcePolicy: ResourcePolicyResolver
},
component: ResourcePolicyEditComponent,
data: { title: 'resource-policies.edit.page.title', showBreadcrumbs: true }
},
{
path: '',
resolve: {
bitstream: BitstreamPageResolver
},
component: BitstreamAuthorizationsComponent,
data: { title: 'bitstream.edit.authorizations.title', showBreadcrumbs: true }
}
]
} }
]) ])
], ],

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { SharedModule } from '../shared/shared.module'; import { SharedModule } from '../shared/shared.module';
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
/** /**
* This module handles all components that are necessary for Bitstream related pages * This module handles all components that are necessary for Bitstream related pages
@@ -14,6 +15,7 @@ import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
BitstreamPageRoutingModule BitstreamPageRoutingModule
], ],
declarations: [ declarations: [
BitstreamAuthorizationsComponent,
EditBitstreamPageComponent EditBitstreamPageComponent
] ]
}) })

View File

@@ -35,7 +35,7 @@ export class BitstreamPageResolver implements Resolve<RemoteData<Bitstream>> {
*/ */
get followLinks(): FollowLinkConfig<Bitstream>[] { get followLinks(): FollowLinkConfig<Bitstream>[] {
return [ return [
followLink('bundle', undefined, true, true, true, followLink('item')), followLink('bundle', {}, followLink('item')),
followLink('format') followLink('format')
]; ];
} }

View File

@@ -19,7 +19,11 @@
[submitLabel]="'form.save'" [submitLabel]="'form.save'"
(submitForm)="onSubmit()" (submitForm)="onSubmit()"
(cancel)="onCancel()" (cancel)="onCancel()"
(dfChange)="onChange($event)"></ds-form> (dfChange)="onChange($event)">
<div additional class="container py-3">
<a [routerLink]="['/bitstreams', bitstreamRD?.payload?.id, 'authorizations']">{{'bitstream.edit.authorizations.link' | translate}}</a>
</div>
</ds-form>
</div> </div>
</div> </div>
<ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error> <ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error>

View File

@@ -18,12 +18,8 @@ import { hasValue } from '../../shared/empty.util';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { FileSizePipe } from '../../shared/utils/file-size-pipe'; import { FileSizePipe } from '../../shared/utils/file-size-pipe';
import { VarDirective } from '../../shared/utils/var.directive'; import { VarDirective } from '../../shared/utils/var.directive';
import { import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
createSuccessfulRemoteDataObject, import { getEntityEditRoute } from '../../+item-page/item-page-routing-paths';
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { RouterStub } from '../../shared/testing/router.stub';
import { getEntityEditRoute, getItemEditRoute } from '../../+item-page/item-page-routing-paths';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
@@ -39,7 +35,6 @@ let bitstream: Bitstream;
let selectedFormat: BitstreamFormat; let selectedFormat: BitstreamFormat;
let allFormats: BitstreamFormat[]; let allFormats: BitstreamFormat[];
let router: Router; let router: Router;
let routerStub;
describe('EditBitstreamPageComponent', () => { describe('EditBitstreamPageComponent', () => {
let comp: EditBitstreamPageComponent; let comp: EditBitstreamPageComponent;
@@ -129,10 +124,6 @@ describe('EditBitstreamPageComponent', () => {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)) findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats))
}); });
const itemPageUrl = `fake-url/some-uuid`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}`
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule], imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective], declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
@@ -142,7 +133,6 @@ describe('EditBitstreamPageComponent', () => {
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), snapshot: { queryParams: {} } } }, { provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), snapshot: { queryParams: {} } } },
{ provide: BitstreamDataService, useValue: bitstreamService }, { provide: BitstreamDataService, useValue: bitstreamService },
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService }, { provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
{ provide: Router, useValue: routerStub },
ChangeDetectorRef ChangeDetectorRef
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -154,7 +144,8 @@ describe('EditBitstreamPageComponent', () => {
fixture = TestBed.createComponent(EditBitstreamPageComponent); fixture = TestBed.createComponent(EditBitstreamPageComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
router = (comp as any).router; router = TestBed.inject(Router);
spyOn(router, 'navigate');
}); });
describe('on startup', () => { describe('on startup', () => {
@@ -241,14 +232,14 @@ describe('EditBitstreamPageComponent', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => { it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => {
comp.itemId = 'some-uuid1'; comp.itemId = 'some-uuid1';
comp.navigateToItemEditBitstreams(); comp.navigateToItemEditBitstreams();
expect(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']); expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']);
}); });
}); });
describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => { describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => { it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => {
comp.itemId = undefined; comp.itemId = undefined;
comp.navigateToItemEditBitstreams(); comp.navigateToItemEditBitstreams();
expect(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']); expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']);
}); });
}); });
}); });

View File

@@ -19,10 +19,10 @@ import { cloneDeep } from 'lodash';
import { BitstreamDataService } from '../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { import {
getAllSucceededRemoteDataPayload, getAllSucceededRemoteDataPayload,
getFirstSucceededRemoteDataPayload, getFirstCompletedRemoteData,
getRemoteDataPayload,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getFirstCompletedRemoteData getFirstSucceededRemoteDataPayload,
getRemoteDataPayload
} from '../../core/shared/operators'; } from '../../core/shared/operators';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
@@ -131,15 +131,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
rows: 10 rows: 10
}); });
/**
* The Dynamic Input Model for the file's embargo (disabled on this page)
*/
embargoModel = new DynamicInputModel({
id: 'embargo',
name: 'embargo',
disabled: true
});
/** /**
* The Dynamic Input Model for the selected format * The Dynamic Input Model for the selected format
*/ */
@@ -159,7 +150,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
/** /**
* All input models in a simple array for easier iterations * All input models in a simple array for easier iterations
*/ */
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, this.selectedFormatModel, this.newFormatModel]; inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.selectedFormatModel, this.newFormatModel];
/** /**
* The dynamic form fields used for editing the information of a bitstream * The dynamic form fields used for editing the information of a bitstream
@@ -179,12 +170,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
this.descriptionModel this.descriptionModel
] ]
}), }),
new DynamicFormGroupModel({
id: 'embargoContainer',
group: [
this.embargoModel
]
}),
new DynamicFormGroupModel({ new DynamicFormGroupModel({
id: 'formatContainer', id: 'formatContainer',
group: [ group: [
@@ -243,11 +228,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
host: 'row' host: 'row'
} }
}, },
embargoContainer: {
grid: {
host: 'row'
}
},
formatContainer: { formatContainer: {
grid: { grid: {
host: 'row' host: 'row'

View File

@@ -14,7 +14,7 @@ import { ResolvedAction } from '../core/resolving/resolver.actions';
* Requesting them as embeds will limit the number of requests * Requesting them as embeds will limit the number of requests
*/ */
export const COLLECTION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Collection>[] = [ export const COLLECTION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Collection>[] = [
followLink('parentCommunity', undefined, true, true, true, followLink('parentCommunity', {},
followLink('parentCommunity') followLink('parentCommunity')
), ),
followLink('logo') followLink('logo')

View File

@@ -6,11 +6,12 @@
<p class="pb-2">{{ 'collection.delete.text' | translate:{ dso: dso.name } }}</p> <p class="pb-2">{{ 'collection.delete.text' | translate:{ dso: dso.name } }}</p>
<div class="form-group row"> <div class="form-group row">
<div class="col text-right"> <div class="col text-right">
<button class="btn btn-outline-secondary" (click)="onCancel(dso)"> <button class="btn btn-outline-secondary" (click)="onCancel(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-times"></i> {{'collection.delete.cancel' | translate}} <i class="fas fa-times"></i> {{'collection.delete.cancel' | translate}}
</button> </button>
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)"> <button class="btn btn-danger mr-2" (click)="onConfirm(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-trash"></i> {{'collection.delete.confirm' | translate}} <span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'collection.delete.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fas fa-trash"></i> {{'collection.delete.confirm' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -6,11 +6,12 @@
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p> <p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p>
<div class="form-group row"> <div class="form-group row">
<div class="col text-right"> <div class="col text-right">
<button class="btn btn-outline-secondary" (click)="onCancel(dso)"> <button class="btn btn-outline-secondary" (click)="onCancel(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-times"></i> {{'community.delete.cancel' | translate}} <i class="fas fa-times"></i> {{'community.delete.cancel' | translate}}
</button> </button>
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)"> <button class="btn btn-danger mr-2" (click)="onConfirm(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}} <span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'community.delete.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -4,10 +4,9 @@ import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators'; import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators';
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model'; import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { import {
getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataWithNotEmptyPayload,
getFirstSucceededRemoteDataWithNotEmptyPayload
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { followLink } from '../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../shared/utils/follow-link-config.model';
@@ -15,7 +14,6 @@ import { LinkService } from '../../../core/cache/builders/link.service';
import { Bundle } from '../../../core/shared/bundle.model'; import { Bundle } from '../../../core/shared/bundle.model';
import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { Bitstream } from '../../../core/shared/bitstream.model'; import { Bitstream } from '../../../core/shared/bitstream.model';
import { FindListOptions } from '../../../core/data/request.models';
/** /**
* Interface for a bundle's bitstream map entry * Interface for a bundle's bitstream map entry
@@ -79,7 +77,7 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy {
getFirstSucceededRemoteDataWithNotEmptyPayload(), getFirstSucceededRemoteDataWithNotEmptyPayload(),
map((item: Item) => this.linkService.resolveLink( map((item: Item) => this.linkService.resolveLink(
item, item,
followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams')) followLink('bundles', {}, followLink('bitstreams'))
)) ))
) as Observable<Item>; ) as Observable<Item>;

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

@@ -6,21 +6,30 @@
</button> </button>
</h5> </h5>
<ng-container *ngVar="updates$ | async as updates"> <ng-container *ngVar="updates$ | async as updates">
<ng-container *ngIf="updates"> <ng-container *ngIf="updates && !(loading$ | async)">
<ng-container *ngVar="updates | dsObjectValues as updateValues"> <ng-container *ngVar="updates | dsObjectValues as updateValues">
<ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate" <ds-pagination
class="relationship-row d-block alert" [paginationOptions]="paginationConfig"
[fieldUpdate]="updateValue || {}" [pageInfoState]="(relationshipsRd$ | async)?.payload?.pageInfo"
[url]="url" [collectionSize]="(relationshipsRd$ | async)?.payload?.totalElements + (this.nbAddedFields$ | async)"
[editItem]="item" [hideGear]="true"
[ngClass]="{ [hidePagerWhenSinglePage]="true">
<div class="my-2">
<ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate"
class="relationship-row d-block alert"
[fieldUpdate]="updateValue || {}"
[url]="url"
[editItem]="item"
[ngClass]="{
'alert-success': updateValue.changeType === 1, 'alert-success': updateValue.changeType === 1,
'alert-warning': updateValue.changeType === 0, 'alert-warning': updateValue.changeType === 0,
'alert-danger': updateValue.changeType === 2 'alert-danger': updateValue.changeType === 2
}"> }">
</ds-edit-relationship> </ds-edit-relationship>
<div *ngIf="updateValues.length === 0">{{"item.edit.relationships.no-relationships" | translate}}</div> </div>
</ds-pagination>
<div *ngIf="updateValues.length === 0">{{"item.edit.relationships.no-relationships" | translate}}</div>
</ng-container> </ng-container>
</ng-container> </ng-container>
<ds-loading *ngIf="!updates"></ds-loading> <ds-loading *ngIf="loading$ | async"></ds-loading>
</ng-container> </ng-container>

View File

@@ -16,6 +16,12 @@ import { SharedModule } from '../../../../shared/shared.module';
import { EditRelationshipListComponent } from './edit-relationship-list.component'; import { EditRelationshipListComponent } from './edit-relationship-list.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../../shared/testing/utils.test';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
import { HostWindowService } from '../../../../shared/host-window.service';
import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
let comp: EditRelationshipListComponent; let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>; let fixture: ComponentFixture<EditRelationshipListComponent>;
@@ -25,6 +31,8 @@ let linkService;
let objectUpdatesService; let objectUpdatesService;
let relationshipService; let relationshipService;
let selectableListService; let selectableListService;
let paginationService;
let hostWindowService;
const url = 'http://test-url.com/test-url'; const url = 'http://test-url.com/test-url';
@@ -37,9 +45,21 @@ let fieldUpdate1;
let fieldUpdate2; let fieldUpdate2;
let relationships; let relationships;
let relationshipType; let relationshipType;
let paginationOptions;
describe('EditRelationshipListComponent', () => { describe('EditRelationshipListComponent', () => {
const resetComponent = () => {
fixture = TestBed.createComponent(EditRelationshipListComponent);
comp = fixture.componentInstance;
de = fixture.debugElement;
comp.item = item;
comp.itemType = entityType;
comp.url = url;
comp.relationshipType = relationshipType;
fixture.detectChanges();
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
entityType = Object.assign(new ItemType(), { entityType = Object.assign(new ItemType(), {
@@ -63,6 +83,12 @@ describe('EditRelationshipListComponent', () => {
rightwardType: 'isPublicationOfAuthor', rightwardType: 'isPublicationOfAuthor',
}); });
paginationOptions = Object.assign(new PaginationComponentOptions(), {
id: `er${relationshipType.id}`,
pageSize: 5,
currentPage: 1,
});
author1 = Object.assign(new Item(), { author1 = Object.assign(new Item(), {
id: 'author1', id: 'author1',
uuid: 'author1' uuid: 'author1'
@@ -141,6 +167,10 @@ describe('EditRelationshipListComponent', () => {
resolveLinks: () => null, resolveLinks: () => null,
}; };
paginationService = new PaginationServiceStub(paginationOptions);
hostWindowService = new HostWindowServiceStub(1200);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()], imports: [SharedModule, TranslateModule.forRoot()],
declarations: [EditRelationshipListComponent], declarations: [EditRelationshipListComponent],
@@ -149,22 +179,15 @@ describe('EditRelationshipListComponent', () => {
{ provide: RelationshipService, useValue: relationshipService }, { provide: RelationshipService, useValue: relationshipService },
{ provide: SelectableListService, useValue: selectableListService }, { provide: SelectableListService, useValue: selectableListService },
{ provide: LinkService, useValue: linkService }, { provide: LinkService, useValue: linkService },
{ provide: PaginationService, useValue: paginationService },
{ provide: HostWindowService, useValue: hostWindowService },
], schemas: [ ], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
] ]
}).compileComponents(); }).compileComponents();
}));
beforeEach(() => { resetComponent();
fixture = TestBed.createComponent(EditRelationshipListComponent); }));
comp = fixture.componentInstance;
de = fixture.debugElement;
comp.item = item;
comp.itemType = entityType;
comp.url = url;
comp.relationshipType = relationshipType;
fixture.detectChanges();
});
describe('changeType is REMOVE', () => { describe('changeType is REMOVE', () => {
beforeEach(() => { beforeEach(() => {
@@ -176,4 +199,82 @@ describe('EditRelationshipListComponent', () => {
expect(element.classList).toContain('alert-danger'); expect(element.classList).toContain('alert-danger');
}); });
}); });
describe('pagination component', () => {
let paginationComp: PaginationComponent;
beforeEach(() => {
paginationComp = de.query(By.css('ds-pagination')).componentInstance;
});
it('should receive the correct pagination config', () => {
expect(paginationComp.paginationOptions).toEqual(paginationOptions);
});
it('should receive correct collection size', () => {
expect(paginationComp.collectionSize).toEqual(relationships.length);
});
});
describe('relationshipService.getItemRelationshipsByLabel', () => {
it('should receive the correct pagination info', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const findListOptions = callArgs[2];
expect(findListOptions.elementsPerPage).toEqual(paginationOptions.pageSize);
expect(findListOptions.currentPage).toEqual(paginationOptions.currentPage);
});
describe('when the publication is on the left side of the relationship', () => {
beforeEach(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(entityType), // publication
rightType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor',
});
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
it('should fetch isAuthorOfPublication', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const label = callArgs[1];
expect(label).toEqual('isAuthorOfPublication');
});
});
describe('when the publication is on the right side of the relationship', () => {
beforeEach(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
rightType: createSuccessfulRemoteDataObject$(entityType), // publication
leftwardType: 'isPublicationOfAuthor',
rightwardType: 'isAuthorOfPublication',
});
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
it('should fetch isAuthorOfPublication', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const label = callArgs[1];
expect(label).toEqual('isAuthorOfPublication');
});
});
});
}); });

View File

@@ -1,9 +1,14 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { LinkService } from '../../../../core/cache/builders/link.service'; import { LinkService } from '../../../../core/cache/builders/link.service';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
from as observableFrom
} from 'rxjs';
import { import {
FieldUpdate, FieldUpdate,
FieldUpdates, FieldUpdates,
@@ -11,14 +16,24 @@ import {
} from '../../../../core/data/object-updates/object-updates.reducer'; } from '../../../../core/data/object-updates/object-updates.reducer';
import { RelationshipService } from '../../../../core/data/relationship.service'; import { RelationshipService } from '../../../../core/data/relationship.service';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { defaultIfEmpty, map, mergeMap, switchMap, take, startWith } from 'rxjs/operators'; import {
import { hasValue, hasValueOperator } from '../../../../shared/empty.util'; defaultIfEmpty,
map,
mergeMap,
switchMap,
take,
startWith,
toArray,
tap
} from 'rxjs/operators';
import { hasValue, hasValueOperator, hasNoValue } from '../../../../shared/empty.util';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { import {
getAllSucceededRemoteData,
getRemoteDataPayload, getRemoteDataPayload,
getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload,
getAllSucceededRemoteData,
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component'; import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component';
@@ -30,6 +45,10 @@ import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model'; import { Collection } from '../../../../core/shared/collection.model';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { Subscription } from 'rxjs/internal/Subscription';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
@Component({ @Component({
selector: 'ds-edit-relationship-list', selector: 'ds-edit-relationship-list',
@@ -40,7 +59,7 @@ import { Collection } from '../../../../core/shared/collection.model';
* A component creating a list of editable relationships of a certain type * A component creating a list of editable relationships of a certain type
* The relationships are rendered as a list of related items * The relationships are rendered as a list of related items
*/ */
export class EditRelationshipListComponent implements OnInit { export class EditRelationshipListComponent implements OnInit, OnDestroy {
/** /**
* The item to display related items for * The item to display related items for
@@ -60,6 +79,17 @@ export class EditRelationshipListComponent implements OnInit {
*/ */
@Input() relationshipType: RelationshipType; @Input() relationshipType: RelationshipType;
/**
* Observable that emits the left and right item type of {@link relationshipType} simultaneously.
*/
private relationshipLeftAndRightType$: Observable<[ItemType, ItemType]>;
/**
* Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType},
* false if it is on the right-hand side and undefined in the rare case that it is on neither side.
*/
private currentItemIsLeftItem$: Observable<boolean>;
private relatedEntityType$: Observable<ItemType>; private relatedEntityType$: Observable<ItemType>;
/** /**
@@ -70,7 +100,38 @@ export class EditRelationshipListComponent implements OnInit {
/** /**
* The FieldUpdates for the relationships in question * The FieldUpdates for the relationships in question
*/ */
updates$: Observable<FieldUpdates>; updates$: BehaviorSubject<FieldUpdates> = new BehaviorSubject(undefined);
/**
* The RemoteData for the relationships
*/
relationshipsRd$: BehaviorSubject<RemoteData<PaginatedList<Relationship>>> = new BehaviorSubject(undefined);
/**
* Whether the current page is the last page
*/
isLastPage$: BehaviorSubject<boolean> = new BehaviorSubject(true);
/**
* Whether we're loading
*/
loading$: BehaviorSubject<boolean> = new BehaviorSubject(true);
/**
* The number of added fields that haven't been saved yet
*/
nbAddedFields$: BehaviorSubject<number> = new BehaviorSubject(0);
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
private subs: Subscription[] = [];
/**
* The pagination config
*/
paginationConfig: PaginationComponentOptions;
/** /**
* A reference to the lookup window * A reference to the lookup window
@@ -82,6 +143,7 @@ export class EditRelationshipListComponent implements OnInit {
protected linkService: LinkService, protected linkService: LinkService,
protected relationshipService: RelationshipService, protected relationshipService: RelationshipService,
protected modalService: NgbModal, protected modalService: NgbModal,
protected paginationService: PaginationService,
protected selectableListService: SelectableListService, protected selectableListService: SelectableListService,
) { ) {
} }
@@ -172,6 +234,10 @@ export class EditRelationshipListComponent implements OnInit {
this.objectUpdatesService.saveAddFieldUpdate(this.url, update); this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
}); });
} }
this.loading$.next(true);
// emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
}); });
}); });
}; };
@@ -186,6 +252,10 @@ export class EditRelationshipListComponent implements OnInit {
) )
); );
}); });
this.loading$.next(true);
// emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
}; };
this.relatedEntityType$ this.relatedEntityType$
.pipe(take(1)) .pipe(take(1))
@@ -212,10 +282,10 @@ export class EditRelationshipListComponent implements OnInit {
if (field.relationship) { if (field.relationship) {
return this.getRelatedItem(field.relationship); return this.getRelatedItem(field.relationship);
} else { } else {
return of(field.relatedItem); return observableOf(field.relatedItem);
} }
}) })
) : of([]) ) : observableOf([])
), ),
take(1), take(1),
map((items) => items.map((item) => { map((items) => items.map((item) => {
@@ -267,18 +337,19 @@ export class EditRelationshipListComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
// store the left and right type of the relationship in a single observable
this.relationshipLeftAndRightType$ = observableCombineLatest([
this.relationshipType.leftType,
this.relationshipType.rightType,
].map((type) => type.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
))) as Observable<[ItemType, ItemType]>;
this.relatedEntityType$ = this.relatedEntityType$ = this.relationshipLeftAndRightType$.pipe(
observableCombineLatest([ map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)),
this.relationshipType.leftType, hasValueOperator()
this.relationshipType.rightType, );
].map((type) => type.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
))).pipe(
map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)),
hasValueOperator()
);
this.relatedEntityType$.pipe( this.relatedEntityType$.pipe(
take(1) take(1)
@@ -286,65 +357,142 @@ export class EditRelationshipListComponent implements OnInit {
(relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}` (relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}`
); );
this.updates$ = this.getItemRelationships().pipe( this.currentItemIsLeftItem$ = this.relationshipLeftAndRightType$.pipe(
switchMap((relationships) => map(([leftType, rightType]: [ItemType, ItemType]) => {
observableCombineLatest( if (leftType.id === this.itemType.id) {
relationships.map((relationship) => this.relationshipService.isLeftItem(relationship, this.item)) return true;
).pipe( }
defaultIfEmpty([]),
map((isLeftItemArray) => isLeftItemArray.map((isLeftItem, index) => { if (rightType.id === this.itemType.id) {
const relationship = relationships[index]; return false;
const nameVariant = isLeftItem ? relationship.rightwardValue : relationship.leftwardValue; }
// should never happen...
console.warn(`The item ${this.item.uuid} is not on the right or the left side of relationship type ${this.relationshipType.uuid}`);
return undefined;
})
);
// initialize the pagination options
this.paginationConfig = new PaginationComponentOptions();
this.paginationConfig.id = `er${this.relationshipType.id}`;
this.paginationConfig.pageSize = 5;
this.paginationConfig.currentPage = 1;
// get the pagination params from the route
const currentPagination$ = this.paginationService.getCurrentPagination(
this.paginationConfig.id,
this.paginationConfig
).pipe(
tap(() => this.loading$.next(true))
);
this.subs.push(
observableCombineLatest([
currentPagination$,
this.currentItemIsLeftItem$,
]).pipe(
switchMap(([currentPagination, currentItemIsLeftItem]: [PaginationComponentOptions, boolean]) =>
// get the relationships for the current item, relationshiptype and page
this.relationshipService.getItemRelationshipsByLabel(
this.item,
currentItemIsLeftItem ? this.relationshipType.leftwardType : this.relationshipType.rightwardType,
{
elementsPerPage: currentPagination.pageSize,
currentPage: currentPagination.currentPage,
},
false,
true,
followLink('leftItem'),
followLink('rightItem'),
)),
).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => {
this.relationshipsRd$.next(rd);
})
);
// keep isLastPage$ up to date based on relationshipsRd$
this.subs.push(this.relationshipsRd$.pipe(
hasValueOperator(),
getAllSucceededRemoteData()
).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => {
this.isLastPage$.next(hasNoValue(rd.payload._links.next));
}));
this.subs.push(this.relationshipsRd$.pipe(
hasValueOperator(),
getAllSucceededRemoteData(),
switchMap((rd: RemoteData<PaginatedList<Relationship>>) =>
// emit each relationship in the page separately
observableFrom(rd.payload.page).pipe(
mergeMap((relationship: Relationship) =>
// check for each relationship whether it's the left item
this.relationshipService.isLeftItem(relationship, this.item).pipe(
// emit an array containing both the relationship and whether it's the left item,
// as we'll need both
map((isLeftItem: boolean) => [relationship, isLeftItem])
)
),
map(([relationship, isLeftItem]: [Relationship, boolean]) => {
// turn it into a RelationshipIdentifiable, an
const nameVariant =
isLeftItem ? relationship.rightwardValue : relationship.leftwardValue;
return { return {
uuid: relationship.id, uuid: relationship.id,
type: this.relationshipType, type: this.relationshipType,
relationship, relationship,
nameVariant, nameVariant,
} as RelationshipIdentifiable; } as RelationshipIdentifiable;
})), }),
)), // wait until all relationships have been processed, and emit them all as a single array
switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields).pipe( toArray(),
map((fieldUpdates) => { // if the pipe above completes without emitting anything, emit an empty array instead
const fieldUpdatesFiltered: FieldUpdates = {}; defaultIfEmpty([])
Object.keys(fieldUpdates).forEach((uuid) => {
if (hasValue(fieldUpdates[uuid])) {
const field = fieldUpdates[uuid].field;
if ((field as RelationshipIdentifiable).type.id === this.relationshipType.id) {
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
}
}
});
return fieldUpdatesFiltered;
}),
)), )),
switchMap((nextFields: RelationshipIdentifiable[]) => {
// Get a list that contains the unsaved changes for the page, as well as the page of
// RelationshipIdentifiables, as a single list of FieldUpdates
return this.objectUpdatesService.getFieldUpdates(this.url, nextFields).pipe(
map((fieldUpdates: FieldUpdates) => {
const fieldUpdatesFiltered: FieldUpdates = {};
this.nbAddedFields$.next(0);
// iterate over the fieldupdates and filter out the ones that pertain to this
// relationshiptype
Object.keys(fieldUpdates).forEach((uuid) => {
if (hasValue(fieldUpdates[uuid])) {
const field = fieldUpdates[uuid].field as RelationshipIdentifiable;
// only include fieldupdates regarding this RelationshipType
if (field.type.id === this.relationshipType.id) {
// if it's a newly added relationship
if (fieldUpdates[uuid].changeType === FieldChangeType.ADD) {
// increase the counter that tracks new relationships
this.nbAddedFields$.next(this.nbAddedFields$.getValue() + 1);
if (this.isLastPage$.getValue() === true) {
// only include newly added relationships to the output if we're on the last
// page
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
}
} else {
// include all others
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
}
}
}
});
return fieldUpdatesFiltered;
}),
);
}),
startWith({}), startWith({}),
); ).subscribe((updates: FieldUpdates) => {
this.loading$.next(false);
this.updates$.next(updates);
}));
} }
private getItemRelationships() { ngOnDestroy(): void {
this.linkService.resolveLink(this.item, this.subs
followLink('relationships', undefined, true, true, true, .filter((subscription) => hasValue(subscription))
followLink('relationshipType'), .forEach((subscription) => subscription.unsubscribe());
followLink('leftItem'),
followLink('rightItem'),
));
return this.item.relationships.pipe(
getAllSucceededRemoteData(),
map((relationships: RemoteData<PaginatedList<Relationship>>) => relationships.payload.page.filter((relationship: Relationship) => hasValue(relationship))),
switchMap((itemRelationships: Relationship[]) =>
observableCombineLatest(
itemRelationships
.map((relationship) => relationship.relationshipType.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
))
).pipe(
defaultIfEmpty([]),
map((relationshipTypes) => itemRelationships.filter(
(relationship, index) => relationshipTypes[index].id === this.relationshipType.id)
),
)
),
);
} }
} }

View File

@@ -227,7 +227,6 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
* Sends all initial values of this item to the object updates service * Sends all initial values of this item to the object updates service
*/ */
public initializeOriginalFields() { public initializeOriginalFields() {
console.log('init');
return this.relationshipService.getRelatedItems(this.item).pipe( return this.relationshipService.getRelatedItems(this.item).pipe(
take(1), take(1),
).subscribe((items: Item[]) => { ).subscribe((items: Item[]) => {

View File

@@ -68,7 +68,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
{elementsPerPage: options.pageSize, currentPage: options.currentPage}, {elementsPerPage: options.pageSize, currentPage: options.currentPage},
true, true,
true, true,
followLink('format') followLink('format'),
followLink('thumbnail'),
)), )),
tap((rd: RemoteData<PaginatedList<Bitstream>>) => { tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(rd.errorMessage)) { if (hasValue(rd.errorMessage)) {
@@ -85,7 +86,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
{elementsPerPage: options.pageSize, currentPage: options.currentPage}, {elementsPerPage: options.pageSize, currentPage: options.currentPage},
true, true,
true, true,
followLink('format') followLink('format'),
followLink('thumbnail'),
)), )),
tap((rd: RemoteData<PaginatedList<Bitstream>>) => { tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(rd.errorMessage)) { if (hasValue(rd.errorMessage)) {

View File

@@ -5,7 +5,6 @@ import { RemoteData } from '../core/data/remote-data';
import { ItemDataService } from '../core/data/item-data.service'; import { ItemDataService } from '../core/data/item-data.service';
import { Item } from '../core/shared/item.model'; import { Item } from '../core/shared/item.model';
import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model';
import { FindListOptions } from '../core/data/request.models';
import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { ResolvedAction } from '../core/resolving/resolver.actions'; import { ResolvedAction } from '../core/resolving/resolver.actions';
@@ -15,13 +14,13 @@ import { ResolvedAction } from '../core/resolving/resolver.actions';
* Requesting them as embeds will limit the number of requests * Requesting them as embeds will limit the number of requests
*/ */
export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Item>[] = [ export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Item>[] = [
followLink('owningCollection', undefined, true, true, true, followLink('owningCollection', {},
followLink('parentCommunity', undefined, true, true, true, followLink('parentCommunity', {},
followLink('parentCommunity')) followLink('parentCommunity'))
), ),
followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams')),
followLink('relationships'), followLink('relationships'),
followLink('version', undefined, true, true, true, followLink('versionhistory')), followLink('version', {}, followLink('versionhistory')),
followLink('thumbnail')
]; ];
/** /**

View File

@@ -10,7 +10,7 @@
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ng-container *ngIf="!mediaViewer.image"> <ng-container *ngIf="!mediaViewer.image">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
</ng-container> </ng-container>
<ng-container *ngIf="mediaViewer.image"> <ng-container *ngIf="mediaViewer.image">

View File

@@ -1,12 +1,7 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { environment } from '../../../../../environments/environment'; import { environment } from '../../../../../environments/environment';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { takeUntilCompletedRemoteData } from '../../../../core/shared/operators';
import { getItemPageRoute } from '../../../item-page-routing-paths'; import { getItemPageRoute } from '../../../item-page-routing-paths';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { RemoteData } from '../../../../core/data/remote-data';
@Component({ @Component({
selector: 'ds-item', selector: 'ds-item',
@@ -18,28 +13,14 @@ import { RemoteData } from '../../../../core/data/remote-data';
export class ItemComponent implements OnInit { export class ItemComponent implements OnInit {
@Input() object: Item; @Input() object: Item;
/**
* The Item's thumbnail
*/
thumbnail$: BehaviorSubject<RemoteData<Bitstream>>;
/** /**
* Route to the item page * Route to the item page
*/ */
itemPageRoute: string; itemPageRoute: string;
mediaViewer = environment.mediaViewer;
constructor(protected bitstreamDataService: BitstreamDataService) { mediaViewer = environment.mediaViewer;
}
ngOnInit(): void { ngOnInit(): void {
this.itemPageRoute = getItemPageRoute(this.object); this.itemPageRoute = getItemPageRoute(this.object);
this.thumbnail$ = new BehaviorSubject<RemoteData<Bitstream>>(undefined);
this.bitstreamDataService.getThumbnailFor(this.object).pipe(
takeUntilCompletedRemoteData(),
).subscribe((rd: RemoteData<Bitstream>) => {
this.thumbnail$.next(rd);
});
} }
} }

View File

@@ -10,7 +10,7 @@
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ng-container *ngIf="!mediaViewer.image"> <ng-container *ngIf="!mediaViewer.image">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
</ng-container> </ng-container>
<ng-container *ngIf="mediaViewer.image"> <ng-container *ngIf="mediaViewer.image">

View File

@@ -16,9 +16,11 @@ import { SearchResult } from '../shared/search/search-result.model';
import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; import { SearchConfigurationService } from '../core/shared/search/search-configuration.service';
import { SearchService } from '../core/shared/search/search.service'; import { SearchService } from '../core/shared/search/search.service';
import { currentPath } from '../shared/utils/route.utils'; import { currentPath } from '../shared/utils/route.utils';
import { Router} from '@angular/router'; import { Router } from '@angular/router';
import { Context } from '../core/shared/context.model'; import { Context } from '../core/shared/context.model';
import { SortOptions } from '../core/cache/models/sort-options.model'; import { SortOptions } from '../core/cache/models/sort-options.model';
import { followLink } from '../shared/utils/follow-link-config.model';
import { Item } from '../core/shared/item.model';
@Component({ @Component({
selector: 'ds-search', selector: 'ds-search',
@@ -128,8 +130,11 @@ export class SearchComponent implements OnInit {
this.searchLink = this.getSearchLink(); this.searchLink = this.getSearchLink();
this.searchOptions$ = this.getSearchOptions(); this.searchOptions$ = this.getSearchOptions();
this.sub = this.searchOptions$.pipe( this.sub = this.searchOptions$.pipe(
switchMap((options) => this.service.search(options).pipe(getFirstSucceededRemoteData(), startWith(undefined)))) switchMap((options) => this.service.search(
.subscribe((results) => { options, undefined, true, true, followLink<Item>('thumbnail', { isOptional: true })
).pipe(getFirstSucceededRemoteData(), startWith(undefined))
)
).subscribe((results) => {
this.resultsRD$.next(results); this.resultsRD$.next(results);
}); });
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(

View File

@@ -174,8 +174,8 @@ export class CommunityListService {
direction: options.sort.direction direction: options.sort.direction
} }
}, },
followLink('subcommunities', this.configOnePage, true, true), followLink('subcommunities', { findListOptions: this.configOnePage }),
followLink('collections', this.configOnePage, true, true)) followLink('collections', { findListOptions: this.configOnePage }))
.pipe( .pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
map((results) => results.payload), map((results) => results.payload),
@@ -242,8 +242,8 @@ export class CommunityListService {
elementsPerPage: MAX_COMCOLS_PER_PAGE, elementsPerPage: MAX_COMCOLS_PER_PAGE,
currentPage: i currentPage: i
}, },
followLink('subcommunities', this.configOnePage, true, true), followLink('subcommunities', { findListOptions: this.configOnePage }),
followLink('collections', this.configOnePage, true, true)) followLink('collections', { findListOptions: this.configOnePage }))
.pipe( .pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
switchMap((rd: RemoteData<PaginatedList<Community>>) => { switchMap((rd: RemoteData<PaginatedList<Community>>) => {

View File

@@ -292,10 +292,13 @@ export class ResetAuthenticationMessagesAction implements Action {
export class RetrieveAuthMethodsAction implements Action { export class RetrieveAuthMethodsAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS; public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS;
payload: AuthStatus; payload: {
status: AuthStatus;
blocking: boolean;
};
constructor(authStatus: AuthStatus) { constructor(status: AuthStatus, blocking: boolean) {
this.payload = authStatus; this.payload = { status, blocking };
} }
} }
@@ -306,10 +309,14 @@ export class RetrieveAuthMethodsAction implements Action {
*/ */
export class RetrieveAuthMethodsSuccessAction implements Action { export class RetrieveAuthMethodsSuccessAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS; public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS;
payload: AuthMethod[];
constructor(authMethods: AuthMethod[] ) { payload: {
this.payload = authMethods; authMethods: AuthMethod[];
blocking: boolean;
};
constructor(authMethods: AuthMethod[], blocking: boolean ) {
this.payload = { authMethods, blocking };
} }
} }
@@ -320,6 +327,12 @@ export class RetrieveAuthMethodsSuccessAction implements Action {
*/ */
export class RetrieveAuthMethodsErrorAction implements Action { export class RetrieveAuthMethodsErrorAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR; public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR;
payload: boolean;
constructor(blocking: boolean) {
this.payload = blocking;
}
} }
/** /**

View File

@@ -43,10 +43,12 @@ describe('AuthEffects', () => {
let initialState; let initialState;
let token; let token;
let store: MockStore<AppState>; let store: MockStore<AppState>;
let authStatus;
function init() { function init() {
authServiceStub = new AuthServiceStub(); authServiceStub = new AuthServiceStub();
token = authServiceStub.getToken(); token = authServiceStub.getToken();
authStatus = Object.assign(new AuthStatus(), {});
initialState = { initialState = {
core: { core: {
auth: { auth: {
@@ -217,16 +219,38 @@ describe('AuthEffects', () => {
expect(authEffects.checkTokenCookie$).toBeObservable(expected); expect(authEffects.checkTokenCookie$).toBeObservable(expected);
}); });
it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { describe('on CSR', () => {
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
observableOf( spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
{ authenticated: false }) observableOf(
); { authenticated: false })
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); );
spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue(
new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false)
);
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) }); const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false) });
expect(authEffects.checkTokenCookie$).toBeObservable(expected); expect(authEffects.checkTokenCookie$).toBeObservable(expected);
});
});
describe('on SSR', () => {
it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
observableOf(
{ authenticated: false })
);
spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue(
new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true)
);
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true) });
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
});
}); });
}); });
@@ -359,27 +383,74 @@ describe('AuthEffects', () => {
describe('retrieveMethods$', () => { describe('retrieveMethods$', () => {
describe('when retrieve authentication methods succeeded', () => { describe('on CSR', () => {
it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { describe('when retrieve authentication methods succeeded', () => {
actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: false}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) }); const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, false) });
expect(authEffects.retrieveMethods$).toBeObservable(expected); expect(authEffects.retrieveMethods$).toBeObservable(expected);
});
});
describe('when retrieve authentication methods failed', () => {
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: false}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(false) });
expect(authEffects.retrieveMethods$).toBeObservable(expected);
});
}); });
}); });
describe('when retrieve authentication methods failed', () => { describe('on SSR', () => {
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { describe('when retrieve authentication methods succeeded', () => {
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: true}
}
});
actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, true) });
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() }); expect(authEffects.retrieveMethods$).toBeObservable(expected);
});
});
expect(authEffects.retrieveMethods$).toBeObservable(expected); describe('when retrieve authentication methods failed', () => {
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: true}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(true) });
expect(authEffects.retrieveMethods$).toBeObservable(expected);
});
}); });
}); });
}); });
describe('clearInvalidTokenOnRehydrate$', () => { describe('clearInvalidTokenOnRehydrate$', () => {

View File

@@ -145,7 +145,7 @@ export class AuthEffects {
if (response.authenticated) { if (response.authenticated) {
return new RetrieveTokenAction(); return new RetrieveTokenAction();
} else { } else {
return new RetrieveAuthMethodsAction(response); return this.authService.getRetrieveAuthMethodsAction(response);
} }
}), }),
catchError((error) => observableOf(new AuthenticatedErrorAction(error))) catchError((error) => observableOf(new AuthenticatedErrorAction(error)))
@@ -234,10 +234,10 @@ export class AuthEffects {
.pipe( .pipe(
ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS), ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS),
switchMap((action: RetrieveAuthMethodsAction) => { switchMap((action: RetrieveAuthMethodsAction) => {
return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload.status)
.pipe( .pipe(
map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels, action.payload.blocking)),
catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction(action.payload.blocking)))
); );
}) })
); );

View File

@@ -512,7 +512,7 @@ describe('authReducer', () => {
loading: false, loading: false,
authMethods: [] authMethods: []
}; };
const action = new RetrieveAuthMethodsAction(new AuthStatus()); const action = new RetrieveAuthMethodsAction(new AuthStatus(), true);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = { state = {
authenticated: false, authenticated: false,
@@ -536,7 +536,7 @@ describe('authReducer', () => {
new AuthMethod(AuthMethodType.Password), new AuthMethod(AuthMethodType.Password),
new AuthMethod(AuthMethodType.Shibboleth, 'location') new AuthMethod(AuthMethodType.Shibboleth, 'location')
]; ];
const action = new RetrieveAuthMethodsSuccessAction(authMethods); const action = new RetrieveAuthMethodsSuccessAction(authMethods, false);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = { state = {
authenticated: false, authenticated: false,
@@ -548,7 +548,31 @@ describe('authReducer', () => {
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => { it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action with blocking as true', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
authMethods: []
};
const authMethods = [
new AuthMethod(AuthMethodType.Password),
new AuthMethod(AuthMethodType.Shibboleth, 'location')
];
const action = new RetrieveAuthMethodsSuccessAction(authMethods, true);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
blocking: true,
loading: false,
authMethods: authMethods
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action ', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
@@ -557,7 +581,7 @@ describe('authReducer', () => {
authMethods: [] authMethods: []
}; };
const action = new RetrieveAuthMethodsErrorAction(); const action = new RetrieveAuthMethodsErrorAction(false);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = { state = {
authenticated: false, authenticated: false,
@@ -568,4 +592,25 @@ describe('authReducer', () => {
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action with blocking as true', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
authMethods: []
};
const action = new RetrieveAuthMethodsErrorAction(true);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
blocking: true,
loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
};
expect(newState).toEqual(state);
});
}); });

View File

@@ -10,6 +10,7 @@ import {
RedirectWhenTokenExpiredAction, RedirectWhenTokenExpiredAction,
RefreshTokenSuccessAction, RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonSuccessAction, RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction, RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction SetRedirectUrlAction
} from './auth.actions'; } from './auth.actions';
@@ -211,14 +212,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: false, loading: false,
blocking: false, blocking: (action as RetrieveAuthMethodsSuccessAction).payload.blocking,
authMethods: (action as RetrieveAuthMethodsSuccessAction).payload authMethods: (action as RetrieveAuthMethodsSuccessAction).payload.authMethods
}); });
case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: false, loading: false,
blocking: false, blocking: (action as RetrieveAuthMethodsErrorAction).payload,
authMethods: [new AuthMethod(AuthMethodType.Password)] authMethods: [new AuthMethod(AuthMethodType.Password)]
}); });

View File

@@ -35,6 +35,7 @@ import { AppState } from '../../app.reducer';
import { import {
CheckAuthenticationTokenAction, CheckAuthenticationTokenAction,
ResetAuthenticationMessagesAction, ResetAuthenticationMessagesAction,
RetrieveAuthMethodsAction,
SetRedirectUrlAction SetRedirectUrlAction
} from './auth.actions'; } from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { NativeWindowRef, NativeWindowService } from '../services/window.service';
@@ -518,4 +519,13 @@ export class AuthService {
); );
} }
/**
* Return a new instance of RetrieveAuthMethodsAction
*
* @param authStatus The auth status
*/
getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
return new RetrieveAuthMethodsAction(authStatus, false);
}
} }

View File

@@ -10,6 +10,7 @@ import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthTokenInfo } from './models/auth-token-info.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { RetrieveAuthMethodsAction } from './auth.actions';
/** /**
* The auth service. * The auth service.
@@ -60,4 +61,13 @@ export class ServerAuthService extends AuthService {
map((rd: RemoteData<AuthStatus>) => Object.assign(new AuthStatus(), rd.payload)) map((rd: RemoteData<AuthStatus>) => Object.assign(new AuthStatus(), rd.payload))
); );
} }
/**
* Return a new instance of RetrieveAuthMethodsAction
*
* @param authStatus The auth status
*/
getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
return new RetrieveAuthMethodsAction(authStatus, true);
}
} }

View File

@@ -102,7 +102,7 @@ describe('LinkService', () => {
describe('resolveLink', () => { describe('resolveLink', () => {
describe(`when the linkdefinition concerns a single object`, () => { describe(`when the linkdefinition concerns a single object`, () => {
beforeEach(() => { beforeEach(() => {
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}); });
it('should call dataservice.findByHref with the correct href and nested links', () => { it('should call dataservice.findByHref with the correct href and nested links', () => {
expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor')); expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor'));
@@ -116,7 +116,7 @@ describe('LinkService', () => {
propertyName: 'predecessor', propertyName: 'predecessor',
isList: true isList: true
}); });
service.resolveLink(testModel, followLink('predecessor', { some: 'options ' } as any, true, true, true, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor')));
}); });
it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => { it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => {
expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor'));
@@ -124,7 +124,7 @@ describe('LinkService', () => {
}); });
describe('either way', () => { describe('either way', () => {
beforeEach(() => { beforeEach(() => {
result = service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}); });
it('should call getLinkDefinition with the correct model and link', () => { it('should call getLinkDefinition with the correct model and link', () => {
@@ -149,7 +149,7 @@ describe('LinkService', () => {
}); });
it('should throw an error', () => { it('should throw an error', () => {
expect(() => { expect(() => {
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}).toThrow(); }).toThrow();
}); });
}); });
@@ -160,7 +160,7 @@ describe('LinkService', () => {
}); });
it('should throw an error', () => { it('should throw an error', () => {
expect(() => { expect(() => {
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}).toThrow(); }).toThrow();
}); });
}); });

View File

@@ -39,7 +39,7 @@ export class LinkService {
*/ */
public resolveLinks<T extends HALResource>(model: T, ...linksToFollow: FollowLinkConfig<T>[]): T { public resolveLinks<T extends HALResource>(model: T, ...linksToFollow: FollowLinkConfig<T>[]): T {
linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => { linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
this.resolveLink(model, linkToFollow); this.resolveLink(model, linkToFollow);
}); });
return model; return model;
} }
@@ -55,9 +55,7 @@ export class LinkService {
public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U>> { public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U>> {
const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name); const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name);
if (hasNoValue(matchingLinkDef)) { if (hasValue(matchingLinkDef)) {
throw new Error(`followLink('${linkToFollow.name}') was used for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
} else {
const provider = this.getDataServiceFor(matchingLinkDef.resourceType); const provider = this.getDataServiceFor(matchingLinkDef.resourceType);
if (hasNoValue(provider)) { if (hasNoValue(provider)) {
@@ -84,7 +82,10 @@ export class LinkService {
throw e; throw e;
} }
} }
} else if (!linkToFollow.isOptional) {
throw new Error(`followLink('${linkToFollow.name}') was used as a required link for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
} }
return EMPTY; return EMPTY;
} }

View File

@@ -523,7 +523,7 @@ describe('RemoteDataBuildService', () => {
let paginatedLinksToFollow; let paginatedLinksToFollow;
beforeEach(() => { beforeEach(() => {
paginatedLinksToFollow = [ paginatedLinksToFollow = [
followLink('page', undefined, true, true, true, ...linksToFollow), followLink('page', {}, ...linksToFollow),
...linksToFollow ...linksToFollow
]; ];
}); });

View File

@@ -271,7 +271,7 @@ export class RemoteDataBuildService {
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/ */
buildList<T extends HALResource>(href$: string | Observable<string>, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> { buildList<T extends HALResource>(href$: string | Observable<string>, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
return this.buildFromHref<PaginatedList<T>>(href$, followLink('page', undefined, false, true, true, ...linksToFollow)); return this.buildFromHref<PaginatedList<T>>(href$, followLink('page', { shouldEmbed: false }, ...linksToFollow));
} }
/** /**

View File

@@ -162,6 +162,7 @@ import { UsageReport } from './statistics/models/usage-report.model';
import { RootDataService } from './data/root-data.service'; import { RootDataService } from './data/root-data.service';
import { Root } from './data/root.model'; import { Root } from './data/root.model';
import { SearchConfig } from './shared/search/search-filters/search-config.model'; import { SearchConfig } from './shared/search/search-filters/search-config.model';
import { SequenceService } from './shared/sequence.service';
/** /**
* When not in production, endpoint responses can be mocked for testing purposes * When not in production, endpoint responses can be mocked for testing purposes
@@ -282,7 +283,8 @@ const PROVIDERS = [
FilteredDiscoveryPageResponseParsingService, FilteredDiscoveryPageResponseParsingService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory }, { provide: NativeWindowService, useFactory: NativeWindowFactory },
VocabularyService, VocabularyService,
VocabularyTreeviewService VocabularyTreeviewService,
SequenceService,
]; ];
/** /**

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators'; import { dataService } from '../cache/builders/build-decorators';
@@ -18,7 +18,7 @@ import { Item } from '../shared/item.model';
import { BundleDataService } from './bundle-data.service'; import { BundleDataService } from './bundle-data.service';
import { DataService } from './data.service'; import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { PaginatedList, buildPaginatedList } from './paginated-list.model'; import { buildPaginatedList, PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { FindListOptions, PutRequest } from './request.models'; import { FindListOptions, PutRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
@@ -28,7 +28,6 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { sendRequest } from '../shared/operators'; import { sendRequest } from '../shared/operators';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { RequestEntryState } from './request.reducer';
/** /**
* A service to retrieve {@link Bitstream}s from the REST API * A service to retrieve {@link Bitstream}s from the REST API
@@ -75,92 +74,6 @@ export class BitstreamDataService extends DataService<Bitstream> {
return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/**
* Retrieves the thumbnail for the given item
* @returns {Observable<RemoteData<{@link Bitstream}>>} the first bitstream in the THUMBNAIL bundle
*/
// TODO should be implemented rest side. {@link Item} should get a thumbnail link
public getThumbnailFor(item: Item): Observable<RemoteData<Bitstream>> {
return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
switchMap((bundleRD: RemoteData<Bundle>) => {
if (isNotEmpty(bundleRD.payload)) {
return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe(
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
return new RemoteData(
bitstreamRD.timeCompleted,
bitstreamRD.msToLive,
bitstreamRD.lastUpdated,
bitstreamRD.state,
bitstreamRD.errorMessage,
bitstreamRD.payload.page[0],
bitstreamRD.statusCode
);
} else {
return bitstreamRD as any;
}
})
);
} else {
return [bundleRD as any];
}
})
);
}
/**
* Retrieve the matching thumbnail for a {@link Bitstream}.
*
* The {@link Item} is technically redundant, but is available
* in all current use cases, and having it simplifies this method
*
* @param item The {@link Item} the {@link Bitstream} and its thumbnail are a part of
* @param bitstreamInOriginal The original {@link Bitstream} to find the thumbnail for
*/
// TODO should be implemented rest side
public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable<RemoteData<Bitstream>> {
return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
switchMap((bundleRD: RemoteData<Bundle>) => {
if (isNotEmpty(bundleRD.payload)) {
return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 9999 }).pipe(
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
const matchingThumbnail = bitstreamRD.payload.page.find((thumbnail: Bitstream) =>
thumbnail.name.startsWith(bitstreamInOriginal.name)
);
if (hasValue(matchingThumbnail)) {
return new RemoteData(
bitstreamRD.timeCompleted,
bitstreamRD.msToLive,
bitstreamRD.lastUpdated,
bitstreamRD.state,
bitstreamRD.errorMessage,
matchingThumbnail,
bitstreamRD.statusCode
);
} else {
return new RemoteData(
bitstreamRD.timeCompleted,
bitstreamRD.msToLive,
bitstreamRD.lastUpdated,
RequestEntryState.Error,
'No matching thumbnail found',
undefined,
404
);
}
} else {
return bitstreamRD as any;
}
})
);
} else {
return [bundleRD as any];
}
})
);
}
/** /**
* Retrieve all {@link Bitstream}s in a certain {@link Bundle}. * Retrieve all {@link Bitstream}s in a certain {@link Bundle}.
* *

View File

@@ -233,7 +233,7 @@ describe('DataService', () => {
const config: FindListOptions = Object.assign(new FindListOptions(), { const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 5 elementsPerPage: 5
}); });
(service as any).getFindAllHref({}, null, followLink('bundles', config, true, true, true)).subscribe((value) => { (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -253,7 +253,7 @@ describe('DataService', () => {
elementsPerPage: 2 elementsPerPage: 2
}); });
(service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', config, true, true, true), followLink('templateItemOf')).subscribe((value) => { (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -261,7 +261,13 @@ describe('DataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => { it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpoint}?embed=templateItemOf`; const expected = `${endpoint}?embed=templateItemOf`;
(service as any).getFindAllHref({}, null, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')).subscribe((value) => { (service as any).getFindAllHref(
{},
null,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -269,7 +275,7 @@ describe('DataService', () => {
it('should include nested linksToFollow 3lvl', () => { it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`;
(service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))).subscribe((value) => { (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -279,7 +285,7 @@ describe('DataService', () => {
const config: FindListOptions = Object.assign(new FindListOptions(), { const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 4 elementsPerPage: 4
}); });
(service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', config, true, true, true))).subscribe((value) => { (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -308,13 +314,19 @@ describe('DataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => { it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')); const result = (service as any).getIDHref(
endpointMock,
resourceIdMock,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
);
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it('should include nested linksToFollow 3lvl', () => { it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', undefined, true, true, true,followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))); const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships'))));
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
}); });

View File

@@ -119,7 +119,7 @@ describe('DsoRedirectDataService', () => {
}); });
it('should navigate to entities route with the corresponding entity type', () => { it('should navigate to entities route with the corresponding entity type', () => {
remoteData.payload.type = 'item'; remoteData.payload.type = 'item';
remoteData.payload.metadata = { remoteData.payload.metadata = {
'dspace.entity.type': [ 'dspace.entity.type': [
{ {
language: 'en_US', language: 'en_US',
@@ -174,13 +174,29 @@ describe('DsoRedirectDataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => { it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${requestUUIDURL}&embed=templateItemOf`; const expected = `${requestUUIDURL}&embed=templateItemOf`;
const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')); const result = (service as any).getIDHref(
pidLink,
dsoUUID,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
);
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it('should include nested linksToFollow 3lvl', () => { it('should include nested linksToFollow 3lvl', () => {
const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`;
const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))); const result = (service as any).getIDHref(
pidLink,
dsoUUID,
followLink('owningCollection',
{},
followLink('itemtemplate',
{},
followLink('relationships')
)
)
);
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
}); });

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

@@ -20,6 +20,7 @@ export const JsonPatchOperationsActionTypes = {
ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'), ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'),
FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'), FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'),
START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'), START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'),
DELETE_PENDING_JSON_PATCH_OPERATIONS: type('dspace/core/patch/DELETE_PENDING_JSON_PATCH_OPERATIONS'),
}; };
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@@ -261,6 +262,13 @@ export class NewPatchReplaceOperationAction implements Action {
} }
} }
/**
* An ngrx action to delete all pending JSON Patch Operations.
*/
export class DeletePendingJsonPatchOperationsAction implements Action {
type = JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS;
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
/** /**
@@ -276,4 +284,5 @@ export type PatchOperationsActions
| NewPatchRemoveOperationAction | NewPatchRemoveOperationAction
| NewPatchReplaceOperationAction | NewPatchReplaceOperationAction
| RollbacktPatchOperationsAction | RollbacktPatchOperationsAction
| StartTransactionPatchOperationsAction; | StartTransactionPatchOperationsAction
| DeletePendingJsonPatchOperationsAction;

View File

@@ -1,7 +1,7 @@
import * as deepFreeze from 'deep-freeze'; import * as deepFreeze from 'deep-freeze';
import { import {
CommitPatchOperationsAction, CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction,
FlushPatchOperationsAction, FlushPatchOperationsAction,
NewPatchAddOperationAction, NewPatchAddOperationAction,
NewPatchRemoveOperationAction, NewPatchRemoveOperationAction,
@@ -323,4 +323,19 @@ describe('jsonPatchOperationsReducer test suite', () => {
}); });
describe('When DeletePendingJsonPatchOperationsAction has been dispatched', () => {
it('should set set the JsonPatchOperationsState to null ', () => {
const action = new DeletePendingJsonPatchOperationsAction();
initState = Object.assign({}, testState, {
[testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], {
transactionStartTime: startTimestamp,
commitPending: true
})
});
const newState = jsonPatchOperationsReducer(initState, action);
expect(newState).toBeNull();
});
});
}); });

View File

@@ -11,7 +11,8 @@ import {
NewPatchReplaceOperationAction, NewPatchReplaceOperationAction,
CommitPatchOperationsAction, CommitPatchOperationsAction,
StartTransactionPatchOperationsAction, StartTransactionPatchOperationsAction,
RollbacktPatchOperationsAction RollbacktPatchOperationsAction,
DeletePendingJsonPatchOperationsAction
} from './json-patch-operations.actions'; } from './json-patch-operations.actions';
import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model'; import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model';
@@ -101,6 +102,10 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp
return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction); return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction);
} }
case JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS: {
return deletePendingOperations(state, action as DeletePendingJsonPatchOperationsAction);
}
default: { default: {
return state; return state;
} }
@@ -178,6 +183,20 @@ function rollbackOperations(state: JsonPatchOperationsState, action: RollbacktPa
} }
} }
/**
* Set the JsonPatchOperationsState to its initial value.
*
* @param state
* the current state
* @param action
* an DeletePendingJsonPatchOperationsAction
* @return JsonPatchOperationsState
* the new state.
*/
function deletePendingOperations(state: JsonPatchOperationsState, action: DeletePendingJsonPatchOperationsAction): JsonPatchOperationsState {
return null;
}
/** /**
* Add new JSON patch operation list. * Add new JSON patch operation list.
* *

View File

@@ -17,6 +17,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer';
import { import {
CommitPatchOperationsAction, CommitPatchOperationsAction,
DeletePendingJsonPatchOperationsAction,
RollbacktPatchOperationsAction, RollbacktPatchOperationsAction,
StartTransactionPatchOperationsAction StartTransactionPatchOperationsAction
} from './json-patch-operations.actions'; } from './json-patch-operations.actions';
@@ -288,4 +289,19 @@ describe('JsonPatchOperationsService test suite', () => {
}); });
}); });
describe('deletePendingJsonPatchOperations', () => {
beforeEach(() => {
store.dispatch.and.callFake(() => { /* */ });
});
it('should dispatch a new DeletePendingJsonPatchOperationsAction', () => {
const expectedAction = new DeletePendingJsonPatchOperationsAction();
scheduler.schedule(() => service.deletePendingJsonPatchOperations());
scheduler.flush();
expect(store.dispatch).toHaveBeenCalledWith(expectedAction);
});
});
}); });

View File

@@ -10,7 +10,7 @@ import { CoreState } from '../core.reducers';
import { jsonPatchOperationsByResourceType } from './selectors'; import { jsonPatchOperationsByResourceType } from './selectors';
import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer';
import { import {
CommitPatchOperationsAction, CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction,
RollbacktPatchOperationsAction, RollbacktPatchOperationsAction,
StartTransactionPatchOperationsAction StartTransactionPatchOperationsAction
} from './json-patch-operations.actions'; } from './json-patch-operations.actions';
@@ -105,6 +105,13 @@ export abstract class JsonPatchOperationsService<ResponseDefinitionDomain, Patch
); );
} }
/**
* Dispatch an action to delete all pending JSON patch Operations.
*/
public deletePendingJsonPatchOperations() {
this.store.dispatch(new DeletePendingJsonPatchOperationsAction());
}
/** /**
* Return an instance for RestRequest class * Return an instance for RestRequest class
* *

View File

@@ -43,13 +43,14 @@ export class Bitstream extends DSpaceObject implements HALResource {
bundle: HALLink; bundle: HALLink;
format: HALLink; format: HALLink;
content: HALLink; content: HALLink;
thumbnail: HALLink;
}; };
/** /**
* The thumbnail for this Bitstream * The thumbnail for this Bitstream
* Needs to be resolved first, but isn't available as a {@link HALLink} yet * Will be undefined unless the thumbnail {@link HALLink} has been resolved.
* Use BitstreamDataService.getThumbnailFor(…) for now.
*/ */
@link(BITSTREAM, false, 'thumbnail')
thumbnail?: Observable<RemoteData<Bitstream>>; thumbnail?: Observable<RemoteData<Bitstream>>;
/** /**

View File

@@ -1,4 +1,4 @@
import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; import { autoserialize, autoserializeAs, deserialize, deserializeAs, inheritSerialization } from 'cerialize';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { isEmpty } from '../../shared/empty.util'; import { isEmpty } from '../../shared/empty.util';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
@@ -19,6 +19,8 @@ import { ITEM } from './item.resource-type';
import { ChildHALResource } from './child-hal-resource.model'; import { ChildHALResource } from './child-hal-resource.model';
import { Version } from './version.model'; import { Version } from './version.model';
import { VERSION } from './version.resource-type'; import { VERSION } from './version.resource-type';
import { BITSTREAM } from './bitstream.resource-type';
import { Bitstream } from './bitstream.model';
/** /**
* Class representing a DSpace Item * Class representing a DSpace Item
@@ -37,7 +39,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
/** /**
* The Date of the last modification of this Item * The Date of the last modification of this Item
*/ */
@deserialize @deserializeAs(Date)
lastModified: Date; lastModified: Date;
/** /**
@@ -69,6 +71,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
owningCollection: HALLink; owningCollection: HALLink;
templateItemOf: HALLink; templateItemOf: HALLink;
version: HALLink; version: HALLink;
thumbnail: HALLink;
self: HALLink; self: HALLink;
}; };
@@ -100,6 +103,13 @@ export class Item extends DSpaceObject implements ChildHALResource {
@link(RELATIONSHIP, true) @link(RELATIONSHIP, true)
relationships?: Observable<RemoteData<PaginatedList<Relationship>>>; relationships?: Observable<RemoteData<PaginatedList<Relationship>>>;
/**
* The thumbnail for this Item
* Will be undefined unless the thumbnail {@link HALLink} has been resolved.
*/
@link(BITSTREAM, false, 'thumbnail')
thumbnail?: Observable<RemoteData<Bitstream>>;
/** /**
* Method that returns as which type of object this object should be rendered * Method that returns as which type of object this object should be rendered
*/ */

View File

@@ -41,6 +41,43 @@ import { SearchConfig } from './search-filters/search-config.model';
import { PaginationService } from '../../pagination/pagination.service'; import { PaginationService } from '../../pagination/pagination.service';
import { SearchConfigurationService } from './search-configuration.service'; import { SearchConfigurationService } from './search-configuration.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { DataService } from '../../data/data.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DSOChangeAnalyzer } from '../../data/dso-change-analyzer.service';
/* tslint:disable:max-classes-per-file */
/**
* A class that lets us delegate some methods to DataService
*/
class DataServiceImpl extends DataService<any> {
protected linkPath = 'discover';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<any>) {
super();
}
/**
* Adds the embed options to the link for the request
* @param href The href the params are to be added to
* @param args params for the query string
* @param linksToFollow links we want to embed in query string if shouldEmbed is true
*/
public addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig<any>[]) {
return super.addEmbedParams(href, args, ...linksToFollow);
}
}
/** /**
* Service that performs all general actions that have to do with the search page * Service that performs all general actions that have to do with the search page
@@ -78,6 +115,11 @@ export class SearchService implements OnDestroy {
*/ */
private sub; private sub;
/**
* Instance of DataServiceImpl that lets us delegate some methods to DataService
*/
private searchDataService: DataServiceImpl;
constructor(private router: Router, constructor(private router: Router,
private routeService: RouteService, private routeService: RouteService,
protected requestService: RequestService, protected requestService: RequestService,
@@ -89,6 +131,16 @@ export class SearchService implements OnDestroy {
private paginationService: PaginationService, private paginationService: PaginationService,
private searchConfigurationService: SearchConfigurationService private searchConfigurationService: SearchConfigurationService
) { ) {
this.searchDataService = new DataServiceImpl(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
} }
/** /**
@@ -131,7 +183,17 @@ export class SearchService implements OnDestroy {
search<T extends DSpaceObject>(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<SearchObjects<T>>> { search<T extends DSpaceObject>(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<SearchObjects<T>>> {
const href$ = this.getEndpoint(searchOptions); const href$ = this.getEndpoint(searchOptions);
href$.pipe(take(1)).subscribe((url: string) => { href$.pipe(
take(1),
map((href: string) => {
const args = this.searchDataService.addEmbedParams(href, [], ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
} else {
return href;
}
})
).subscribe((url: string) => {
const request = new this.request(this.requestService.generateRequestId(), url); const request = new this.request(this.requestService.generateRequestId(), url);
const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => { const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => {
@@ -152,7 +214,7 @@ export class SearchService implements OnDestroy {
); );
return this.directlyAttachIndexableObjects(sqr$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.directlyAttachIndexableObjects(sqr$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/** /**
* Method to retrieve request entries for search results from the server * Method to retrieve request entries for search results from the server
@@ -399,9 +461,9 @@ export class SearchService implements OnDestroy {
let pageParams = { page: 1 }; let pageParams = { page: 1 };
const queryParams = { view: viewMode }; const queryParams = { view: viewMode };
if (viewMode === ViewMode.DetailedListElement) { if (viewMode === ViewMode.DetailedListElement) {
pageParams = Object.assign(pageParams, {pageSize: 1}); pageParams = Object.assign(pageParams, { pageSize: 1 });
} else if (config.pageSize === 1) { } else if (config.pageSize === 1) {
pageParams = Object.assign(pageParams, {pageSize: 10}); pageParams = Object.assign(pageParams, { pageSize: 10 });
} }
this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], pageParams, queryParams); this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], pageParams, queryParams);
}); });
@@ -413,7 +475,7 @@ export class SearchService implements OnDestroy {
* @param {string} configurationName the name of the configuration * @param {string} configurationName the name of the configuration
* @returns {Observable<RemoteData<SearchConfig[]>>} The found configuration * @returns {Observable<RemoteData<SearchConfig[]>>} The found configuration
*/ */
getSearchConfigurationFor(scope?: string, configurationName?: string ): Observable<RemoteData<SearchConfig>> { getSearchConfigurationFor(scope?: string, configurationName?: string): Observable<RemoteData<SearchConfig>> {
const href$ = this.halService.getEndpoint(this.configurationLinkPath).pipe( const href$ = this.halService.getEndpoint(this.configurationLinkPath).pipe(
map((url: string) => this.getConfigUrl(url, scope, configurationName)), map((url: string) => this.getConfigUrl(url, scope, configurationName)),
); );

View File

@@ -0,0 +1,22 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { SequenceService } from './sequence.service';
let service: SequenceService;
describe('SequenceService', () => {
beforeEach(() => {
service = new SequenceService();
});
it('should return sequential numbers on next(), starting with 1', () => {
const NUMBERS = [1,2,3,4,5];
const sequence = NUMBERS.map(() => service.next());
expect(sequence).toEqual(NUMBERS);
});
});

View File

@@ -0,0 +1,24 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
@Injectable()
/**
* Provides unique sequential numbers
*/
export class SequenceService {
private value: number;
constructor() {
this.value = 0;
}
public next(): number {
return ++this.value;
}
}

View File

@@ -173,7 +173,7 @@ export class VocabularyService {
); );
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries), switchMap((vocabulary: Vocabulary) => vocabulary.entries),
); );
@@ -200,7 +200,7 @@ export class VocabularyService {
); );
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries), switchMap((vocabulary: Vocabulary) => vocabulary.entries),
); );
@@ -249,7 +249,7 @@ export class VocabularyService {
); );
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries), switchMap((vocabulary: Vocabulary) => vocabulary.entries),
getFirstSucceededRemoteListPayload(), getFirstSucceededRemoteListPayload(),

View File

@@ -8,13 +8,13 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>

View File

@@ -8,13 +8,13 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>

View File

@@ -8,13 +8,13 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>

View File

@@ -9,7 +9,7 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="object" <ds-generic-item-page-field [item]="object"
[fields]="['publicationvolume.volumeNumber']" [fields]="['publicationvolume.volumeNumber']"

View File

@@ -9,7 +9,7 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="object" <ds-generic-item-page-field [item]="object"
[fields]="['publicationvolume.volumeNumber']" [fields]="['publicationvolume.volumeNumber']"

View File

@@ -9,7 +9,7 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ds-generic-item-page-field class="item-page-fields" [item]="object" <ds-generic-item-page-field class="item-page-fields" [item]="object"
[fields]="['creativeworkseries.issn']" [fields]="['creativeworkseries.issn']"

View File

@@ -8,13 +8,13 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>

View File

@@ -8,13 +8,13 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>

View File

@@ -8,13 +8,13 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>

View File

@@ -9,7 +9,7 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="thumbnail$ | async" <ds-thumbnail [thumbnail]="object?.thumbnail | async"
[defaultImage]="'assets/images/orgunit-placeholder.svg'" [defaultImage]="'assets/images/orgunit-placeholder.svg'"
[alt]="'thumbnail.orgunit.alt'" [alt]="'thumbnail.orgunit.alt'"
[placeholder]="'thumbnail.orgunit.placeholder'" [placeholder]="'thumbnail.orgunit.placeholder'"

View File

@@ -9,7 +9,7 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="thumbnail$ | async" <ds-thumbnail [thumbnail]="object?.thumbnail | async"
[defaultImage]="'assets/images/person-placeholder.svg'" [defaultImage]="'assets/images/person-placeholder.svg'"
[alt]="'thumbnail.person.alt'" [alt]="'thumbnail.person.alt'"
[placeholder]="'thumbnail.person.placeholder'"> [placeholder]="'thumbnail.person.placeholder'">

View File

@@ -10,7 +10,7 @@
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail <ds-thumbnail
[thumbnail]="thumbnail$ | async" [thumbnail]="object?.thumbnail | async"
[defaultImage]="'assets/images/project-placeholder.svg'" [defaultImage]="'assets/images/project-placeholder.svg'"
[alt]="'thumbnail.project.alt'" [alt]="'thumbnail.project.alt'"
[placeholder]="'thumbnail.project.placeholder'"> [placeholder]="'thumbnail.project.placeholder'">

View File

@@ -1,6 +1,6 @@
<div class="d-flex"> <div class="d-flex">
<!-- <div class="person-thumbnail pr-2">--> <!-- <div class="person-thumbnail pr-2">-->
<!-- <ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>--> <!-- <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>-->
<!-- </div>--> <!-- </div>-->
<div class="flex-grow-1"> <div class="flex-grow-1">
<ds-org-unit-input-suggestions *ngIf="useNameVariants" [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)" <ds-org-unit-input-suggestions *ngIf="useNameVariants" [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)"

View File

@@ -1,8 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
import { Bitstream } from '../../../../../core/shared/bitstream.model';
import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
@@ -113,11 +110,4 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
modalComp.value = value; modalComp.value = value;
return modalRef.result; return modalRef.result;
} }
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
getThumbnail(): Observable<Bitstream> {
return this.bitstreamDataService.getThumbnailFor(this.dso).pipe(
getFirstSucceededRemoteDataPayload()
);
}
} }

View File

@@ -1,8 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
import { Bitstream } from '../../../../../core/shared/bitstream.model';
import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
@@ -108,11 +105,4 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu
modalComp.value = value; modalComp.value = value;
return modalRef.result; return modalRef.result;
} }
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
getThumbnail(): Observable<Bitstream> {
return this.bitstreamDataService.getThumbnailFor(this.dso).pipe(
getFirstSucceededRemoteDataPayload()
);
}
} }

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { first, map } from 'rxjs/operators'; import { first, map } from 'rxjs/operators';
@@ -29,6 +29,12 @@ export class DeleteComColPageComponent<TDomain extends Community | Collection> i
*/ */
public dsoRD$: Observable<RemoteData<TDomain>>; public dsoRD$: Observable<RemoteData<TDomain>>;
/**
* A boolean representing if a delete operation is pending
* @type {BehaviorSubject<boolean>}
*/
public processing$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public constructor( public constructor(
protected dsoDataService: ComColDataService<TDomain>, protected dsoDataService: ComColDataService<TDomain>,
protected router: Router, protected router: Router,
@@ -48,6 +54,7 @@ export class DeleteComColPageComponent<TDomain extends Community | Collection> i
* Deletes an existing DSO and redirects to the home page afterwards, showing a notification that states whether or not the deletion was successful * Deletes an existing DSO and redirects to the home page afterwards, showing a notification that states whether or not the deletion was successful
*/ */
onConfirm(dso: TDomain) { onConfirm(dso: TDomain) {
this.processing$.next(true);
this.dsoDataService.delete(dso.id) this.dsoDataService.delete(dso.id)
.pipe(getFirstCompletedRemoteData()) .pipe(getFirstCompletedRemoteData())
.subscribe((response: RemoteData<NoContent>) => { .subscribe((response: RemoteData<NoContent>) => {

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

@@ -48,7 +48,7 @@
</ng-template> </ng-template>
</ds-dynamic-form> </ds-dynamic-form>
<ng-content select="[additional]"></ng-content>
<ng-content *ngIf="!displaySubmit && !displayCancel"></ng-content> <ng-content *ngIf="!displaySubmit && !displayCancel"></ng-content>
<div *ngIf="displaySubmit || displayCancel"> <div *ngIf="displaySubmit || displayCancel">

View File

@@ -3,13 +3,27 @@
(keydown.arrowdown)="shiftFocusDown($event)" (keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()" (keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close();"> (dsClickOutside)="close();">
<input #inputField type="text" [(ngModel)]="value" [name]="name" <div class="form-group mb-0">
class="form-control suggestion_input" <label *ngIf="label; else searchInput">
[ngClass]="{'is-invalid': !valid}" <span class="font-weight-bold">
[dsDebounce]="debounceTime" (onDebounce)="find($event)" {{label}}
[placeholder]="placeholder" </span>
[ngModelOptions]="{standalone: true}" autocomplete="off"/> <ng-container *ngTemplateOutlet="searchInput"></ng-container>
<input type="submit" class="d-none"/> </label>
</div>
<ng-template #searchInput>
<input #inputField type="text" [(ngModel)]="value" [name]="name"
class="form-control suggestion_input"
[ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"
/>
</ng-template>
<label class="d-none">
<input type="submit"/>
<span>{{'search.filters.search.submit' | translate}}</span>
</label>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}"> <div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<div class="dropdown-list"> <div class="dropdown-list">
<div *ngFor="let suggestionOption of suggestions"> <div *ngFor="let suggestionOption of suggestions">
@@ -19,4 +33,4 @@
</div> </div>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -53,6 +53,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
*/ */
@Input() valid = true; @Input() valid = true;
/**
* Label for the input field. Used for screen readers.
*/
@Input() label? = '';
/** /**
* Output for when the form is submitted * Output for when the form is submitted
*/ */

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

@@ -49,8 +49,8 @@ export class ClaimedTaskSearchResultDetailElementComponent extends SearchResultD
*/ */
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, true, true, this.linkService.resolveLinks(this.dso, followLink('workflowitem', {},
followLink('item', null, true, true, true, followLink('bundles')), followLink('item', {}, followLink('bundles')),
followLink('submitter') followLink('submitter')
), followLink('action')); ), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>; this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;

View File

@@ -10,7 +10,7 @@
<div class="row mb-1"> <div class="row mb-1">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper [hideIfNoTextContent]="false"> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="item?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ng-container *ngVar="(getFiles() | async) as bitstreams"> <ng-container *ngVar="(getFiles() | async) as bitstreams">
<ds-metadata-field-wrapper [label]="('item.page.files' | translate)"> <ds-metadata-field-wrapper [label]="('item.page.files' | translate)">

View File

@@ -126,13 +126,6 @@ describe('ItemDetailPreviewComponent', () => {
})); }));
it('should get item thumbnail', (done) => {
component.getThumbnail().subscribe((thumbnail) => {
expect(thumbnail).toBeDefined();
done();
});
});
it('should get item bitstreams', (done) => { it('should get item bitstreams', (done) => {
component.getFiles().subscribe((bitstreams) => { component.getFiles().subscribe((bitstreams) => {
expect(bitstreams).toBeDefined(); expect(bitstreams).toBeDefined();

View File

@@ -5,10 +5,7 @@ import { first } from 'rxjs/operators';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators';
getFirstSucceededRemoteDataPayload,
getFirstSucceededRemoteListPayload
} from '../../../../core/shared/operators';
import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type';
import { fadeInOut } from '../../../animations/fade'; import { fadeInOut } from '../../../animations/fade';
import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Bitstream } from '../../../../core/shared/bitstream.model';
@@ -57,11 +54,6 @@ export class ItemDetailPreviewComponent {
*/ */
public separator = ', '; public separator = ', ';
/**
* The item's thumbnail
*/
public thumbnail$: Observable<Bitstream>;
/** /**
* Initialize instance variables * Initialize instance variables
* *
@@ -86,13 +78,6 @@ export class ItemDetailPreviewComponent {
}); });
} }
// TODO refactor this method to return RemoteData, and the template to deal with loading and errors
public getThumbnail(): Observable<Bitstream> {
return this.bitstreamDataService.getThumbnailFor(this.item).pipe(
getFirstSucceededRemoteDataPayload()
);
}
// TODO refactor this method to return RemoteData, and the template to deal with loading and errors // TODO refactor this method to return RemoteData, and the template to deal with loading and errors
public getFiles(): Observable<Bitstream[]> { public getFiles(): Observable<Bitstream[]> {
return this.bitstreamDataService return this.bitstreamDataService

View File

@@ -48,8 +48,8 @@ export class PoolSearchResultDetailElementComponent extends SearchResultDetailEl
*/ */
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, true, true, this.linkService.resolveLinks(this.dso, followLink('workflowitem', {},
followLink('item', null, true, true, true, followLink('bundles')), followLink('item', {}, followLink('bundles')),
followLink('submitter') followLink('submitter')
), followLink('action')); ), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>; this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;

View File

@@ -6,13 +6,13 @@
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer" [routerLink]="[itemPageRoute]" <a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-thumbnail [thumbnail]="getThumbnail() | async" [limitWidth]="false"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>
@@ -43,4 +43,3 @@
</ds-truncatable> </ds-truncatable>
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>

View File

@@ -3,13 +3,11 @@ import { Observable } from 'rxjs';
import { SearchResult } from '../../search/search-result.model'; import { SearchResult } from '../../search/search-result.model';
import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../core/data/bitstream-data.service';
import { Bitstream } from '../../../core/shared/bitstream.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { Metadata } from '../../../core/shared/metadata.utils'; import { Metadata } from '../../../core/shared/metadata.utils';
import { hasValue } from '../../empty.util'; import { hasValue } from '../../empty.util';
import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component';
import { TruncatableService } from '../../truncatable/truncatable.service'; import { TruncatableService } from '../../truncatable/truncatable.service';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
@Component({ @Component({
selector: 'ds-search-result-grid-element', selector: 'ds-search-result-grid-element',
@@ -66,11 +64,4 @@ export class SearchResultGridElementComponent<T extends SearchResult<K>, K exten
private isCollapsed(): Observable<boolean> { private isCollapsed(): Observable<boolean> {
return this.truncatableService.isCollapsed(this.dso.id); return this.truncatableService.isCollapsed(this.dso.id);
} }
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
getThumbnail(): Observable<Bitstream> {
return this.bitstreamDataService.getThumbnailFor(this.dso as any).pipe(
getFirstSucceededRemoteDataPayload()
);
}
} }

View File

@@ -55,10 +55,7 @@ export class ClaimedApprovedSearchResultListElementComponent extends SearchResul
super.ngOnInit(); super.ngOnInit();
this.linkService.resolveLinks(this.dso, this.linkService.resolveLinks(this.dso,
followLink('workflowitem', followLink('workflowitem',
null, { useCachedVersionIfAvailable: false },
true,
false,
true,
followLink('item'), followLink('item'),
followLink('submitter') followLink('submitter')
), ),

View File

@@ -56,10 +56,7 @@ export class ClaimedDeclinedSearchResultListElementComponent extends SearchResul
super.ngOnInit(); super.ngOnInit();
this.linkService.resolveLinks(this.dso, this.linkService.resolveLinks(this.dso,
followLink('workflowitem', followLink('workflowitem',
null, { useCachedVersionIfAvailable: false },
true,
false,
true,
followLink('item'), followLink('item'),
followLink('submitter') followLink('submitter')
), ),

View File

@@ -50,7 +50,7 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle
*/ */
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, true, true, this.linkService.resolveLinks(this.dso, followLink('workflowitem', {},
followLink('item'), followLink('submitter') followLink('item'), followLink('submitter')
), followLink('action')); ), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>; this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;

View File

@@ -60,7 +60,7 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen
*/ */
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, true, true, this.linkService.resolveLinks(this.dso, followLink('workflowitem', {},
followLink('item'), followLink('submitter') followLink('item'), followLink('submitter')
), followLink('action')); ), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>; this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;

View File

@@ -8,15 +8,18 @@
</ng-container> </ng-container>
<div class="clearfix toggle-more-filters"> <div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)" <a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more" (click)="showMore()" href="javascript:void(0);">
| translate}}</a> {{"search.filters.filter.show-more" | translate}}
</a>
<a class="float-right" *ngIf="(currentPage | async) > 1" <a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less" (click)="showFirstPageOnly()" href="javascript:void(0);">
| translate}}</a> {{"search.filters.filter.show-less" | translate}}
</a>
</div> </div>
</div> </div>
<ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)" <ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder' | translate"
[label]="'search.filters.filter.' + filterConfig.name + '.label' | translate"
[action]="currentUrl" [action]="currentUrl"
[name]="filterConfig.paramName" [name]="filterConfig.paramName"
[(ngModel)]="filter" [(ngModel)]="filter"

View File

@@ -8,11 +8,13 @@
</ng-container> </ng-container>
<div class="clearfix toggle-more-filters"> <div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)" <a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more" (click)="showMore()" href="javascript:void(0);">
| translate}}</a> {{"search.filters.filter.show-more" | translate}}
</a>
<a class="float-right" *ngIf="(currentPage | async) > 1" <a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less" (click)="showFirstPageOnly()" href="javascript:void(0);">
| translate}}</a> {{"search.filters.filter.show-less" | translate}}
</a>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,13 @@
<a *ngIf="isVisible | async" class="d-flex flex-row" <a *ngIf="isVisible | async" class="d-flex flex-row"
[tabIndex]="-1"
[routerLink]="[searchLink]" [routerLink]="[searchLink]"
[queryParams]="addQueryParams" queryParamsHandling="merge"> [queryParams]="addQueryParams" queryParamsHandling="merge">
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/> <label class="mb-0">
<span class="filter-value px-1"> <input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">
{{ 'search.filters.' + filterConfig.name + '.' + filterValue.value | translate: {default: filterValue.value} }} {{ 'search.filters.' + filterConfig.name + '.' + filterValue.value | translate: {default: filterValue.value} }}
</span> </span>
</label>
<span class="float-right filter-value-count ml-auto"> <span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{filterValue.count}}</span> <span class="badge badge-secondary badge-pill">{{filterValue.count}}</span>
</span> </span>

View File

@@ -1,8 +1,11 @@
<a class="d-flex flex-row" <a class="d-flex flex-row"
[tabIndex]="-1"
[routerLink]="[searchLink]" [routerLink]="[searchLink]"
[queryParams]="removeQueryParams" queryParamsHandling="merge"> [queryParams]="removeQueryParams" queryParamsHandling="merge">
<label class="mb-0">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/> <input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1 text-capitalize"> <span class="filter-value pl-1 text-capitalize">
{{ 'search.filters.' + filterConfig.name + '.' + selectedValue.value | translate: {default: selectedValue.label} }} {{ 'search.filters.' + filterConfig.name + '.' + selectedValue.value | translate: {default: selectedValue.label} }}
</span> </span>
</label>
</a> </a>

View File

@@ -1,17 +1,21 @@
<div class="facet-filter d-block mb-3 p-3" *ngIf="active$ | async"> <div class="facet-filter d-block mb-3 p-3" *ngIf="active$ | async"
<div (click)="toggle()" class="filter-name"> [id]="regionId" [attr.aria-labelledby]="toggleId" [ngClass]="{ 'focus': focusBox }" role="region">
<button (click)="toggle()" (focusin)="focusBox = true" (focusout)="focusBox = false"
class="filter-name d-flex" [attr.aria-controls]="regionId" [id]="toggleId"
[attr.aria-expanded]="false"
[attr.aria-label]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate"
>
<h5 class="d-inline-block mb-0"> <h5 class="d-inline-block mb-0">
{{'search.filters.filter.' + filter.name + '.head'| translate}} {{'search.filters.filter.' + filter.name + '.head'| translate}}
</h5> </h5>
<span class="filter-toggle fas float-right" <span class="filter-toggle flex-grow-1 fas p-auto"
[ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'" [ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'"
[title]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate" [title]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate">
[attr.aria-label]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate">
</span> </span>
</div> </button>
<div [@slide]="(collapsed$ | async) ? 'collapsed' : 'expanded'" <div [@slide]="(collapsed$ | async) ? 'collapsed' : 'expanded'"
(@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)"
class="search-filter-wrapper" [ngClass]="{'closed' : closed}"> class="search-filter-wrapper" [ngClass]="{ 'closed' : closed, 'notab': notab }">
<ds-search-facet-filter-wrapper <ds-search-facet-filter-wrapper
[filterConfig]="filter" [filterConfig]="filter"
[inPlaceSearch]="inPlaceSearch"> [inPlaceSearch]="inPlaceSearch">

View File

@@ -1,10 +1,36 @@
:host .facet-filter { :host .facet-filter {
border: 1px solid var(--bs-light); border: 1px solid var(--bs-light);
cursor: pointer; cursor: pointer;
.search-filter-wrapper.closed { line-height: 0;
overflow: hidden;
.search-filter-wrapper {
line-height: var(--bs-line-height-base);
&.closed {
overflow: hidden;
} }
.filter-toggle { &.notab {
line-height: var(--bs-line-height-base); visibility: hidden;
} }
}
.filter-toggle {
line-height: var(--bs-line-height-base);
text-align: right;
position: relative;
top: -0.125rem; // Fix weird outline shape in Chrome
}
> button {
appearance: none;
border: 0;
padding: 0;
background: transparent;
width: 100%;
outline: none !important;
}
&.focus {
outline: none;
box-shadow: var(--bs-input-btn-focus-box-shadow);
}
} }

View File

@@ -12,6 +12,7 @@ import { SearchFilterConfig } from '../../search-filter-config.model';
import { FilterType } from '../../filter-type.model'; import { FilterType } from '../../filter-type.model';
import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub'; import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
import { SequenceService } from '../../../../core/shared/sequence.service';
describe('SearchFilterComponent', () => { describe('SearchFilterComponent', () => {
let comp: SearchFilterComponent; let comp: SearchFilterComponent;
@@ -50,12 +51,15 @@ describe('SearchFilterComponent', () => {
}; };
let filterService; let filterService;
let sequenceService;
const mockResults = observableOf(['test', 'data']); const mockResults = observableOf(['test', 'data']);
const searchServiceStub = { const searchServiceStub = {
getFacetValuesFor: (filter) => mockResults getFacetValuesFor: (filter) => mockResults
}; };
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
sequenceService = jasmine.createSpyObj('sequenceService', { next: 17 });
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
declarations: [SearchFilterComponent], declarations: [SearchFilterComponent],
@@ -65,7 +69,8 @@ describe('SearchFilterComponent', () => {
provide: SearchFilterService, provide: SearchFilterService,
useValue: mockFilterService useValue: mockFilterService
}, },
{ provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
{ provide: SequenceService, useValue: sequenceService },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFilterComponent, { }).overrideComponent(SearchFilterComponent, {
@@ -81,6 +86,12 @@ describe('SearchFilterComponent', () => {
filterService = (comp as any).filterService; filterService = (comp as any).filterService;
}); });
it('should generate unique IDs', () => {
expect(sequenceService.next).toHaveBeenCalled();
expect(comp.toggleId).toContain('17');
expect(comp.regionId).toContain('17');
});
describe('when the toggle method is triggered', () => { describe('when the toggle method is triggered', () => {
beforeEach(() => { beforeEach(() => {
spyOn(filterService, 'toggle'); spyOn(filterService, 'toggle');

View File

@@ -10,6 +10,7 @@ import { isNotEmpty } from '../../../empty.util';
import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchService } from '../../../../core/shared/search/search.service';
import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
import { SequenceService } from '../../../../core/shared/sequence.service';
@Component({ @Component({
selector: 'ds-search-filter', selector: 'ds-search-filter',
@@ -37,6 +38,16 @@ export class SearchFilterComponent implements OnInit {
*/ */
closed: boolean; closed: boolean;
/**
* True when the filter controls should be hidden & removed from the tablist
*/
notab: boolean;
/**
* True when the filter toggle button is focused
*/
focusBox = false;
/** /**
* Emits true when the filter is currently collapsed in the store * Emits true when the filter is currently collapsed in the store
*/ */
@@ -52,10 +63,15 @@ export class SearchFilterComponent implements OnInit {
*/ */
active$: Observable<boolean>; active$: Observable<boolean>;
private readonly sequenceId: number;
constructor( constructor(
private filterService: SearchFilterService, private filterService: SearchFilterService,
private searchService: SearchService, private searchService: SearchService,
@Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService,
private sequenceService: SequenceService,
) {
this.sequenceId = this.sequenceService.next();
} }
/** /**
@@ -112,6 +128,9 @@ export class SearchFilterComponent implements OnInit {
if (event.fromState === 'collapsed') { if (event.fromState === 'collapsed') {
this.closed = false; this.closed = false;
} }
if (event.toState === 'collapsed') {
this.notab = true;
}
} }
/** /**
@@ -122,6 +141,17 @@ export class SearchFilterComponent implements OnInit {
if (event.toState === 'collapsed') { if (event.toState === 'collapsed') {
this.closed = true; this.closed = true;
} }
if (event.fromState === 'collapsed') {
this.notab = false;
}
}
get regionId(): string {
return `search-filter-region-${this.sequenceId}`;
}
get toggleId(): string {
return `search-filter-toggle-${this.sequenceId}`;
} }
/** /**

View File

@@ -8,15 +8,18 @@
</ng-container> </ng-container>
<div class="clearfix toggle-more-filters"> <div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)" <a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more" (click)="showMore()" href="javascript:void(0);">
| translate}}</a> {{"search.filters.filter.show-more" | translate}}
</a>
<a class="float-right" *ngIf="(currentPage | async) > 1" <a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less" (click)="showFirstPageOnly()" href="javascript:void(0);">
| translate}}</a> {{"search.filters.filter.show-less" | translate}}
</a>
</div> </div>
</div> </div>
<ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)" <ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder' | translate"
[label]="'search.filters.filter.' + filterConfig.name + '.label' | translate"
[action]="currentUrl" [action]="currentUrl"
[name]="filterConfig.paramName" [name]="filterConfig.paramName"
[(ngModel)]="filter" [(ngModel)]="filter"

View File

@@ -2,25 +2,42 @@
<div class="filters py-2"> <div class="filters py-2">
<form #form="ngForm" (ngSubmit)="onSubmit()" class="add-filter row" <form #form="ngForm" (ngSubmit)="onSubmit()" class="add-filter row"
[action]="currentUrl"> [action]="currentUrl">
<div class="col-6"> <div class="col-6 form-group mb-0">
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'" <label>
class="form-control" (blur)="onSubmit()" <span class="font-weight-bold">
aria-label="Mininum value" {{'search.filters.filter.' + filterConfig.name + '.min.label' | translate}}
[placeholder]="'search.filters.filter.' + filterConfig.name + '.min.placeholder'| translate"/> </span>
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'"
class="form-control" (blur)="onSubmit()"
aria-label="Mininum value"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.min.placeholder' | translate"
/>
</label>
</div> </div>
<div class="col-6"> <div class="col-6">
<input type="text" [(ngModel)]="range[1]" [name]="filterConfig.paramName + '.max'" <label>
class="form-control" (blur)="onSubmit()" <span class="font-weight-bold">
aria-label="Maximum value" {{'search.filters.filter.' + filterConfig.name + '.max.label' | translate}}
[placeholder]="'search.filters.filter.' + filterConfig.name + '.max.placeholder'| translate"/> </span>
<input type="text" [(ngModel)]="range[1]" [name]="filterConfig.paramName + '.max'"
class="form-control" (blur)="onSubmit()"
aria-label="Maximum value"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.max.placeholder' | translate"
/>
</label>
</div> </div>
<input type="submit" class="d-none"/> <label class="d-none">
<input type="submit" class="d-none"/>
<span>{{'search.filters.search.submit' | translate}}</span>
</label>
</form> </form>
<ng-container *ngIf="shouldShowSlider()"> <ng-container *ngIf="shouldShowSlider()">
<nouislider [connect]="true" [min]="min" [max]="max" [step]="1" <nouislider [connect]="true" [min]="min" [max]="max" [step]="1"
[(ngModel)]="range" (change)="onSubmit()" ngDefaultControl></nouislider> [dsDebounce]="250" (onDebounce)="onSubmit()"
(keydown)="startKeyboardControl()" (keyup)="stopKeyboardControl()"
[(ngModel)]="range" ngDefaultControl>
</nouislider>
</ng-container> </ng-container>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload"> <ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState"> <div [@facetLoad]="animationState">

Some files were not shown because too many files have changed in this diff Show More