Merge branch 'response-cache-refactoring' into w2p-54472_Create-community-and-collection-pages

Conflicts:
	src/app/core/auth/auth-response-parsing.service.ts
	src/app/core/auth/auth.service.ts
	src/app/core/auth/models/auth-status.model.ts
	src/app/core/auth/models/normalized-auth-status.model.ts
	src/app/core/auth/server-auth.service.ts
	src/app/core/cache/builders/remote-data-build.service.ts
	src/app/core/data/collection-data.service.ts
	src/app/core/data/comcol-data.service.spec.ts
	src/app/core/data/comcol-data.service.ts
	src/app/core/data/community-data.service.ts
	src/app/core/data/data.service.spec.ts
	src/app/core/data/data.service.ts
	src/app/core/data/dspace-object-data.service.ts
	src/app/core/data/item-data.service.ts
	src/app/core/data/request.models.ts
	src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
	src/app/core/shared/hal-endpoint.service.ts
	src/app/core/shared/operators.ts
	src/app/shared/testing/auth-request-service-stub.ts
	src/app/shared/testing/auth-service-stub.ts
This commit is contained in:
Kristof De Langhe
2018-10-29 10:26:44 +01:00
324 changed files with 9452 additions and 7948 deletions

View File

@@ -1,14 +1,13 @@
import { BitstreamFormatsComponent } from './bitstream-formats.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistryService } from '../../../core/registry/registry.service';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { SharedModule } from '../../../shared/shared.module';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
@@ -53,7 +52,7 @@ describe('BitstreamFormatsComponent', () => {
extensions: null
}
];
const mockFormats = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList)));
const mockFormats = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList)));
const registryServiceStub = {
getBitstreamFormats: () => mockFormats
};

View File

@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { BitstreamFormat } from '../../../core/registry/mock-bitstream-format.model';

View File

@@ -1,6 +1,6 @@
import { MetadataRegistryComponent } from './metadata-registry.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { TranslateModule } from '@ngx-translate/core';
@@ -8,7 +8,6 @@ import { By } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { RegistryService } from '../../../core/registry/registry.service';
import { SharedModule } from '../../../shared/shared.module';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
@@ -33,7 +32,7 @@ describe('MetadataRegistryComponent', () => {
namespace: 'http://dspace.org/mockschema'
}
];
const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
const registryServiceStub = {
getMetadataSchemas: () => mockSchemas
};
@@ -68,5 +67,4 @@ describe('MetadataRegistryComponent', () => {
const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(3)')).nativeElement;
expect(mockName.textContent).toBe('mock');
});
});

View File

@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';

View File

@@ -1,16 +1,14 @@
import { MetadataSchemaComponent } from './metadata-schema.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { By } from '@angular/platform-browser';
import { MockTranslateLoader } from '../../../shared/testing/mock-translate-loader';
import { RegistryService } from '../../../core/registry/registry.service';
import { SharedModule } from '../../../shared/shared.module';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
@@ -68,15 +66,15 @@ describe('MetadataSchemaComponent', () => {
schema: mockSchemasList[1]
}
];
const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
const registryServiceStub = {
getMetadataSchemas: () => mockSchemas,
getMetadataFieldsBySchema: (schema: MetadataSchema) => Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))),
getMetadataSchemaByName: (schemaName: string) => Observable.of(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0]))
getMetadataFieldsBySchema: (schema: MetadataSchema) => observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))),
getMetadataSchemaByName: (schemaName: string) => observableOf(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0]))
};
const schemaNameParam = 'mock';
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
params: Observable.of({
params: observableOf({
schemaName: schemaNameParam
})
});

View File

@@ -1,7 +1,7 @@
import { Component, OnInit } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataField } from '../../../core/metadata/metadatafield.model';

View File

@@ -0,0 +1,11 @@
<div class="container">
<div class="browse-by-author w-100 row">
<ds-browse-by class="col-xs-12 w-100"
title="{{'browse.title' | translate:{collection: '', field: 'Author', value: (value)? '&quot;' + value + '&quot;': ''} }}"
[objects$]="(items$ !== undefined)? items$ : authors$"
[currentUrl]="currentUrl"
[paginationConfig]="paginationConfig"
[sortConfig]="sortConfig">
</ds-browse-by>
</div>
</div>

View File

@@ -0,0 +1,107 @@
import {combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
import { Component, OnInit } from '@angular/core';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list';
import { ItemDataService } from '../../core/data/item-data.service';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { ActivatedRoute } from '@angular/router';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { BrowseService } from '../../core/browse/browse.service';
import { BrowseEntry } from '../../core/shared/browse-entry.model';
import { Item } from '../../core/shared/item.model';
@Component({
selector: 'ds-browse-by-author-page',
styleUrls: ['./browse-by-author-page.component.scss'],
templateUrl: './browse-by-author-page.component.html'
})
/**
* Component for browsing (items) by author (dc.contributor.author)
*/
export class BrowseByAuthorPageComponent implements OnInit {
authors$: Observable<RemoteData<PaginatedList<BrowseEntry>>>;
items$: Observable<RemoteData<PaginatedList<Item>>>;
paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'browse-by-author-pagination',
currentPage: 1,
pageSize: 20
});
sortConfig: SortOptions = new SortOptions('dc.contributor.author', SortDirection.ASC);
subs: Subscription[] = [];
currentUrl: string;
value = '';
public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute, private browseService: BrowseService) {
}
ngOnInit(): void {
this.currentUrl = this.route.snapshot.pathFromRoot
.map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '')
.join('/');
this.updatePage({
pagination: this.paginationConfig,
sort: this.sortConfig
});
this.subs.push(
observableCombineLatest(
this.route.params,
this.route.queryParams,
(params, queryParams, ) => {
return Object.assign({}, params, queryParams);
})
.subscribe((params) => {
const page = +params.page || this.paginationConfig.currentPage;
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
const sortDirection = params.sortDirection || this.sortConfig.direction;
const sortField = params.sortField || this.sortConfig.field;
this.value = +params.value || params.value || '';
const pagination = Object.assign({},
this.paginationConfig,
{ currentPage: page, pageSize: pageSize }
);
const sort = Object.assign({},
this.sortConfig,
{ direction: sortDirection, field: sortField }
);
const searchOptions = {
pagination: pagination,
sort: sort
};
if (isNotEmpty(this.value)) {
this.updatePageWithItems(searchOptions, this.value);
} else {
this.updatePage(searchOptions);
}
}));
}
/**
* Updates the current page with searchOptions
* @param searchOptions Options to narrow down your search:
* { pagination: PaginationComponentOptions,
* sort: SortOptions }
*/
updatePage(searchOptions) {
this.authors$ = this.browseService.getBrowseEntriesFor('author', searchOptions);
this.items$ = undefined;
}
/**
* Updates the current page with searchOptions and display items linked to author
* @param searchOptions Options to narrow down your search:
* { pagination: PaginationComponentOptions,
* sort: SortOptions }
* @param author The author's name for displaying items
*/
updatePageWithItems(searchOptions, author: string) {
this.items$ = this.browseService.getBrowseItemsFor('author', author, searchOptions);
}
ngOnDestroy(): void {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -0,0 +1,11 @@
<div class="container">
<div class="browse-by-title w-100 row">
<ds-browse-by class="col-xs-12 w-100"
title="{{'browse.title' | translate:{collection: '', field: 'Title', value: ''} }}"
[objects$]="items$"
[currentUrl]="currentUrl"
[paginationConfig]="paginationConfig"
[sortConfig]="sortConfig">
</ds-browse-by>
</div>
</div>

View File

@@ -0,0 +1,92 @@
import {combineLatest as observableCombineLatest, Observable , Subscription } from 'rxjs';
import { Component, OnInit } from '@angular/core';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { PaginatedList } from '../../core/data/paginated-list';
import { ItemDataService } from '../../core/data/item-data.service';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { Item } from '../../core/shared/item.model';
import { ActivatedRoute, PRIMARY_OUTLET, UrlSegmentGroup } from '@angular/router';
import { hasValue } from '../../shared/empty.util';
import { Collection } from '../../core/shared/collection.model';
@Component({
selector: 'ds-browse-by-title-page',
styleUrls: ['./browse-by-title-page.component.scss'],
templateUrl: './browse-by-title-page.component.html'
})
/**
* Component for browsing items by title (dc.title)
*/
export class BrowseByTitlePageComponent implements OnInit {
items$: Observable<RemoteData<PaginatedList<Item>>>;
paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'browse-by-title-pagination',
currentPage: 1,
pageSize: 20
});
sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC);
subs: Subscription[] = [];
currentUrl: string;
public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute) {
}
ngOnInit(): void {
this.currentUrl = this.route.snapshot.pathFromRoot
.map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '')
.join('/');
this.updatePage({
pagination: this.paginationConfig,
sort: this.sortConfig
});
this.subs.push(
observableCombineLatest(
this.route.params,
this.route.queryParams,
(params, queryParams, ) => {
return Object.assign({}, params, queryParams);
})
.subscribe((params) => {
const page = +params.page || this.paginationConfig.currentPage;
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
const sortDirection = params.sortDirection || this.sortConfig.direction;
const sortField = params.sortField || this.sortConfig.field;
const pagination = Object.assign({},
this.paginationConfig,
{ currentPage: page, pageSize: pageSize }
);
const sort = Object.assign({},
this.sortConfig,
{ direction: sortDirection, field: sortField }
);
this.updatePage({
pagination: pagination,
sort: sort
});
}));
}
/**
* Updates the current page with searchOptions
* @param searchOptions Options to narrow down your search:
* { pagination: PaginationComponentOptions,
* sort: SortOptions }
*/
updatePage(searchOptions) {
this.items$ = this.itemDataService.findAll({
currentPage: searchOptions.pagination.currentPage,
elementsPerPage: searchOptions.pagination.pageSize,
sort: searchOptions.sort
});
}
ngOnDestroy(): void {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -0,0 +1,16 @@
import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component';
import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component';
@NgModule({
imports: [
RouterModule.forChild([
{ path: 'title', component: BrowseByTitlePageComponent },
{ path: 'author', component: BrowseByAuthorPageComponent }
])
]
})
export class BrowseByRoutingModule {
}

View File

@@ -0,0 +1,27 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component';
import { ItemDataService } from '../core/data/item-data.service';
import { SharedModule } from '../shared/shared.module';
import { BrowseByRoutingModule } from './browse-by-routing.module';
import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component';
import { BrowseService } from '../core/browse/browse.service';
@NgModule({
imports: [
BrowseByRoutingModule,
CommonModule,
SharedModule
],
declarations: [
BrowseByTitlePageComponent,
BrowseByAuthorPageComponent
],
providers: [
ItemDataService,
BrowseService
]
})
export class BrowseByModule {
}

View File

@@ -1,8 +1,6 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { Observable, Subscription } from 'rxjs';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CollectionDataService } from '../core/data/collection-data.service';
import { PaginatedList } from '../core/data/paginated-list';
@@ -17,7 +15,7 @@ import { Item } from '../core/shared/item.model';
import { fadeIn, fadeInOut } from '../shared/animations/fade';
import { hasValue, isNotEmpty } from '../shared/empty.util';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { filter, flatMap, map } from 'rxjs/operators';
import { filter, flatMap, map, tap } from 'rxjs/operators';
import { SearchService } from '../+search-page/search-service/search.service';
import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
import { toDSpaceObjectListRD } from '../core/shared/operators';
@@ -56,7 +54,9 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.collectionRD$ = this.route.data.map((data) => data.collection);
this.collectionRD$ = this.route.data.pipe(
map((data) => data.collection)
);
this.logoRD$ = this.collectionRD$.pipe(
map((rd: RemoteData<Collection>) => rd.payload),
filter((collection: Collection) => hasValue(collection)),

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Collection } from '../core/shared/collection.model';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { CollectionDataService } from '../core/data/collection-data.service';
import { RemoteData } from '../core/data/remote-data';
import { getSucceededRemoteData } from '../core/shared/operators';

View File

@@ -1,23 +1,14 @@
import { Component } from '@angular/core';
import { Community } from '../../core/shared/community.model';
import { ComColDataService } from '../../core/data/comcol-data.service';
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { CollectionDataService } from '../../core/data/collection-data.service';
import { Collection } from '../../core/shared/collection.model';
import { RouteService } from '../../shared/services/route.service';
import { Router } from '@angular/router';
import { DSOSuccessResponse, ErrorResponse } from '../../core/cache/response-cache.models';
import { Observable } from 'rxjs/Observable';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { first, flatMap, map, take } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { RemoteData } from '../../core/data/remote-data';
import { hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { HttpEvent } from '@angular/common/http';
import { getSucceededRemoteData } from '../../core/shared/operators';
import { ObjectCacheService } from '../../core/cache/object-cache.service';
import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model';
import { isNotEmpty } from '../../shared/empty.util';
@Component({
selector: 'ds-create-collection',

View File

@@ -24,9 +24,11 @@
[content]="communityPayload.copyrightText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list>
<ds-community-page-sub-collection-list
[community]="communityPayload"></ds-community-page-sub-collection-list>
</div>
</div>
<ds-error *ngIf="communityRD?.hasFailed" message="{{'error.community' | translate}}"></ds-error>
<ds-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-loading>
<ds-loading *ngIf="communityRD?.isLoading"
message="{{'loading.community' | translate}}"></ds-loading>
</div>

View File

@@ -1,7 +1,8 @@
import { mergeMap, filter, map, first, tap } from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs/Subscription';
import { Subscription, Observable } from 'rxjs';
import { CommunityDataService } from '../core/data/community-data.service';
import { RemoteData } from '../core/data/remote-data';
import { Bitstream } from '../core/shared/bitstream.model';
@@ -12,7 +13,6 @@ import { MetadataService } from '../core/metadata/metadata.service';
import { fadeInOut } from '../shared/animations/fade';
import { hasValue } from '../shared/empty.util';
import { Observable } from 'rxjs/Observable';
@Component({
selector: 'ds-community-page',
@@ -24,6 +24,8 @@ import { Observable } from 'rxjs/Observable';
export class CommunityPageComponent implements OnInit, OnDestroy {
communityRD$: Observable<RemoteData<Community>>;
logoRD$: Observable<RemoteData<Bitstream>>;
private subs: Subscription[] = [];
constructor(
@@ -35,15 +37,19 @@ export class CommunityPageComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.communityRD$ = this.route.data.map((data) => data.community);
this.logoRD$ = this.communityRD$
.map((rd: RemoteData<Community>) => rd.payload)
.filter((community: Community) => hasValue(community))
.flatMap((community: Community) => community.logo);
this.communityRD$ = this.route.data.pipe(map((data) => data.community));
this.logoRD$ = this.communityRD$.pipe(
map((rd: RemoteData<Community>) => rd.payload),
filter((community: Community) => hasValue(community)),
mergeMap((community: Community) => community.logo));
}
ngOnDestroy(): void {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { RemoteData } from '../core/data/remote-data';
import { getSucceededRemoteData } from '../core/shared/operators';
import { Community } from '../core/shared/community.model';

View File

@@ -1,8 +1,7 @@
import { Component } from '@angular/core';
import { Community } from '../../core/shared/community.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { ErrorResponse } from '../../core/cache/response-cache.models';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { RouteService } from '../../shared/services/route.service';
import { Router } from '@angular/router';
import { RemoteData } from '../../core/data/remote-data';

View File

@@ -1,5 +1,5 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { Collection } from '../../core/shared/collection.model';

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { PaginatedList } from '../../core/data/paginated-list';
@@ -17,6 +17,7 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeInOut]
})
export class TopLevelCommunityListComponent {
communitiesRDObs: Observable<RemoteData<PaginatedList<Community>>>;
config: PaginationComponentOptions;

View File

@@ -6,7 +6,7 @@ import { Collection } from '../../../core/shared/collection.model';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/mock-remote-data-build.service';
import { Item } from '../../../core/shared/item.model';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { TranslateModule } from '@ngx-translate/core';
@@ -22,8 +22,8 @@ const mockCollection1: Collection = Object.assign(new Collection(), {
}]
});
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, true, null, mockCollection1))});
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, false, null, mockCollection1))});
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: observableOf(new RemoteData(false, false, true, null, mockCollection1))});
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: observableOf(new RemoteData(false, false, false, null, mockCollection1))});
describe('CollectionsComponent', () => {
beforeEach(async(() => {

View File

@@ -1,5 +1,7 @@
import {map} from 'rxjs/operators';
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { Collection } from '../../../core/shared/collection.model';
import { Item } from '../../../core/shared/item.model';
@@ -35,11 +37,11 @@ export class CollectionsComponent implements OnInit {
// TODO: this should use parents, but the collections
// for an Item aren't returned by the REST API yet,
// only the owning collection
this.collections = this.item.owner.map((rd: RemoteData<Collection>) => [rd.payload]);
this.collections = this.item.owner.pipe(map((rd: RemoteData<Collection>) => [rd.payload]));
}
hasSucceeded() {
return this.item.owner.map((rd: RemoteData<Collection>) => rd.hasSucceeded);
return this.item.owner.pipe(map((rd: RemoteData<Collection>) => rd.hasSucceeded));
}
}

View File

@@ -1,6 +1,8 @@
<div class="simple-view-element">
<span *ngIf="content.children.length != 0">
<h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5>
<div class="simple-view-element-body">
<ng-content></ng-content>
</div>
</span>
<div #content class="simple-view-element-body">
<ng-content></ng-content>
</div>
</div>

View File

@@ -0,0 +1,54 @@
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Component, DebugElement } from '@angular/core';
import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component';
@Component({
selector: 'ds-component-with-content',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
' <div class="my content">\n' +
' </div>\n' +
'</ds-metadata-field-wrapper>'
})
class ContentComponent {}
describe('MetadataFieldWrapperComponent', () => {
let component: MetadataFieldWrapperComponent;
let fixture: ComponentFixture<MetadataFieldWrapperComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MetadataFieldWrapperComponent, ContentComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MetadataFieldWrapperComponent);
component = fixture.componentInstance;
});
const wrapperSelector = '.simple-view-element';
const labelSelector = '.simple-view-element-header';
it('should create', () => {
expect(component).toBeDefined();
});
it('should not show a label when there is no content', () => {
component.label = 'test label';
fixture.detectChanges();
const debugLabel = fixture.debugElement.query(By.css(labelSelector));
expect(debugLabel).toBeNull();
});
it('should show a label when there is content', () => {
const parentFixture = TestBed.createComponent(ContentComponent);
parentFixture.detectChanges();
const parentComponent = parentFixture.componentInstance;
const parentNative = parentFixture.nativeElement;
const nativeLabel = parentNative.querySelector(labelSelector);
expect(nativeLabel.textContent).toContain('test label');
});
});

View File

@@ -1,4 +1,5 @@
import { Component, Input } from '@angular/core';
import { Metadatum } from '../../../core/shared/metadatum.model';
/**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.
@@ -11,7 +12,7 @@ import { Component, Input } from '@angular/core';
})
export class MetadataValuesComponent {
@Input() values: any;
@Input() values: Metadatum[];
@Input() separator: string;

View File

@@ -1,10 +1,10 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { Item } from '../../../../core/shared/item.model';
import { FileSectionComponent } from '../../../simple/field-components/file-section/file-section.component';
import { hasValue } from '../../../../shared/empty.util';
import { map } from 'rxjs/operators';
/**
* This component renders the file section of the item
@@ -33,7 +33,7 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
initialize(): void {
const originals = this.item.getFiles();
const licenses = this.item.getBitstreamsByBundleName('LICENSE');
this.bitstreamsObs = Observable.combineLatest(originals, licenses, (o, l) => [...o, ...l]);
this.bitstreamsObs = observableCombineLatest(originals, licenses).pipe(map(([o, l]) => [...o, ...l]));
this.bitstreamsObs.subscribe(
(files) =>
files.forEach(

View File

@@ -1,7 +1,9 @@
import {filter, map} from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { ItemPageComponent } from '../simple/item-page.component';
import { Metadatum } from '../../core/shared/metadatum.model';
@@ -41,9 +43,9 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit {
/*** AoT inheritance fix, will hopefully be resolved in the near future **/
ngOnInit(): void {
super.ngOnInit();
this.metadata$ = this.itemRD$
.map((rd: RemoteData<Item>) => rd.payload)
.filter((item: Item) => hasValue(item))
.map((item: Item) => item.metadata);
this.metadata$ = this.itemRD$.pipe(
map((rd: RemoteData<Item>) => rd.payload),
filter((item: Item) => hasValue(item)),
map((item: Item) => item.metadata),);
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { RemoteData } from '../core/data/remote-data';
import { getSucceededRemoteData } from '../core/shared/operators';
import { ItemDataService } from '../core/data/item-data.service';

View File

@@ -1,5 +1,5 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { Item } from '../../../../core/shared/item.model';

View File

@@ -1,7 +1,9 @@
import {mergeMap, filter, map} from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { ItemDataService } from '../../core/data/item-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { Bitstream } from '../../core/shared/bitstream.model';
@@ -44,11 +46,11 @@ export class ItemPageComponent implements OnInit {
}
ngOnInit(): void {
this.itemRD$ = this.route.data.map((data) => data.item);
this.itemRD$ = this.route.data.pipe(map((data) => data.item));
this.metadataService.processRemoteData(this.itemRD$);
this.thumbnail$ = this.itemRD$
.map((rd: RemoteData<Item>) => rd.payload)
.filter((item: Item) => hasValue(item))
.flatMap((item: Item) => item.getThumbnail());
this.thumbnail$ = this.itemRD$.pipe(
map((rd: RemoteData<Item>) => rd.payload),
filter((item: Item) => hasValue(item)),
mergeMap((item: Item) => item.getThumbnail()),);
}
}

View File

@@ -3,8 +3,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { of as observableOf } from 'rxjs';
import { LoginPageComponent } from './login-page.component';
@@ -16,7 +15,7 @@ describe('LoginPageComponent', () => {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
select: observableOf(true)
});
beforeEach(async(() => {

View File

@@ -1,4 +1,3 @@
import 'rxjs/add/observable/of';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { PaginatedSearchOptions } from './paginated-search-options.model';

View File

@@ -7,7 +7,7 @@ import { SearchFilterConfig } from '../../../search-service/search-filter-config
import { FilterType } from '../../../search-service/filter-type.model';
import { FacetValue } from '../../../search-service/facet-value.model';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs';
import { SearchService } from '../../../search-service/search.service';
import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
import { RemoteData } from '../../../../core/data/remote-data';
@@ -54,9 +54,9 @@ describe('SearchFacetFilterComponent', () => {
let filterService;
let searchService;
let router;
const page = Observable.of(0);
const page = observableOf(0);
const mockValues = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values)));
const mockValues = observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values)));
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
@@ -65,11 +65,11 @@ describe('SearchFacetFilterComponent', () => {
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{ provide: FILTER_CONFIG, useValue: new SearchFilterConfig() },
{ provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
{ provide: SearchConfigurationService, useValue: {searchOptions: Observable.of({})} },
{ provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} },
{ provide: SearchConfigurationService, useValue: {searchOptions: observableOf({})} },
{
provide: SearchFilterService, useValue: {
getSelectedValuesForFilter: () => Observable.of(selectedValues),
getSelectedValuesForFilter: () => observableOf(selectedValues),
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */

View File

@@ -1,23 +1,26 @@
import {
combineLatest as observableCombineLatest,
of as observableOf,
BehaviorSubject,
Observable,
Subject,
Subscription
} from 'rxjs';
import { switchMap, distinctUntilChanged, first, map } from 'rxjs/operators';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { Subscription } from 'rxjs/Subscription';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe';
import { SearchOptions } from '../../../search-options.model';
import { FacetValue } from '../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { SearchService } from '../../../search-service/search.service';
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { map } from 'rxjs/operators';
@Component({
selector: 'ds-search-facet-filter',
@@ -56,7 +59,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
/**
* Emits the result values for this filter found by the current filter query
*/
filterSearchResults: Observable<any[]> = Observable.of([]);
filterSearchResults: Observable<any[]> = observableOf([]);
/**
* Emits the active values for this filter
@@ -82,25 +85,28 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
*/
ngOnInit(): void {
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
this.currentPage = this.getCurrentPage().distinctUntilChanged();
this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged());
this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig);
const searchOptions = this.searchConfigService.searchOptions;
this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList()));
const facetValues = Observable.combineLatest(searchOptions, this.currentPage, (options, page) => {
return { options, page }
}).switchMap(({ options, page }) => {
return this.searchService.getFacetValuesFor(this.filterConfig, page, options)
.pipe(
getSucceededRemoteData(),
map((results) => {
return {
values: Observable.of(results),
page: page
};
}
const facetValues = observableCombineLatest(searchOptions, this.currentPage).pipe(
map(([options, page]) => {
return { options, page }
}),
switchMap(({ options, page }) => {
return this.searchService.getFacetValuesFor(this.filterConfig, page, options)
.pipe(
getSucceededRemoteData(),
map((results) => {
return {
values: observableOf(results),
page: page
};
}
)
)
)
});
})
);
let filterValues = [];
this.subs.push(facetValues.subscribe((facetOutcome) => {
const newValues$ = facetOutcome.values;
@@ -120,7 +126,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
this.animationState = 'ready';
this.filterValues$.next(rd);
}));
this.subs.push(newValues$.first().subscribe((rd) => {
this.subs.push(newValues$.pipe(first()).subscribe((rd) => {
this.isLastPage$.next(hasNoValue(rd.payload.next))
}));
}));
@@ -183,7 +189,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
* @param data The string from the input field
*/
onSubmit(data: any) {
this.selectedValues.first().subscribe((selectedValues) => {
this.selectedValues.pipe(first()).subscribe((selectedValues) => {
if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], {
queryParams:
@@ -192,7 +198,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
});
this.filter = '';
}
this.filterSearchResults = Observable.of([]);
this.filterSearchResults = observableOf([]);
}
)
}
@@ -214,12 +220,12 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
* @returns {Observable<any>} The changed filter parameters
*/
getRemoveParams(value: string): Observable<any> {
return this.selectedValues.map((selectedValues) => {
return this.selectedValues.pipe(map((selectedValues) => {
return {
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== value),
page: 1
};
});
}));
}
/**
@@ -228,12 +234,12 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
* @returns {Observable<any>} The changed filter parameters
*/
getAddParams(value: string): Observable<any> {
return this.selectedValues.map((selectedValues) => {
return this.selectedValues.pipe(map((selectedValues) => {
return {
[this.filterConfig.paramName]: [...selectedValues, value],
page: 1
};
});
}));
}
/**
@@ -252,7 +258,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
*/
findSuggestions(data): void {
if (isNotEmpty(data)) {
this.searchConfigService.searchOptions.first().subscribe(
this.searchConfigService.searchOptions.pipe(first()).subscribe(
(options) => {
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
.pipe(
@@ -267,7 +273,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
}
)
} else {
this.filterSearchResults = Observable.of([]);
this.filterSearchResults = observableOf([]);
}
}

View File

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs/Observable';
import { Observable, of as observableOf } from 'rxjs';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFilterService } from './search-filter.service';
import { SearchService } from '../../search-service/search.service';
@@ -38,19 +38,19 @@ describe('SearchFilterComponent', () => {
initialExpand: (filter) => {
},
getSelectedValuesForFilter: (filter) => {
return Observable.of([filterName1, filterName2, filterName3])
return observableOf([filterName1, filterName2, filterName3])
},
isFilterActive: (filter) => {
return Observable.of([filterName1, filterName2, filterName3].indexOf(filter) >= 0);
return observableOf([filterName1, filterName2, filterName3].indexOf(filter) >= 0);
},
isCollapsed: (filter) => {
return Observable.of(true)
return observableOf(true)
}
/* tslint:enable:no-empty */
};
let filterService;
const mockResults = Observable.of(['test', 'data']);
const mockResults = observableOf(['test', 'data']);
const searchServiceStub = {
getFacetValuesFor: (filter) => mockResults
};
@@ -140,7 +140,7 @@ describe('SearchFilterComponent', () => {
describe('when isCollapsed is called and the filter is collapsed', () => {
let isActive: Observable<boolean>;
beforeEach(() => {
filterService.isCollapsed = () => Observable.of(true);
filterService.isCollapsed = () => observableOf(true);
isActive = comp.isCollapsed();
});
@@ -155,7 +155,7 @@ describe('SearchFilterComponent', () => {
describe('when isCollapsed is called and the filter is not collapsed', () => {
let isActive: Observable<boolean>;
beforeEach(() => {
filterService.isCollapsed = () => Observable.of(false);
filterService.isCollapsed = () => observableOf(false);
isActive = comp.isCollapsed();
});

View File

@@ -1,7 +1,9 @@
import {first} from 'rxjs/operators';
import { Component, Input, OnInit } from '@angular/core';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { SearchFilterService } from './search-filter.service';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { slide } from '../../../shared/animations/slide';
import { isNotEmpty } from '../../../shared/empty.util';
@@ -35,7 +37,7 @@ export class SearchFilterComponent implements OnInit {
* Else, the filter should initially be collapsed
*/
ngOnInit() {
this.getSelectedValues().first().subscribe((isActive) => {
this.getSelectedValues().pipe(first()).subscribe((isActive) => {
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
this.initialExpand();
} else {

View File

@@ -1,16 +1,20 @@
import { Observable } from 'rxjs/Observable';
import { SearchFilterService } from './search-filter.service';
import { Store } from '@ngrx/store';
import {
SearchFilterCollapseAction, SearchFilterDecrementPageAction, SearchFilterExpandAction,
SearchFilterCollapseAction,
SearchFilterDecrementPageAction,
SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction, SearchFilterInitialExpandAction, SearchFilterResetPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
import { SearchFiltersState } from './search-filter.reducer';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { FilterType } from '../../search-service/filter-type.model';
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
import { of as observableOf } from 'rxjs';
describe('SearchFilterService', () => {
let service: SearchFilterService;
@@ -28,7 +32,7 @@ describe('SearchFilterService', () => {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
select: observableOf(true)
});
const routeServiceStub: any = {
@@ -42,10 +46,10 @@ describe('SearchFilterService', () => {
addQueryParameterValue: (param: string, value: string) => {
},
getQueryParameterValues: (param: string) => {
return Observable.of({});
return observableOf({});
},
getQueryParamsWithPrefix: (param: string) => {
return Observable.of({});
return observableOf({});
}
/* tslint:enable:no-empty */
};

View File

@@ -1,8 +1,8 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Injectable, InjectionToken } from '@angular/core';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import {
SearchFilterCollapseAction,
SearchFilterDecrementPageAction,
@@ -13,14 +13,10 @@ import {
SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util';
import { hasValue, isNotEmpty, } from '../../../shared/empty.util';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { RouteService } from '../../../shared/services/route.service';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchOptions } from '../../search-options.model';
import { PaginatedSearchOptions } from '../../paginated-search-options.model';
import { ActivatedRoute, Params } from '@angular/router';
import { Params } from '@angular/router';
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
@@ -63,13 +59,19 @@ export class SearchFilterService {
*/
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable<string[]> {
const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName);
const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').map((params: Params) => [].concat(...Object.values(params)));
return Observable.combineLatest(values$, prefixValues$, (values, prefixValues) => {
if (isNotEmpty(values)) {
return values;
}
return prefixValues;
})
const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').pipe(
map((params: Params) => [].concat(...Object.values(params)))
);
return observableCombineLatest(values$, prefixValues$).pipe(
map(([values, prefixValues]) => {
if (isNotEmpty(values)) {
return values;
}
return prefixValues;
}
)
)
}
/**
@@ -78,14 +80,16 @@ export class SearchFilterService {
* @returns {Observable<boolean>} Emits the current collapsed state of the given filter, if it's unavailable, return false
*/
isCollapsed(filterName: string): Observable<boolean> {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => {
return this.store.pipe(
select(filterByNameSelector(filterName)),
map((object: SearchFilterState) => {
if (object) {
return object.filterCollapsed;
} else {
return false;
}
});
})
);
}
/**
@@ -94,14 +98,15 @@ export class SearchFilterService {
* @returns {Observable<boolean>} Emits the current page state of the given filter, if it's unavailable, return 1
*/
getPage(filterName: string): Observable<number> {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => {
return this.store.pipe(
select(filterByNameSelector(filterName)),
map((object: SearchFilterState) => {
if (object) {
return object.page;
} else {
return 1;
}
});
}));
}
/**
@@ -159,6 +164,7 @@ export class SearchFilterService {
public incrementPage(filterName: string): void {
this.store.dispatch(new SearchFilterIncrementPageAction(filterName));
}
/**
* Dispatches a reset page action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched

View File

@@ -7,7 +7,7 @@ import { SearchFilterConfig } from '../../../search-service/search-filter-config
import { FilterType } from '../../../search-service/filter-type.model';
import { FacetValue } from '../../../search-service/facet-value.model';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs'
import { SearchService } from '../../../search-service/search.service';
import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
import { RemoteData } from '../../../../core/data/remote-data';
@@ -56,13 +56,13 @@ describe('SearchRangeFilterComponent', () => {
];
const searchLink = '/search';
const selectedValues = Observable.of([value1]);
const selectedValues = observableOf([value1]);
let filterService;
let searchService;
let router;
const page = Observable.of(0);
const page = observableOf(0);
const mockValues = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values)));
const mockValues = observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values)));
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
@@ -71,10 +71,10 @@ describe('SearchRangeFilterComponent', () => {
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{ provide: FILTER_CONFIG, useValue: mockFilterConfig },
{ provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
{ provide: RouteService, useValue: {getQueryParameterValue: () => Observable.of({})} },
{ provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} },
{ provide: RouteService, useValue: {getQueryParameterValue: () => observableOf({})} },
{ provide: SearchConfigurationService, useValue: {
searchOptions: Observable.of({}) }
searchOptions: observableOf({}) }
},
{
provide: SearchFilterService, useValue: {

View File

@@ -1,3 +1,10 @@
import {
of as observableOf,
combineLatest as observableCombineLatest,
Observable,
Subscription
} from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { isPlatformBrowser } from '@angular/common';
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
@@ -12,10 +19,8 @@ import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchService } from '../../../search-service/search.service';
import { Router } from '@angular/router';
import * as moment from 'moment';
import { Observable } from 'rxjs/Observable';
import { RouteService } from '../../../../shared/services/route.service';
import { hasValue } from '../../../../shared/empty.util';
import { Subscription } from 'rxjs/Subscription';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
/**
@@ -80,13 +85,15 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
super.ngOnInit();
this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min;
this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max;
const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).startWith(undefined);
const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).startWith(undefined);
this.sub = Observable.combineLatest(iniMin, iniMax, (min, max) => {
const minimum = hasValue(min) ? min : this.min;
const maximum = hasValue(max) ? max : this.max;
return [minimum, maximum]
}).subscribe((minmax) => this.range = minmax);
const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).pipe(startWith(undefined));
const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).pipe(startWith(undefined));
this.sub = observableCombineLatest(iniMin, iniMax).pipe(
map(([min, max]) => {
const minimum = hasValue(min) ? min : this.min;
const maximum = hasValue(max) ? max : this.max;
return [minimum, maximum]
})
).subscribe((minmax) => this.range = minmax);
}
/**
@@ -98,7 +105,7 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
const parts = value.split(rangeDelimiter);
const min = parts.length > 1 ? parts[0].trim() : value;
const max = parts.length > 1 ? parts[1].trim() : value;
return Observable.of(
return observableOf(
{
[this.filterConfig.paramName + minSuffix]: [min],
[this.filterConfig.paramName + maxSuffix]: [max],

View File

@@ -1,6 +1,4 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Component, HostBinding, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Component, OnInit } from '@angular/core';
import { FilterType } from '../../../search-service/filter-type.model';
import {
facetLoad,

View File

@@ -7,8 +7,8 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFilterService } from './search-filter/search-filter.service';
import { SearchFiltersComponent } from './search-filters.component';
import { SearchService } from '../search-service/search.service';
import { Observable } from 'rxjs/Observable';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
import { of as observableOf } from 'rxjs';
describe('SearchFiltersComponent', () => {
let comp: SearchFiltersComponent;
@@ -17,7 +17,7 @@ describe('SearchFiltersComponent', () => {
const searchServiceStub = {
/* tslint:disable:no-empty */
getConfig: () =>
Observable.of({ hasSucceeded: true, payload: [] }),
observableOf({ hasSucceeded: true, payload: [] }),
getClearFiltersQueryParams: () => {
},
getSearchLink: () => {
@@ -31,7 +31,7 @@ describe('SearchFiltersComponent', () => {
};
const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', {
getCurrentFrontendFilters: Observable.of({})
getCurrentFrontendFilters: observableOf({})
});
beforeEach(async(() => {

View File

@@ -1,8 +1,10 @@
import { Observable, of as observableOf } from 'rxjs';
import { filter, map, mergeMap, startWith, switchMap } from 'rxjs/operators';
import { Component } from '@angular/core';
import { SearchService } from '../search-service/search.service';
import { RemoteData } from '../../core/data/remote-data';
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
import { Observable } from 'rxjs/Observable';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
import { isNotEmpty } from '../../shared/empty.util';
import { SearchFilterService } from './search-filter/search-filter.service';
@@ -37,10 +39,10 @@ export class SearchFiltersComponent {
*/
constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService, private filterService: SearchFilterService) {
this.filters = searchService.getConfig().pipe(getSucceededRemoteData());
this.clearParams = searchConfigService.getCurrentFrontendFilters().map((filters) => {
this.clearParams = searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => {
Object.keys(filters).forEach((f) => filters[f] = null);
return filters;
});
}));
}
/**
@@ -55,23 +57,22 @@ export class SearchFiltersComponent {
* @param {SearchFilterConfig} filter The filter to check for
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
*/
isActive(filter: SearchFilterConfig): Observable<boolean> {
// console.log(filter.name);
return this.filterService.getSelectedValuesForFilter(filter)
.flatMap((isActive) => {
isActive(filterConfig: SearchFilterConfig): Observable<boolean> {
return this.filterService.getSelectedValuesForFilter(filterConfig).pipe(
mergeMap((isActive) => {
if (isNotEmpty(isActive)) {
return Observable.of(true);
return observableOf(true);
} else {
return this.searchConfigService.searchOptions
.switchMap((options) => {
return this.searchService.getFacetValuesFor(filter, 1, options)
.filter((RD) => !RD.isLoading)
.map((valuesRD) => {
return this.searchConfigService.searchOptions.pipe(
switchMap((options) => {
return this.searchService.getFacetValuesFor(filterConfig, 1, options).pipe(
filter((RD) => !RD.isLoading),
map((valuesRD) => {
return valuesRD.payload.totalElements > 0
})
}),)
}
)
))
}
}).startWith(true);
}),startWith(true),);
}
}

View File

@@ -6,7 +6,7 @@ import { SearchService } from '../search-service/search.service';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SearchServiceStub } from '../../shared/testing/search-service-stub';
import { Observable } from 'rxjs/Observable';
import { Observable, of as observableOf } from 'rxjs';
import { Params } from '@angular/router';
import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
@@ -35,7 +35,7 @@ describe('SearchLabelsComponent', () => {
declarations: [SearchLabelsComponent, ObjectKeysPipe],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => Observable.of({})} }
{ provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchLabelsComponent, {
@@ -47,7 +47,7 @@ describe('SearchLabelsComponent', () => {
fixture = TestBed.createComponent(SearchLabelsComponent);
comp = fixture.componentInstance;
searchService = (comp as any).searchService;
(comp as any).appliedFilters = Observable.of(mockFilters);
(comp as any).appliedFilters = observableOf(mockFilters);
fixture.detectChanges();
});

View File

@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { SearchService } from '../search-service/search.service';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { Params } from '@angular/router';
import { map } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../shared/empty.util';

View File

@@ -1,4 +1,3 @@
import 'rxjs/add/observable/of';
import { PaginatedSearchOptions } from './paginated-search-options.model';
import { SearchOptions } from './search-options.model';
import { SearchFilter } from './search-filter.model';

View File

@@ -5,8 +5,7 @@ import { RouterTestingModule } from '@angular/router/testing';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { cold, hot } from 'jasmine-marbles';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { of as observableOf } from 'rxjs';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CommunityDataService } from '../core/data/community-data.service';
import { HostWindowService } from '../shared/host-window.service';
@@ -30,18 +29,18 @@ describe('SearchPageComponent', () => {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
select: observableOf(true)
});
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
pagination.id = 'search-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
const mockResults = Observable.of(new RemoteData(false, false, true, null, ['test', 'data']));
const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data']));
const searchServiceStub = jasmine.createSpyObj('SearchService', {
search: mockResults,
getSearchLink: '/search',
getScopes: Observable.of(['test-scope'])
getScopes: observableOf(['test-scope'])
});
const queryParam = 'test query';
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
@@ -52,15 +51,15 @@ describe('SearchPageComponent', () => {
sort
};
const activatedRouteStub = {
queryParams: Observable.of({
queryParams: observableOf({
query: queryParam,
scope: scopeParam
})
};
const sidebarService = {
isCollapsed: Observable.of(true),
collapse: () => this.isCollapsed = Observable.of(true),
expand: () => this.isCollapsed = Observable.of(false)
isCollapsed: observableOf(true),
collapse: () => this.isCollapsed = observableOf(true),
expand: () => this.isCollapsed = observableOf(false)
};
beforeEach(async(() => {
@@ -80,9 +79,9 @@ describe('SearchPageComponent', () => {
{
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
{
isXs: Observable.of(true),
isSm: Observable.of(false),
isXsOrSm: Observable.of(true)
isXs: observableOf(true),
isSm: observableOf(false),
isXsOrSm: observableOf(true)
})
},
{
@@ -98,7 +97,7 @@ describe('SearchPageComponent', () => {
paginatedSearchOptions: hot('a', {
a: paginatedSearchOptions
}),
getCurrentScope: (a) => Observable.of('test-id')
getCurrentScope: (a) => observableOf('test-id')
}
},
],
@@ -154,7 +153,7 @@ describe('SearchPageComponent', () => {
beforeEach(() => {
menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
comp.isSidebarCollapsed = () => Observable.of(true);
comp.isSidebarCollapsed = () => observableOf(true);
fixture.detectChanges();
});
@@ -169,7 +168,7 @@ describe('SearchPageComponent', () => {
beforeEach(() => {
menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
comp.isSidebarCollapsed = () => Observable.of(false);
comp.isSidebarCollapsed = () => observableOf(false);
fixture.detectChanges();
});

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { flatMap, switchMap, } from 'rxjs/operators';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { switchMap, } from 'rxjs/operators';
import { PaginatedList } from '../core/data/paginated-list';
import { RemoteData } from '../core/data/remote-data';
import { DSpaceObject } from '../core/shared/dspace-object.model';
@@ -11,9 +11,7 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
import { SearchResult } from './search-result.model';
import { SearchService } from './search-service/search.service';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { Subscription } from 'rxjs/Subscription';
import { hasValue } from '../shared/empty.util';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { SearchConfigurationService } from './search-service/search-configuration.service';
import { getSucceededRemoteData } from '../core/shared/operators';
@@ -78,8 +76,8 @@ export class SearchPageComponent implements OnInit {
*/
ngOnInit(): void {
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
this.sub = this.searchOptions$
.switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))
this.sub = this.searchOptions$.pipe(
switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData())))
.subscribe((results) => {
this.resultsRD$.next(results);
});

View File

@@ -3,8 +3,8 @@ import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { Observable } from 'rxjs/Observable';
import { SearchFilter } from '../search-filter.model';
import { of as observableOf } from 'rxjs';
describe('SearchConfigurationService', () => {
let service: SearchConfigurationService;
@@ -24,8 +24,8 @@ describe('SearchConfigurationService', () => {
const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])];
const spy = jasmine.createSpyObj('RouteService', {
getQueryParameterValue: Observable.of(value1),
getQueryParamsWithPrefix: Observable.of(prefixFilter)
getQueryParameterValue: observableOf(value1),
getQueryParamsWithPrefix: observableOf(prefixFilter)
});
const activatedRoute: any = new ActivatedRouteStub();

View File

@@ -1,15 +1,21 @@
import {
BehaviorSubject,
combineLatest as observableCombineLatest,
merge as observableMerge,
Observable,
of as observableOf,
Subscription
} from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SearchOptions } from '../search-options.model';
import { Observable } from 'rxjs/Observable';
import { ActivatedRoute, Params } from '@angular/router';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { Injectable, OnDestroy } from '@angular/core';
import { RouteService } from '../../shared/services/route.service';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { RemoteData } from '../../core/data/remote-data';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Subscription } from 'rxjs/Subscription';
import { getSucceededRemoteData } from '../../core/shared/operators';
import { SearchFilter } from '../search-filter.model';
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
@@ -87,27 +93,27 @@ export class SearchConfigurationService implements OnDestroy {
* @returns {Observable<string>} Emits the current scope's identifier
*/
getCurrentScope(defaultScope: string) {
return this.routeService.getQueryParameterValue('scope').map((scope) => {
return this.routeService.getQueryParameterValue('scope').pipe(map((scope) => {
return scope || defaultScope;
});
}));
}
/**
* @returns {Observable<string>} Emits the current query string
*/
getCurrentQuery(defaultQuery: string) {
return this.routeService.getQueryParameterValue('query').map((query) => {
return this.routeService.getQueryParameterValue('query').pipe(map((query) => {
return query || defaultQuery;
});
}));
}
/**
* @returns {Observable<number>} Emits the current DSpaceObject type as a number
*/
getCurrentDSOType(): Observable<DSpaceObjectType> {
return this.routeService.getQueryParameterValue('dsoType')
.filter((type) => hasValue(type) && hasValue(DSpaceObjectType[type.toUpperCase()]))
.map((type) => DSpaceObjectType[type.toUpperCase()]);
return this.routeService.getQueryParameterValue('dsoType').pipe(
filter((type) => hasValue(type) && hasValue(DSpaceObjectType[type.toUpperCase()])),
map((type) => DSpaceObjectType[type.toUpperCase()]),);
}
/**
@@ -116,12 +122,13 @@ export class SearchConfigurationService implements OnDestroy {
getCurrentPagination(defaultPagination: PaginationComponentOptions): Observable<PaginationComponentOptions> {
const page$ = this.routeService.getQueryParameterValue('page');
const size$ = this.routeService.getQueryParameterValue('pageSize');
return Observable.combineLatest(page$, size$, (page, size) => {
return Object.assign(new PaginationComponentOptions(), defaultPagination, {
currentPage: page || defaultPagination.currentPage,
pageSize: size || defaultPagination.pageSize
});
});
return observableCombineLatest(page$, size$).pipe(map(([page, size]) => {
return Object.assign(new PaginationComponentOptions(), defaultPagination, {
currentPage: page || defaultPagination.currentPage,
pageSize: size || defaultPagination.pageSize
});
})
);
}
/**
@@ -130,7 +137,7 @@ export class SearchConfigurationService implements OnDestroy {
getCurrentSort(defaultSort: SortOptions): Observable<SortOptions> {
const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection');
const sortField$ = this.routeService.getQueryParameterValue('sortField');
return Observable.combineLatest(sortDirection$, sortField$, (sortDirection, sortField) => {
return observableCombineLatest(sortDirection$, sortField$).pipe(map(([sortDirection, sortField]) => {
// Dirty fix because sometimes the observable value is null somehow
sortField = this.route.snapshot.queryParamMap.get('sortField');
@@ -138,20 +145,21 @@ export class SearchConfigurationService implements OnDestroy {
const direction = SortDirection[sortDirection] || defaultSort.direction;
return new SortOptions(field, direction)
}
)
)
);
}
/**
* @returns {Observable<Params>} Emits the current active filters with their values as they are sent to the backend
*/
getCurrentFilters(): Observable<SearchFilter[]> {
return this.routeService.getQueryParamsWithPrefix('f.').map((filterParams) => {
return this.routeService.getQueryParamsWithPrefix('f.').pipe(map((filterParams) => {
if (isNotEmpty(filterParams)) {
const filters = [];
Object.keys(filterParams).forEach((key) => {
if (key.endsWith('.min') || key.endsWith('.max')) {
const realKey = key.slice(0, -4);
if (hasNoValue(filters.find((filter) => filter.key === realKey))) {
if (hasNoValue(filters.find((f) => f.key === realKey))) {
const min = filterParams[realKey + '.min'] ? filterParams[realKey + '.min'][0] : '*';
const max = filterParams[realKey + '.max'] ? filterParams[realKey + '.max'][0] : '*';
filters.push(new SearchFilter(realKey, ['[' + min + ' TO ' + max + ']']));
@@ -163,7 +171,7 @@ export class SearchConfigurationService implements OnDestroy {
return filters;
}
return [];
});
}));
}
/**
@@ -179,7 +187,7 @@ export class SearchConfigurationService implements OnDestroy {
* @returns {Subscription} The subscription to unsubscribe from
*/
subscribeToSearchOptions(defaults: SearchOptions): Subscription {
return Observable.merge(
return observableMerge(
this.getScopePart(defaults.scope),
this.getQueryPart(defaults.query),
this.getDSOTypePart(),
@@ -197,7 +205,7 @@ export class SearchConfigurationService implements OnDestroy {
* @returns {Subscription} The subscription to unsubscribe from
*/
subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription {
return Observable.merge(
return observableMerge(
this.getPaginationPart(defaults.pagination),
this.getSortPart(defaults.sort),
this.getScopePart(defaults.scope),
@@ -222,7 +230,7 @@ export class SearchConfigurationService implements OnDestroy {
scope: this.defaultScope,
query: this.defaultQuery
});
this._defaults = Observable.of(new RemoteData(false, false, true, null, options));
this._defaults = observableOf(new RemoteData(false, false, true, null, options));
}
return this._defaults;
}
@@ -240,53 +248,53 @@ export class SearchConfigurationService implements OnDestroy {
* @returns {Observable<string>} Emits the current scope's identifier
*/
private getScopePart(defaultScope: string): Observable<any> {
return this.getCurrentScope(defaultScope).map((scope) => {
return this.getCurrentScope(defaultScope).pipe(map((scope) => {
return { scope }
});
}));
}
/**
* @returns {Observable<string>} Emits the current query string as a partial SearchOptions object
*/
private getQueryPart(defaultQuery: string): Observable<any> {
return this.getCurrentQuery(defaultQuery).map((query) => {
return this.getCurrentQuery(defaultQuery).pipe(map((query) => {
return { query }
});
}));
}
/**
* @returns {Observable<string>} Emits the current query string as a partial SearchOptions object
*/
private getDSOTypePart(): Observable<any> {
return this.getCurrentDSOType().map((dsoType) => {
return this.getCurrentDSOType().pipe(map((dsoType) => {
return { dsoType }
});
}));
}
/**
* @returns {Observable<string>} Emits the current pagination settings as a partial SearchOptions object
*/
private getPaginationPart(defaultPagination: PaginationComponentOptions): Observable<any> {
return this.getCurrentPagination(defaultPagination).map((pagination) => {
return this.getCurrentPagination(defaultPagination).pipe(map((pagination) => {
return { pagination }
});
}));
}
/**
* @returns {Observable<string>} Emits the current sorting settings as a partial SearchOptions object
*/
private getSortPart(defaultSort: SortOptions): Observable<any> {
return this.getCurrentSort(defaultSort).map((sort) => {
return this.getCurrentSort(defaultSort).pipe(map((sort) => {
return { sort }
});
}));
}
/**
* @returns {Observable<Params>} Emits the current active filters as a partial SearchOptions object
*/
private getFiltersPart(): Observable<any> {
return this.getCurrentFilters().map((filters) => {
return this.getCurrentFilters().pipe(map((filters) => {
return { filters }
});
}));
}
}

View File

@@ -8,26 +8,25 @@ import { SearchService } from './search.service';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import { ActivatedRoute, Router, UrlTree } from '@angular/router';
import { RequestService } from '../../core/data/request.service';
import { ResponseCacheService } from '../../core/cache/response-cache.service';
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { RouterStub } from '../../shared/testing/router-stub';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { Observable } from 'rxjs/Observable';
import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { RemoteData } from '../../core/data/remote-data';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { RequestEntry } from '../../core/data/request.reducer';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import {
FacetConfigSuccessResponse,
SearchSuccessResponse
} from '../../core/cache/response-cache.models';
} from '../../core/cache/response.models';
import { SearchQueryResponse } from './search-query-response.model';
import { SearchFilterConfig } from './search-filter-config.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { ViewMode } from '../../core/shared/view-mode.model';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { of as observableOf } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({ template: '' })
class DummyComponent {
@@ -52,12 +51,11 @@ describe('SearchService', () => {
providers: [
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: route },
{ provide: ResponseCacheService, useValue: getMockResponseCacheService() },
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
{ provide: CommunityDataService, useValue: {}},
{ provide: DSpaceObjectDataService, useValue: {}},
{ provide: CommunityDataService, useValue: {} },
{ provide: DSpaceObjectDataService, useValue: {} },
SearchService
],
});
@@ -84,14 +82,15 @@ describe('SearchService', () => {
};
const remoteDataBuildService = {
toRemoteDataObservable: (requestEntryObs: Observable<RequestEntry>, responseCacheObs: Observable<ResponseCacheEntry>, payloadObs: Observable<any>) => {
return Observable.combineLatest(requestEntryObs,
responseCacheObs, payloadObs, (req, res, pay) => {
return { req, res, pay };
});
toRemoteDataObservable: (requestEntryObs: Observable<RequestEntry>, payloadObs: Observable<any>) => {
return observableCombineLatest(requestEntryObs, payloadObs).pipe(
map(([req, pay]) => {
return { req, pay };
})
);
},
aggregate: (input: Array<Observable<RemoteData<any>>>): Observable<RemoteData<any[]>> => {
return Observable.of(new RemoteData(false, false, true, null, []));
return observableOf(new RemoteData(false, false, true, null, []));
}
};
@@ -109,12 +108,11 @@ describe('SearchService', () => {
providers: [
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: route },
{ provide: ResponseCacheService, useValue: getMockResponseCacheService() },
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService },
{ provide: HALEndpointService, useValue: halService },
{ provide: CommunityDataService, useValue: {}},
{ provide: DSpaceObjectDataService, useValue: {}},
{ provide: CommunityDataService, useValue: {} },
{ provide: DSpaceObjectDataService, useValue: {} },
SearchService
],
});
@@ -158,10 +156,8 @@ describe('SearchService', () => {
const searchOptions = new PaginatedSearchOptions({});
const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] });
const response = new SearchSuccessResponse(queryResponse, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endPoint));
(searchService as any).responseCache.get.and.returnValue(Observable.of(responseEntry));
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
/* tslint:disable:no-empty */
searchService.search(searchOptions).subscribe((t) => {
}); // subscribe to make sure all methods are called
@@ -179,19 +175,14 @@ describe('SearchService', () => {
it('should call getByHref on the request service with the correct request url', () => {
expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint);
});
it('should call get on the request service with the correct request url', () => {
expect((searchService as any).responseCache.get).toHaveBeenCalledWith(endPoint);
});
});
describe('when getConfig is called without a scope', () => {
const endPoint = 'http://endpoint.com/test/config';
const filterConfig = [new SearchFilterConfig()];
const response = new FacetConfigSuccessResponse(filterConfig, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endPoint));
(searchService as any).responseCache.get.and.returnValue(Observable.of(responseEntry));
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
/* tslint:disable:no-empty */
searchService.getConfig(null).subscribe((t) => {
}); // subscribe to make sure all methods are called
@@ -209,9 +200,6 @@ describe('SearchService', () => {
it('should call getByHref on the request service with the correct request url', () => {
expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint);
});
it('should call get on the request service with the correct request url', () => {
expect((searchService as any).responseCache.get).toHaveBeenCalledWith(endPoint);
});
});
describe('when getConfig is called with a scope', () => {
@@ -220,10 +208,8 @@ describe('SearchService', () => {
const requestUrl = endPoint + '?scope=' + scope;
const filterConfig = [new SearchFilterConfig()];
const response = new FacetConfigSuccessResponse(filterConfig, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endPoint));
(searchService as any).responseCache.get.and.returnValue(Observable.of(responseEntry));
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
/* tslint:disable:no-empty */
searchService.getConfig(scope).subscribe((t) => {
}); // subscribe to make sure all methods are called
@@ -241,9 +227,6 @@ describe('SearchService', () => {
it('should call getByHref on the request service with the correct request url', () => {
expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(requestUrl);
});
it('should call get on the request service with the correct request url', () => {
expect((searchService as any).responseCache.get).toHaveBeenCalledWith(requestUrl);
});
});
});
});

View File

@@ -1,3 +1,4 @@
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { Injectable, OnDestroy } from '@angular/core';
import {
ActivatedRoute,
@@ -6,16 +7,13 @@ import {
Router,
UrlSegmentGroup
} from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { flatMap, map, switchMap } from 'rxjs/operators';
import { map, switchMap, tap } from 'rxjs/operators';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import {
FacetConfigSuccessResponse,
FacetValueSuccessResponse,
SearchSuccessResponse
} from '../../core/cache/response-cache.models';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { ResponseCacheService } from '../../core/cache/response-cache.service';
} from '../../core/cache/response.models';
import { PaginatedList } from '../../core/data/paginated-list';
import { ResponseParsingService } from '../../core/data/parsing.service';
import { RemoteData } from '../../core/data/remote-data';
@@ -24,7 +22,11 @@ import { RequestService } from '../../core/data/request.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { configureRequest, getSucceededRemoteData } from '../../core/shared/operators';
import {
configureRequest,
getResponseFromEntry,
getSucceededRemoteData
} from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { NormalizedSearchResult } from '../normalized-search-result.model';
@@ -68,7 +70,6 @@ export class SearchService implements OnDestroy {
constructor(private router: Router,
private route: ActivatedRoute,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
private rdb: RemoteDataBuildService,
private halService: HALEndpointService,
@@ -98,16 +99,12 @@ export class SearchService implements OnDestroy {
configureRequest(this.requestService)
);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
switchMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
// get search results from response cache
const sqrObs: Observable<SearchQueryResponse> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const sqrObs: Observable<SearchQueryResponse> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: SearchSuccessResponse) => response.results)
);
@@ -115,39 +112,43 @@ export class SearchService implements OnDestroy {
// Turn list of observable remote data DSO's into observable remote data object with list of DSO
const dsoObs: Observable<RemoteData<DSpaceObject[]>> = sqrObs.pipe(
map((sqr: SearchQueryResponse) => {
return sqr.objects.map((nsr: NormalizedSearchResult) =>
this.rdb.buildSingle(nsr.dspaceObject));
return sqr.objects.map((nsr: NormalizedSearchResult) => {
return this.rdb.buildSingle(nsr.dspaceObject);
})
}),
flatMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input))
switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)),
);
// Create search results again with the correct dso objects linked to each result
const tDomainListObs = Observable.combineLatest(sqrObs, dsoObs, (sqr: SearchQueryResponse, dsos: RemoteData<DSpaceObject[]>) => {
const tDomainListObs = observableCombineLatest(sqrObs, dsoObs).pipe(
map(([sqr, dsos]) => {
return sqr.objects.map((object: NormalizedSearchResult, index: number) => {
let co = DSpaceObject;
if (dsos.payload[index]) {
const constructor: GenericConstructor<ListableObject> = dsos.payload[index].constructor as GenericConstructor<ListableObject>;
co = getSearchResultFor(constructor);
return Object.assign(new co(), object, {
dspaceObject: dsos.payload[index]
});
} else {
return undefined;
}
});
})
);
return sqr.objects.map((object: NormalizedSearchResult, index: number) => {
let co = DSpaceObject;
if (dsos.payload[index]) {
const constructor: GenericConstructor<ListableObject> = dsos.payload[index].constructor as GenericConstructor<ListableObject>;
co = getSearchResultFor(constructor);
return Object.assign(new co(), object, {
dspaceObject: dsos.payload[index]
});
} else {
return undefined;
}
});
});
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: FacetValueSuccessResponse) => response.pageInfo)
);
const payloadObs = Observable.combineLatest(tDomainListObs, pageInfoObs, (tDomainList, pageInfo) => {
return new PaginatedList(pageInfo, tDomainList);
});
const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe(
map(([tDomainList, pageInfo]) => {
return new PaginatedList(pageInfo, tDomainList);
})
);
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
}
/**
@@ -179,21 +180,17 @@ export class SearchService implements OnDestroy {
);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
switchMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
// get search results from response cache
const facetConfigObs: Observable<SearchFilterConfig[]> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const facetConfigObs: Observable<SearchFilterConfig[]> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: FacetConfigSuccessResponse) =>
response.results.map((result: any) => Object.assign(new SearchFilterConfig(), result)))
);
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs);
return this.rdb.toRemoteDataObservable(requestEntryObs, facetConfigObs);
}
/**
@@ -226,29 +223,27 @@ export class SearchService implements OnDestroy {
);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
switchMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
// get search results from response cache
const facetValueObs: Observable<FacetValue[]> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const facetValueObs: Observable<FacetValue[]> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: FacetValueSuccessResponse) => response.results)
);
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: FacetValueSuccessResponse) => response.pageInfo)
);
const payloadObs = Observable.combineLatest(facetValueObs, pageInfoObs, (facetValue, pageInfo) => {
return new PaginatedList(pageInfo, facetValue);
});
const payloadObs = observableCombineLatest(facetValueObs, pageInfoObs).pipe(
map(([facetValue, pageInfo]) => {
return new PaginatedList(pageInfo, facetValue);
})
);
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
}
/**
@@ -272,12 +267,14 @@ export class SearchService implements OnDestroy {
switchMap((dsoRD: RemoteData<DSpaceObject>) => {
if (dsoRD.payload.type === ResourceType.Community) {
const community: Community = dsoRD.payload as Community;
return Observable.combineLatest(community.subcommunities, community.collections, (subCommunities, collections) => {
/*if this is a community, we also need to show the direct children*/
return [community, ...subCommunities.payload.page, ...collections.payload.page]
})
return observableCombineLatest(community.subcommunities, community.collections).pipe(
map(([subCommunities, collections]) => {
/*if this is a community, we also need to show the direct children*/
return [community, ...subCommunities.payload.page, ...collections.payload.page]
})
);
} else {
return Observable.of([dsoRD.payload]);
return observableOf([dsoRD.payload]);
}
}
));
@@ -291,13 +288,13 @@ export class SearchService implements OnDestroy {
* @returns {Observable<ViewMode>} The current view mode
*/
getViewMode(): Observable<ViewMode> {
return this.route.queryParams.map((params) => {
return this.route.queryParams.pipe(map((params) => {
if (isNotEmpty(params.view) && hasValue(params.view)) {
return params.view;
} else {
return ViewMode.List;
}
});
}));
}
/**

View File

@@ -1,7 +1,7 @@
import { SearchService } from '../search-service/search.service';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchSettingsComponent } from './search-settings.component';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { TranslateModule } from '@ngx-translate/core';
@@ -15,6 +15,7 @@ import { SearchFilterService } from '../search-filters/search-filter/search-filt
import { hot } from 'jasmine-marbles';
import { VarDirective } from '../../shared/utils/var.directive';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
import { first } from 'rxjs/operators';
describe('SearchSettingsComponent', () => {
@@ -43,16 +44,16 @@ describe('SearchSettingsComponent', () => {
};
const activatedRouteStub = {
queryParams: Observable.of({
queryParams: observableOf({
query: queryParam,
scope: scopeParam
})
};
const sidebarService = {
isCollapsed: Observable.of(true),
collapse: () => this.isCollapsed = Observable.of(true),
expand: () => this.isCollapsed = Observable.of(false)
isCollapsed: observableOf(true),
collapse: () => this.isCollapsed = observableOf(true),
expand: () => this.isCollapsed = observableOf(false)
};
beforeEach(async(() => {
@@ -101,7 +102,7 @@ describe('SearchSettingsComponent', () => {
});
it('it should show the order settings with the respective selectable options', () => {
(comp as any).searchOptions$.first().subscribe((options) => {
(comp as any).searchOptions$.pipe(first()).subscribe((options) => {
fixture.detectChanges();
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
expect(orderSetting).toBeDefined();
@@ -111,7 +112,7 @@ describe('SearchSettingsComponent', () => {
});
it('it should show the size settings with the respective selectable options', () => {
(comp as any).searchOptions$.first().subscribe((options) => {
(comp as any).searchOptions$.pipe(first()).subscribe((options) => {
fixture.detectChanges();
const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings'));
expect(pageSizeSetting).toBeDefined();
@@ -122,7 +123,7 @@ describe('SearchSettingsComponent', () => {
});
it('should have the proper order value selected by default', () => {
(comp as any).searchOptions$.first().subscribe((options) => {
(comp as any).searchOptions$.pipe(first()).subscribe((options) => {
fixture.detectChanges();
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
const childElementToBeSelected = orderSetting.query(By.css('.form-control option[value="0"][selected="selected"]'));
@@ -131,7 +132,7 @@ describe('SearchSettingsComponent', () => {
});
it('should have the proper rpp value selected by default', () => {
(comp as any).searchOptions$.first().subscribe((options) => {
(comp as any).searchOptions$.pipe(first()).subscribe((options) => {
fixture.detectChanges();
const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings'));
const childElementToBeSelected = pageSizeSetting.query(By.css('.form-control option[value="10"][selected="selected"]'));

View File

@@ -3,8 +3,7 @@ import { SearchService } from '../search-service/search.service';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { SearchFilterService } from '../search-filters/search-filter/search-filter.service';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
@Component({

View File

@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jasmine-marbles';
import * as fromRouter from '@ngrx/router-store';

View File

@@ -1,5 +1,6 @@
import { map, tap, filter } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Effect, Actions } from '@ngrx/effects'
import { Effect, Actions, ofType } from '@ngrx/effects'
import * as fromRouter from '@ngrx/router-store';
import { SearchSidebarCollapseAction } from './search-sidebar.actions';
@@ -12,10 +13,14 @@ import { URLBaser } from '../../core/url-baser/url-baser';
export class SearchSidebarEffects {
private previousPath: string;
@Effect() routeChange$ = this.actions$
.ofType(fromRouter.ROUTER_NAVIGATION)
.filter((action) => this.previousPath !== this.getBaseUrl(action))
.do((action) => {this.previousPath = this.getBaseUrl(action)})
.map(() => new SearchSidebarCollapseAction());
.pipe(
ofType(fromRouter.ROUTER_NAVIGATION),
filter((action) => this.previousPath !== this.getBaseUrl(action)),
tap((action) => {
this.previousPath = this.getBaseUrl(action)
}),
map(() => new SearchSidebarCollapseAction())
);
constructor(private actions$: Actions) {

View File

@@ -1,9 +1,8 @@
import { Store } from '@ngrx/store';
import { SearchSidebarService } from './search-sidebar.service';
import { AppState } from '../../app.reducer';
import { async, inject, TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { async, TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { SearchSidebarCollapseAction, SearchSidebarExpandAction } from './search-sidebar.actions';
import { HostWindowService } from '../../shared/host-window.service';
@@ -13,13 +12,13 @@ describe('SearchSidebarService', () => {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
pipe: observableOf(true)
});
const windowService = jasmine.createSpyObj('hostWindowService',
{
isXs: Observable.of(true),
isSm: Observable.of(false),
isXsOrSm: Observable.of(true)
isXs: observableOf(true),
isSm: observableOf(false),
isXsOrSm: observableOf(true)
});
beforeEach(async(() => {
TestBed.configureTestingModule({

View File

@@ -1,10 +1,11 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Injectable } from '@angular/core';
import { SearchSidebarState } from './search-sidebar.reducer';
import { createSelector, Store } from '@ngrx/store';
import { createSelector, select, Store } from '@ngrx/store';
import { SearchSidebarCollapseAction, SearchSidebarExpandAction } from './search-sidebar.actions';
import { Observable } from 'rxjs/Observable';
import { AppState } from '../../app.reducer';
import { HostWindowService } from '../../shared/host-window.service';
import { map } from 'rxjs/operators';
const sidebarStateSelector = (state: AppState) => state.searchSidebar;
const sidebarCollapsedSelector = createSelector(sidebarStateSelector, (sidebar: SearchSidebarState) => sidebar.sidebarCollapsed);
@@ -26,7 +27,7 @@ export class SearchSidebarService {
constructor(private store: Store<AppState>, private windowService: HostWindowService) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
this.isCollapsedInStore = this.store.select(sidebarCollapsedSelector);
this.isCollapsedInStore = this.store.pipe(select(sidebarCollapsedSelector));
}
/**
@@ -34,10 +35,12 @@ export class SearchSidebarService {
* @returns {Observable<boolean>} Emits true if the user's screen size is mobile or when the state in the store is currently collapsed
*/
get isCollapsed(): Observable<boolean> {
return Observable.combineLatest(
return observableCombineLatest(
this.isXsOrSm$,
this.isCollapsedInStore,
(mobile, store) => mobile ? store : true);
this.isCollapsedInStore
).pipe(
map(([mobile, store]) => mobile ? store : true)
);
}
/**

View File

@@ -12,6 +12,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
{ path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' },
{ path: 'admin', loadChildren: './+admin/admin.module#AdminModule' },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },

View File

@@ -92,7 +92,7 @@ describe('App component', () => {
let store: Store<HostWindowState>;
beforeEach(() => {
store = fixture.debugElement.injector.get(Store);
store = fixture.debugElement.injector.get(Store) as Store<HostWindowState>;
spyOn(store, 'dispatch');
window.dispatchEvent(new Event('resize'));

View File

@@ -1,3 +1,4 @@
import { filter, first, take } from 'rxjs/operators';
import {
AfterViewInit,
ChangeDetectionStrategy,
@@ -9,7 +10,7 @@ import {
} from '@angular/core';
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { select, Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
@@ -62,10 +63,10 @@ export class AppComponent implements OnInit, AfterViewInit {
this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight);
// Whether is not authenticathed try to retrieve a possible stored auth token
this.store.select(isAuthenticated)
.take(1)
.filter((authenticated) => !authenticated)
.subscribe((authenticated) => this.authService.checkAuthenticationToken());
this.store.pipe(select(isAuthenticated),
first(),
filter((authenticated) => !authenticated)
).subscribe((authenticated) => this.authService.checkAuthenticationToken());
}

View File

@@ -7,7 +7,6 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { META_REDUCERS, MetaReducer, StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { TranslateModule } from '@ngx-translate/core';
@@ -47,10 +46,6 @@ export function getMetaReducers(config: GlobalConfig): Array<MetaReducer<AppStat
const DEV_MODULES: any[] = [];
if (!ENV_CONFIG.production) {
DEV_MODULES.push(StoreDevtoolsModule.instrument({ maxAge: 500 }));
}
@NgModule({
imports: [
CommonModule,

View File

@@ -1,14 +1,15 @@
import { AuthType } from './auth-type';
import { GenericConstructor } from '../shared/generic-constructor';
import { NormalizedAuthStatus } from './models/normalized-auth-status.model';
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
import { NormalizedEpersonModel } from '../eperson/models/NormalizedEperson.model';
import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { EPerson } from '../eperson/models/eperson.model';
export class AuthObjectFactory {
public static getConstructor(type): GenericConstructor<NormalizedDSpaceObject> {
public static getConstructor(type): GenericConstructor<NormalizedObject> {
switch (type) {
case AuthType.Eperson: {
return NormalizedEpersonModel
case AuthType.EPerson: {
return NormalizedEPerson
}
case AuthType.Status: {

View File

@@ -1,15 +1,16 @@
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { Observable } from 'rxjs/Observable';
import { isNotEmpty } from '../../shared/empty.util';
import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { AuthStatusResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { RequestEntry } from '../data/request.reducer';
import { getResponseFromEntry } from '../shared/operators';
@Injectable()
export class AuthRequestService {
@@ -18,23 +19,22 @@ export class AuthRequestService {
constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected halService: HALEndpointService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService) {
}
protected fetchRequest(request: RestRequest): Observable<any> {
const [successResponse, errorResponse] = this.responseCache.get(request.href)
.map((entry: ResponseCacheEntry) => entry.response)
return this.requestService.getByUUID(request.uuid).pipe(
getResponseFromEntry(),
// TODO to review when https://github.com/DSpace/dspace-angular/issues/217 will be fixed
.do(() => this.responseCache.remove(request.href))
.partition((response: RestResponse) => response.isSuccessful);
return Observable.merge(
errorResponse.flatMap((response: ErrorResponse) =>
Observable.throw(new Error(response.errorMessage))),
successResponse
.filter((response: AuthStatusResponse) => isNotEmpty(response))
.map((response: AuthStatusResponse) => response.response)
.distinctUntilChanged());
// tap(() => this.responseCache.remove(request.href)),
mergeMap((response) => {
if (response.isSuccessful && isNotEmpty(response)) {
return observableOf((response as AuthStatusResponse).response);
} else if (!response.isSuccessful) {
return observableThrowError(new Error((response as ErrorResponse).errorMessage));
}
})
);
}
protected getEndpointByMethod(endpoint: string, method: string): string {
@@ -42,24 +42,24 @@ export class AuthRequestService {
}
public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable<any> {
return this.halService.getEndpoint(this.linkName)
.filter((href: string) => isNotEmpty(href))
.map((endpointURL) => this.getEndpointByMethod(endpointURL, method))
.distinctUntilChanged()
.map((endpointURL: string) => new AuthPostRequest(this.requestService.generateRequestId(), endpointURL, body, options))
.do((request: PostRequest) => this.requestService.configure(request, true))
.flatMap((request: PostRequest) => this.fetchRequest(request))
.distinctUntilChanged();
return this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)),
distinctUntilChanged(),
map((endpointURL: string) => new AuthPostRequest(this.requestService.generateRequestId(), endpointURL, body, options)),
tap((request: PostRequest) => this.requestService.configure(request, true)),
mergeMap((request: PostRequest) => this.fetchRequest(request)),
distinctUntilChanged());
}
public getRequest(method: string, options?: HttpOptions): Observable<any> {
return this.halService.getEndpoint(this.linkName)
.filter((href: string) => isNotEmpty(href))
.map((endpointURL) => this.getEndpointByMethod(endpointURL, method))
.distinctUntilChanged()
.map((endpointURL: string) => new AuthGetRequest(this.requestService.generateRequestId(), endpointURL, options))
.do((request: PostRequest) => this.requestService.configure(request, true))
.flatMap((request: PostRequest) => this.fetchRequest(request))
.distinctUntilChanged();
return this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)),
distinctUntilChanged(),
map((endpointURL: string) => new AuthGetRequest(this.requestService.generateRequestId(), endpointURL, options)),
tap((request: PostRequest) => this.requestService.configure(request, true)),
mergeMap((request: PostRequest) => this.fetchRequest(request)),
distinctUntilChanged());
}
}

View File

@@ -1,20 +1,18 @@
import { AuthStatusResponse } from '../cache/response-cache.models';
import { AuthStatusResponse } from '../cache/response.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { AuthStatus } from './models/auth-status.model';
import { AuthResponseParsingService } from './auth-response-parsing.service';
import { AuthGetRequest, AuthPostRequest } from '../data/request.models';
import { MockStore } from '../../shared/testing/mock-store';
import { ObjectCacheState } from '../cache/object-cache.reducer';
describe('ConfigResponseParsingService', () => {
describe('AuthResponseParsingService', () => {
let service: AuthResponseParsingService;
const EnvConfig = {} as GlobalConfig;
const store = {} as Store<CoreState>;
const objectCacheService = new ObjectCacheService(store);
const EnvConfig = { cache: { msToLive: 1000 } } as any;
const store = new MockStore<ObjectCacheState>({});
const objectCacheService = new ObjectCacheService(store as any);
beforeEach(() => {
service = new AuthResponseParsingService(EnvConfig, objectCacheService);
@@ -86,13 +84,19 @@ describe('ConfigResponseParsingService', () => {
type: 'eperson',
uuid: '4dc70ab5-cd73-492f-b007-3179d2d9296b',
_links: {
self: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b'
self: {
href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b'
}
}
}
},
_links: {
eperson: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b',
self: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/authn/status'
eperson: {
href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b'
},
self: {
href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/authn/status'
}
}
},
statusCode: '200'

View File

@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core';
import { AuthObjectFactory } from './auth-object-factory';
import { BaseResponseParsingService } from '../data/base-response-parsing.service';
import { AuthStatusResponse, RestResponse } from '../cache/response-cache.models';
import { AuthStatusResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
@@ -12,22 +12,22 @@ import { ResponseParsingService } from '../data/parsing.service';
import { RestRequest } from '../data/request.models';
import { AuthType } from './auth-type';
import { AuthStatus } from './models/auth-status.model';
import { NormalizedAuthStatus } from './models/normalized-auth-status.model';
@Injectable()
export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
protected objectFactory = AuthObjectFactory;
protected toCache = false;
protected toCache = true;
constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected objectCache: ObjectCacheService,) {
protected objectCache: ObjectCacheService) {
super();
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) {
const response: AuthStatus = this.process<AuthStatus, AuthType>(data.payload, request.href);
response.eperson = data.payload._embedded.eperson;
const response = this.process<NormalizedAuthStatus, AuthType>(data.payload, request.href);
return new AuthStatusResponse(response, data.statusCode);
} else {
return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode);

View File

@@ -1,4 +1,4 @@
export enum AuthType {
Eperson = 'eperson',
EPerson = 'eperson',
Status = 'status'
}

View File

@@ -5,7 +5,7 @@ import { Action } from '@ngrx/store';
import { type } from '../../shared/ngrx/type';
// import models
import { Eperson } from '../eperson/models/eperson.model';
import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
export const AuthActionTypes = {
@@ -76,10 +76,10 @@ export class AuthenticatedSuccessAction implements Action {
payload: {
authenticated: boolean;
authToken: AuthTokenInfo;
user: Eperson
user: EPerson
};
constructor(authenticated: boolean, authToken: AuthTokenInfo, user: Eperson) {
constructor(authenticated: boolean, authToken: AuthTokenInfo, user: EPerson) {
this.payload = { authenticated, authToken, user };
}
}
@@ -250,9 +250,9 @@ export class RefreshTokenErrorAction implements Action {
*/
export class RegistrationAction implements Action {
public type: string = AuthActionTypes.REGISTRATION;
payload: Eperson;
payload: EPerson;
constructor(user: Eperson) {
constructor(user: EPerson) {
this.payload = user;
}
}
@@ -278,9 +278,9 @@ export class RegistrationErrorAction implements Action {
*/
export class RegistrationSuccessAction implements Action {
public type: string = AuthActionTypes.REGISTRATION_SUCCESS;
payload: Eperson;
payload: EPerson;
constructor(user: Eperson) {
constructor(user: EPerson) {
this.payload = user;
}
}

View File

@@ -4,8 +4,7 @@ import { provideMockActions } from '@ngrx/effects/testing';
import { Store } from '@ngrx/store';
import { cold, hot } from 'jasmine-marbles';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of'
import { Observable, of as observableOf, throwError as observableThrow } from 'rxjs';
import { AuthEffects } from './auth.effects';
import {
@@ -25,22 +24,26 @@ import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
import { AuthService } from './auth.service';
import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer';
import { EpersonMock } from '../../shared/testing/eperson-mock';
import { EPersonMock } from '../../shared/testing/eperson-mock';
describe('AuthEffects', () => {
let authEffects: AuthEffects;
let actions: Observable<any>;
const authServiceStub = new AuthServiceStub();
let authServiceStub;
const store: Store<TruncatablesState> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
select: observableOf(true)
});
const token = authServiceStub.getToken();
let token;
function init() {
authServiceStub = new AuthServiceStub();
token = authServiceStub.getToken();
}
beforeEach(() => {
init();
TestBed.configureTestingModule({
providers: [
AuthEffects,
@@ -72,7 +75,7 @@ describe('AuthEffects', () => {
describe('when credentials are wrong', () => {
it('should return a AUTHENTICATE_ERROR action in response to a AUTHENTICATE action', () => {
spyOn((authEffects as any).authService, 'authenticate').and.returnValue(Observable.throw(new Error('Message Error test')));
spyOn((authEffects as any).authService, 'authenticate').and.returnValue(observableThrow(new Error('Message Error test')));
actions = hot('--a-', {
a: {
@@ -105,7 +108,7 @@ describe('AuthEffects', () => {
it('should return a AUTHENTICATED_SUCCESS action in response to a AUTHENTICATED action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}});
const expected = cold('--b-', {b: new AuthenticatedSuccessAction(true, token, EpersonMock)});
const expected = cold('--b-', {b: new AuthenticatedSuccessAction(true, token, EPersonMock)});
expect(authEffects.authenticated$).toBeObservable(expected);
});
@@ -113,7 +116,7 @@ describe('AuthEffects', () => {
describe('when token is not valid', () => {
it('should return a AUTHENTICATED_ERROR action in response to a AUTHENTICATED action', () => {
spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(Observable.throw(new Error('Message Error test')));
spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(observableThrow(new Error('Message Error test')));
actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}});
@@ -139,7 +142,7 @@ describe('AuthEffects', () => {
describe('when check token failed', () => {
it('should return a CHECK_AUTHENTICATION_TOKEN_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN action', () => {
spyOn((authEffects as any).authService, 'hasValidAuthenticationToken').and.returnValue(Observable.throw(''));
spyOn((authEffects as any).authService, 'hasValidAuthenticationToken').and.returnValue(observableThrow(''));
actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token}});
@@ -165,7 +168,7 @@ describe('AuthEffects', () => {
describe('when refresh token failed', () => {
it('should return a REFRESH_TOKEN_ERROR action in response to a REFRESH_TOKEN action', () => {
spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(Observable.throw(''));
spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow(''));
actions = hot('--a-', {a: {type: AuthActionTypes.REFRESH_TOKEN, payload: token}});
@@ -191,7 +194,7 @@ describe('AuthEffects', () => {
describe('when refresh token failed', () => {
it('should return a REFRESH_TOKEN_ERROR action in response to a LOG_OUT action', () => {
spyOn((authEffects as any).authService, 'logout').and.returnValue(Observable.throw(new Error('Message Error test')));
spyOn((authEffects as any).authService, 'logout').and.returnValue(observableThrow(new Error('Message Error test')));
actions = hot('--a-', {a: {type: AuthActionTypes.LOG_OUT, payload: token}});

View File

@@ -1,11 +1,11 @@
import { of as observableOf, Observable } from 'rxjs';
import { filter, debounceTime, switchMap, take, tap, catchError, map, first } from 'rxjs/operators';
import { Injectable } from '@angular/core';
// import @ngrx
import { Actions, Effect } from '@ngrx/effects';
import { Action, Store } from '@ngrx/store';
// import rxjs
import { Observable } from 'rxjs/Observable';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action, select, Store } from '@ngrx/store';
// import services
import { AuthService } from './auth.service';
@@ -28,7 +28,7 @@ import {
RegistrationErrorAction,
RegistrationSuccessAction
} from './auth.actions';
import { Eperson } from '../eperson/models/eperson.model';
import { EPerson } from '../eperson/models/eperson.model';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { AppState } from '../../app.reducer';
@@ -43,112 +43,131 @@ export class AuthEffects {
* @method authenticate
*/
@Effect()
public authenticate$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATE)
.switchMap((action: AuthenticateAction) => {
return this.authService.authenticate(action.payload.email, action.payload.password)
.first()
.map((response: AuthStatus) => new AuthenticationSuccessAction(response.token))
.catch((error) => Observable.of(new AuthenticationErrorAction(error)));
});
public authenticate$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATE),
switchMap((action: AuthenticateAction) => {
return this.authService.authenticate(action.payload.email, action.payload.password).pipe(
first(),
map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)),
catchError((error) => observableOf(new AuthenticationErrorAction(error)))
);
})
);
@Effect()
public authenticateSuccess$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATE_SUCCESS)
.do((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload))
.map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload));
public authenticateSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATE_SUCCESS),
tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)),
map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload))
);
@Effect()
public authenticated$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATED)
.switchMap((action: AuthenticatedAction) => {
return this.authService.authenticatedUser(action.payload)
.map((user: Eperson) => new AuthenticatedSuccessAction((user !== null), action.payload, user))
.catch((error) => Observable.of(new AuthenticatedErrorAction(error)));
});
public authenticated$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED),
switchMap((action: AuthenticatedAction) => {
return this.authService.authenticatedUser(action.payload).pipe(
map((user: EPerson) => new AuthenticatedSuccessAction((user !== null), action.payload, user)),
catchError((error) => observableOf(new AuthenticatedErrorAction(error))),);
})
);
// It means "reacts to this action but don't send another"
@Effect({dispatch: false})
public authenticatedError$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATED_ERROR)
.do((action: LogOutSuccessAction) => this.authService.removeToken());
@Effect({ dispatch: false })
public authenticatedError$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED_ERROR),
tap((action: LogOutSuccessAction) => this.authService.removeToken())
);
@Effect()
public checkToken$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN)
.switchMap(() => {
return this.authService.hasValidAuthenticationToken()
.map((token: AuthTokenInfo) => new AuthenticatedAction(token))
.catch((error) => Observable.of(new CheckAuthenticationTokenErrorAction()));
});
public checkToken$: Observable<Action> = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN),
switchMap(() => {
return this.authService.hasValidAuthenticationToken().pipe(
map((token: AuthTokenInfo) => new AuthenticatedAction(token)),
catchError((error) => observableOf(new CheckAuthenticationTokenErrorAction()))
);
})
);
@Effect()
public createUser$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REGISTRATION)
.debounceTime(500) // to remove when functionality is implemented
.switchMap((action: RegistrationAction) => {
return this.authService.create(action.payload)
.map((user: Eperson) => new RegistrationSuccessAction(user))
.catch((error) => Observable.of(new RegistrationErrorAction(error)));
});
public createUser$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.REGISTRATION),
debounceTime(500), // to remove when functionality is implemented
switchMap((action: RegistrationAction) => {
return this.authService.create(action.payload).pipe(
map((user: EPerson) => new RegistrationSuccessAction(user)),
catchError((error) => observableOf(new RegistrationErrorAction(error)))
);
})
);
@Effect()
public refreshToken$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REFRESH_TOKEN)
.switchMap((action: RefreshTokenAction) => {
return this.authService.refreshAuthenticationToken(action.payload)
.map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token))
.catch((error) => Observable.of(new RefreshTokenErrorAction()));
});
public refreshToken$: Observable<Action> = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN),
switchMap((action: RefreshTokenAction) => {
return this.authService.refreshAuthenticationToken(action.payload).pipe(
map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)),
catchError((error) => observableOf(new RefreshTokenErrorAction()))
);
})
);
// It means "reacts to this action but don't send another"
@Effect({dispatch: false})
public refreshTokenSuccess$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS)
.do((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload));
@Effect({ dispatch: false })
public refreshTokenSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS),
tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload))
);
/**
* When the store is rehydrated in the browser,
* clear a possible invalid token or authentication errors
*/
@Effect({dispatch: false})
public clearInvalidTokenOnRehydrate$: Observable<any> = this.actions$
.ofType(StoreActionTypes.REHYDRATE)
.switchMap(() => {
return this.store.select(isAuthenticated)
.take(1)
.filter((authenticated) => !authenticated)
.do(() => this.authService.removeToken())
.do(() => this.authService.resetAuthenticationError());
});
@Effect({ dispatch: false })
public clearInvalidTokenOnRehydrate$: Observable<any> = this.actions$.pipe(
ofType(StoreActionTypes.REHYDRATE),
switchMap(() => {
return this.store.pipe(
select(isAuthenticated),
first(),
filter((authenticated) => !authenticated),
tap(() => this.authService.removeToken()),
tap(() => this.authService.resetAuthenticationError())
);
}));
@Effect()
public logOut$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.LOG_OUT)
.switchMap(() => {
return this.authService.logout()
.map((value) => new LogOutSuccessAction())
.catch((error) => Observable.of(new LogOutErrorAction(error)));
});
.pipe(
ofType(AuthActionTypes.LOG_OUT),
switchMap(() => {
return this.authService.logout().pipe(
map((value) => new LogOutSuccessAction()),
catchError((error) => observableOf(new LogOutErrorAction(error)))
);
})
);
@Effect({dispatch: false})
@Effect({ dispatch: false })
public logOutSuccess$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.LOG_OUT_SUCCESS)
.do(() => this.authService.removeToken())
.do(() => this.authService.clearRedirectUrl())
.do(() => this.authService.refreshAfterLogout());
.pipe(ofType(AuthActionTypes.LOG_OUT_SUCCESS),
tap(() => this.authService.removeToken()),
tap(() => this.authService.clearRedirectUrl()),
tap(() => this.authService.refreshAfterLogout())
);
@Effect({dispatch: false})
@Effect({ dispatch: false })
public redirectToLogin$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED)
.do(() => this.authService.removeToken())
.do(() => this.authService.redirectToLogin());
.pipe(ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED),
tap(() => this.authService.removeToken()),
tap(() => this.authService.redirectToLogin())
);
@Effect({dispatch: false})
@Effect({ dispatch: false })
public redirectToLoginTokenExpired$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED)
.do(() => this.authService.removeToken())
.do(() => this.authService.redirectToLoginWhenTokenExpired());
.pipe(
ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED),
tap(() => this.authService.removeToken()),
tap(() => this.authService.redirectToLoginWhenTokenExpired())
);
/**
* @constructor

View File

@@ -4,15 +4,15 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs';
import { AuthInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service';
import { RestRequestMethod } from '../data/request.models';
import { RouterStub } from '../../shared/testing/router-stub';
import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer';
import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
import { RestRequestMethod } from '../data/rest-request-method';
describe(`AuthInterceptor`, () => {
let service: DSpaceRESTv2Service;
@@ -23,7 +23,7 @@ describe(`AuthInterceptor`, () => {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
select: observableOf(true)
});
beforeEach(() => {
@@ -49,7 +49,7 @@ describe(`AuthInterceptor`, () => {
describe('when has a valid token', () => {
it('should not add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => {
service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => {
expect(response).toBeTruthy();
});
@@ -60,7 +60,7 @@ describe(`AuthInterceptor`, () => {
});
it('should add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => {
service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => {
expect(response).toBeTruthy();
});
@@ -85,11 +85,11 @@ describe(`AuthInterceptor`, () => {
it('should redirect to login', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => {
service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => {
expect(response).toBeTruthy();
});
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user');
service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user');
httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems');
});

View File

@@ -1,13 +1,16 @@
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
import { catchError, filter, map } from 'rxjs/operators';
import { Injectable, Injector } from '@angular/core';
import {
HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse,
HttpErrorResponse, HttpResponseBase
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpInterceptor,
HttpRequest,
HttpResponse,
HttpResponseBase
} from '@angular/common/http';
import { Observable } from 'rxjs/Rx';
import 'rxjs/add/observable/throw'
import 'rxjs/add/operator/catch';
import { find } from 'lodash';
import { AppState } from '../../app.reducer';
@@ -35,7 +38,7 @@ export class AuthInterceptor implements HttpInterceptor {
}
private isSuccess(response: HttpResponseBase): boolean {
return response.status === 200;
return (response.status === 200 || response.status === 204);
}
private isAuthRequest(http: HttpRequest<any> | HttpResponseBase): boolean {
@@ -79,11 +82,11 @@ export class AuthInterceptor implements HttpInterceptor {
// The access token is expired
// Redirect to the login route
this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired'));
return Observable.of(null);
return observableOf(null);
} else if (!this.isAuthRequest(req) && isNotEmpty(token)) {
// Intercept a request that is not to the authentication endpoint
authService.isTokenExpiring()
.filter((isExpiring) => isExpiring)
authService.isTokenExpiring().pipe(
filter((isExpiring) => isExpiring))
.subscribe(() => {
// If the current request url is already in the refresh token request list, skip it
if (isUndefined(find(this.refreshTokenRequestUrls, req.url))) {
@@ -101,8 +104,8 @@ export class AuthInterceptor implements HttpInterceptor {
}
// Pass on the new request instead of the original request.
return next.handle(newReq)
.map((response) => {
return next.handle(newReq).pipe(
map((response) => {
// Intercept a Login/Logout response
if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) {
// It's a success Login/Logout response
@@ -122,8 +125,8 @@ export class AuthInterceptor implements HttpInterceptor {
} else {
return response;
}
})
.catch((error, caught) => {
}),
catchError((error, caught) => {
// Intercept an error response
if (error instanceof HttpErrorResponse) {
// Checks if is a response from a request to an authentication endpoint
@@ -138,7 +141,7 @@ export class AuthInterceptor implements HttpInterceptor {
statusText: error.statusText,
url: error.url
});
return Observable.of(authResponse);
return observableOf(authResponse);
} else if (this.isUnauthorized(error)) {
// The access token provided is expired, revoked, malformed, or invalid for other reasons
// Redirect to the login route
@@ -146,8 +149,7 @@ export class AuthInterceptor implements HttpInterceptor {
}
}
// Return error response as is.
return Observable.throw(error);
}) as any;
return observableThrowError(error);
})) as any;
}
}

View File

@@ -21,7 +21,7 @@ import {
SetRedirectUrlAction
} from './auth.actions';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { EpersonMock } from '../../shared/testing/eperson-mock';
import { EPersonMock } from '../../shared/testing/eperson-mock';
describe('authReducer', () => {
@@ -107,7 +107,7 @@ describe('authReducer', () => {
loading: true,
info: undefined
};
const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EpersonMock);
const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock);
const newState = authReducer(initialState, action);
state = {
authenticated: true,
@@ -116,7 +116,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
user: EPersonMock
};
expect(newState).toEqual(state);
});
@@ -182,7 +182,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
user: EPersonMock
};
const action = new LogOutAction();
@@ -199,7 +199,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
user: EPersonMock
};
const action = new LogOutSuccessAction();
@@ -225,7 +225,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
user: EPersonMock
};
const action = new LogOutErrorAction(mockError);
@@ -237,7 +237,7 @@ describe('authReducer', () => {
error: 'Test error message',
loading: false,
info: undefined,
user: EpersonMock
user: EPersonMock
};
expect(newState).toEqual(state);
});
@@ -250,7 +250,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
user: EPersonMock
};
const newTokenInfo = new AuthTokenInfo('Refreshed token');
const action = new RefreshTokenAction(newTokenInfo);
@@ -262,7 +262,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock,
user: EPersonMock,
refreshing: true
};
expect(newState).toEqual(state);
@@ -276,7 +276,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock,
user: EPersonMock,
refreshing: true
};
const newTokenInfo = new AuthTokenInfo('Refreshed token');
@@ -289,7 +289,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock,
user: EPersonMock,
refreshing: false
};
expect(newState).toEqual(state);
@@ -303,7 +303,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock,
user: EPersonMock,
refreshing: true
};
const action = new RefreshTokenErrorAction();
@@ -329,7 +329,7 @@ describe('authReducer', () => {
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
user: EPersonMock
};
state = {

View File

@@ -12,7 +12,7 @@ import {
SetRedirectUrlAction
} from './auth.actions';
// import models
import { Eperson } from '../eperson/models/eperson.model';
import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
/**
@@ -46,7 +46,7 @@ export interface AuthState {
refreshing?: boolean;
// the authenticated user
user?: Eperson;
user?: EPerson;
}
/**

View File

@@ -3,9 +3,8 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { Store, StoreModule } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import 'rxjs/add/observable/of';
import { of as observableOf } from 'rxjs';
import { authReducer, AuthState } from './auth.reducer';
import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service';
@@ -18,49 +17,64 @@ import { AuthRequestServiceStub } from '../../shared/testing/auth-request-servic
import { AuthRequestService } from './auth-request.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { Eperson } from '../eperson/models/eperson.model';
import { EpersonMock } from '../../shared/testing/eperson-mock';
import { EPerson } from '../eperson/models/eperson.model';
import { EPersonMock } from '../../shared/testing/eperson-mock';
import { AppState } from '../../app.reducer';
import { ClientCookieService } from '../../shared/services/client-cookie.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
describe('AuthService test', () => {
const mockStore: Store<AuthState> = jasmine.createSpyObj('store', {
dispatch: {},
select: Observable.of(true)
pipe: observableOf(true)
});
let authService: AuthService;
const authRequest = new AuthRequestServiceStub();
let authRequest;
const window = new NativeWindowRef();
const routerStub = new RouterStub();
const routeStub = new ActivatedRouteStub();
let routeStub;
let storage: CookieService;
const token: AuthTokenInfo = new AuthTokenInfo('test_token');
token.expires = Date.now() + (1000 * 60 * 60);
let authenticatedState = {
authenticated: true,
loaded: true,
loading: false,
authToken: token,
user: EpersonMock
};
let token: AuthTokenInfo;
let authenticatedState;
const rdbService = getMockRemoteDataBuildService();
function init() {
token = new AuthTokenInfo('test_token');
token.expires = Date.now() + (1000 * 60 * 60);
authenticatedState = {
authenticated: true,
loaded: true,
loading: false,
authToken: token,
user: EPersonMock
};
authRequest = new AuthRequestServiceStub();
routeStub = new ActivatedRouteStub();
}
beforeEach(() => {
init();
});
describe('', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
CommonModule,
StoreModule.forRoot({authReducer}),
StoreModule.forRoot({ authReducer }),
],
declarations: [],
providers: [
{provide: AuthRequestService, useValue: authRequest},
{provide: NativeWindowService, useValue: window},
{provide: REQUEST, useValue: {}},
{provide: Router, useValue: routerStub},
{provide: ActivatedRoute, useValue: routeStub},
{ provide: AuthRequestService, useValue: authRequest },
{ provide: NativeWindowService, useValue: window },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
{ provide: ActivatedRoute, useValue: routeStub },
{provide: Store, useValue: mockStore},
{ provide: RemoteDataBuildService, useValue: rdbService },
CookieService,
AuthService
],
@@ -79,7 +93,7 @@ describe('AuthService test', () => {
});
it('should return the authenticated user object when user token is valid', () => {
authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: Eperson) => {
authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: EPerson) => {
expect(user).toBeDefined();
});
});
@@ -113,14 +127,16 @@ describe('AuthService test', () => {
describe('', () => {
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({authReducer})
StoreModule.forRoot({ authReducer })
],
providers: [
{provide: AuthRequestService, useValue: authRequest},
{provide: REQUEST, useValue: {}},
{provide: Router, useValue: routerStub},
{ provide: AuthRequestService, useValue: authRequest },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
{ provide: RemoteDataBuildService, useValue: rdbService },
CookieService
]
}).compileComponents();
@@ -132,7 +148,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, authReqService, router, cookieService, store);
authService = new AuthService({}, window, authReqService, router, cookieService, store, rdbService);
}));
it('should return true when user is logged in', () => {
@@ -164,12 +180,12 @@ describe('AuthService test', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({authReducer})
StoreModule.forRoot({ authReducer })
],
providers: [
{provide: AuthRequestService, useValue: authRequest},
{provide: REQUEST, useValue: {}},
{provide: Router, useValue: routerStub},
{ provide: AuthRequestService, useValue: authRequest },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
ClientCookieService,
CookieService
]
@@ -184,14 +200,14 @@ describe('AuthService test', () => {
loaded: true,
loading: false,
authToken: expiredToken,
user: EpersonMock
user: EPersonMock
};
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, authReqService, router, cookieService, store);
authService = new AuthService({}, window, authReqService, router, cookieService, store, rdbService);
storage = (authService as any).storage;
spyOn(storage, 'get');
spyOn(storage, 'remove');

View File

@@ -1,15 +1,24 @@
import { Observable, of as observableOf } from 'rxjs';
import {
distinctUntilChanged,
filter,
first,
map,
startWith,
switchMap,
take,
withLatestFrom
} from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core';
import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router';
import { HttpHeaders } from '@angular/common/http';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { RouterReducerState } from '@ngrx/router-store';
import { Store } from '@ngrx/store';
import { select, Store } from '@ngrx/store';
import { CookieAttributes } from 'js-cookie';
import { Observable } from 'rxjs/Observable';
import { map, withLatestFrom } from 'rxjs/operators';
import { Eperson } from '../eperson/models/eperson.model';
import { EPerson } from '../eperson/models/eperson.model';
import { AuthRequestService } from './auth-request.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
@@ -17,11 +26,18 @@ import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model';
import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util';
import { CookieService } from '../../shared/services/cookie.service';
import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors';
import {
getAuthenticationToken,
getRedirectUrl,
isAuthenticated,
isTokenRefreshing
} from './selectors';
import { AppState, routerStateSelector } from '../../app.reducer';
import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
@@ -45,22 +61,27 @@ export class AuthService {
protected authRequestService: AuthRequestService,
protected router: Router,
protected storage: CookieService,
protected store: Store<AppState>) {
this.store.select(isAuthenticated)
.startWith(false)
.subscribe((authenticated: boolean) => this._authenticated = authenticated);
protected store: Store<AppState>,
protected rdbService: RemoteDataBuildService
) {
this.store.pipe(
select(isAuthenticated),
startWith(false)
).subscribe((authenticated: boolean) => this._authenticated = authenticated);
// If current route is different from the one setted in authentication guard
// and is not the login route, clear redirect url and messages
const routeUrl$ = this.store.select(routerStateSelector)
.filter((routerState: RouterReducerState) => isNotUndefined(routerState) && isNotUndefined(routerState.state))
.filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url))
.map((routerState: RouterReducerState) => routerState.state.url);
const redirectUrl$ = this.store.select(getRedirectUrl).distinctUntilChanged();
const routeUrl$ = this.store.pipe(
select(routerStateSelector),
filter((routerState: RouterReducerState) => isNotUndefined(routerState) && isNotUndefined(routerState.state)),
filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url)),
map((routerState: RouterReducerState) => routerState.state.url)
);
const redirectUrl$ = this.store.pipe(select(getRedirectUrl), distinctUntilChanged());
routeUrl$.pipe(
withLatestFrom(redirectUrl$),
map(([routeUrl, redirectUrl]) => [routeUrl, redirectUrl])
).filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl))
).pipe(filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl)))
.subscribe(() => {
this.clearRedirectUrl();
});
@@ -93,14 +114,15 @@ export class AuthService {
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
options.headers = headers;
return this.authRequestService.postToEndpoint('login', body, options)
.map((status: AuthStatus) => {
return this.authRequestService.postToEndpoint('login', body, options).pipe(
map((status: AuthStatus) => {
console.log('yey response');
if (status.authenticated) {
return status;
} else {
throw(new Error('Invalid email or password'));
}
})
}))
}
@@ -109,28 +131,33 @@ export class AuthService {
* @returns {Observable<boolean>}
*/
public isAuthenticated(): Observable<boolean> {
return this.store.select(isAuthenticated);
return this.store.pipe(select(isAuthenticated));
}
/**
* Returns the authenticated user
* @returns {User}
*/
public authenticatedUser(token: AuthTokenInfo): Observable<Eperson> {
public authenticatedUser(token: AuthTokenInfo): Observable<EPerson> {
// Determine if the user has an existing auth session on the server
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
options.headers = headers;
return this.authRequestService.getRequest('status', options)
.map((status: AuthStatus) => {
return this.authRequestService.getRequest('status', options).pipe(
switchMap((status: AuthStatus) => {
if (status.authenticated) {
return status.eperson;
// TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
// Review when https://jira.duraspace.org/browse/DS-4006 is fixed
// See https://github.com/DSpace/dspace-angular/issues/292
const person$ = this.rdbService.buildSingle<NormalizedEPerson, EPerson>(status.eperson.toString());
return person$.pipe(map((eperson) => eperson.payload));
} else {
throw(new Error('Not authenticated'));
}
});
}))
}
/**
@@ -144,9 +171,10 @@ export class AuthService {
* Checks if token is present into storage and is not expired
*/
public hasValidAuthenticationToken(): Observable<AuthTokenInfo> {
return this.store.select(getAuthenticationToken)
.take(1)
.map((authTokenInfo: AuthTokenInfo) => {
return this.store.pipe(
select(getAuthenticationToken),
take(1),
map((authTokenInfo: AuthTokenInfo) => {
let token: AuthTokenInfo;
// Retrieve authentication token info and check if is valid
token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM);
@@ -155,7 +183,8 @@ export class AuthService {
} else {
throw false;
}
});
})
);
}
/**
@@ -167,14 +196,14 @@ export class AuthService {
headers = headers.append('Accept', 'application/json');
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
options.headers = headers;
return this.authRequestService.postToEndpoint('login', {}, options)
.map((status: AuthStatus) => {
return this.authRequestService.postToEndpoint('login', {}, options).pipe(
map((status: AuthStatus) => {
if (status.authenticated) {
return status.token;
} else {
throw(new Error('Not authenticated'));
}
});
}));
}
/**
@@ -188,12 +217,12 @@ export class AuthService {
* Create a new user
* @returns {User}
*/
public create(user: Eperson): Observable<Eperson> {
public create(user: EPerson): Observable<EPerson> {
// Normally you would do an HTTP request to POST the user
// details and then return the new user object
// but, let's just return the new user for this example.
// this._authenticated = true;
return Observable.of(user);
return observableOf(user);
}
/**
@@ -204,15 +233,15 @@ export class AuthService {
// Send a request that sign end the session
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
const options: HttpOptions = Object.create({headers, responseType: 'text'});
return this.authRequestService.getRequest('logout', options)
.map((status: AuthStatus) => {
const options: HttpOptions = Object.create({ headers, responseType: 'text' });
return this.authRequestService.getRequest('logout', options).pipe(
map((status: AuthStatus) => {
if (!status.authenticated) {
return true;
} else {
throw(new Error('auth.errors.invalid-user'));
}
})
}))
}
@@ -233,7 +262,7 @@ export class AuthService {
*/
public getToken(): AuthTokenInfo {
let token: AuthTokenInfo;
this.store.select(getAuthenticationToken)
this.store.pipe(select(getAuthenticationToken))
.subscribe((authTokenInfo: AuthTokenInfo) => {
// Retrieve authentication token info and check if is valid
token = authTokenInfo || null;
@@ -246,9 +275,10 @@ export class AuthService {
* @returns {boolean}
*/
public isTokenExpiring(): Observable<boolean> {
return this.store.select(isTokenRefreshing)
.first()
.map((isRefreshing: boolean) => {
return this.store.pipe(
select(isTokenRefreshing),
first(),
map((isRefreshing: boolean) => {
if (this.isTokenExpired() || isRefreshing) {
return false;
} else {
@@ -256,6 +286,7 @@ export class AuthService {
return token.expires - (60 * 5 * 1000) < Date.now();
}
})
)
}
/**
@@ -279,7 +310,7 @@ export class AuthService {
// Set the cookie expire date
const expires = new Date(expireDate);
const options: CookieAttributes = {expires: expires};
const options: CookieAttributes = { expires: expires };
// Save cookie with the token
return this.storage.set(TOKENITEM, token, options);
@@ -324,8 +355,8 @@ export class AuthService {
* Redirect to the route navigated before the login
*/
public redirectToPreviousUrl() {
this.getRedirectUrl()
.first()
this.getRedirectUrl().pipe(
first())
.subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) {
this.clearRedirectUrl();
@@ -337,8 +368,12 @@ export class AuthService {
this.router.navigated = false;
const url = decodeURIComponent(redirectUrl);
this.router.navigateByUrl(url);
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = url;
} else {
this.router.navigate(['/']);
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = '/';
}
})
@@ -359,9 +394,9 @@ export class AuthService {
getRedirectUrl(): Observable<string> {
const redirectUrl = this.storage.get(REDIRECT_COOKIE);
if (isNotEmpty(redirectUrl)) {
return Observable.of(redirectUrl);
return observableOf(redirectUrl);
} else {
return this.store.select(getRedirectUrl);
return this.store.pipe(select(getRedirectUrl));
}
}
@@ -374,7 +409,7 @@ export class AuthService {
// Set the cookie expire date
const expires = new Date(expireDate);
const options: CookieAttributes = {expires: expires};
const options: CookieAttributes = { expires: expires };
this.storage.set(REDIRECT_COOKIE, url, options);
this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : ''));
}

View File

@@ -1,8 +1,10 @@
import {take} from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { select, Store } from '@ngrx/store';
// reducers
import { CoreState } from '../core.reducers';
@@ -52,12 +54,12 @@ export class AuthenticatedGuard implements CanActivate, CanLoad {
private handleAuth(url: string): Observable<boolean> {
// get observable
const observable = this.store.select(isAuthenticated);
const observable = this.store.pipe(select(isAuthenticated));
// redirect to sign in page if user is not authenticated
observable
observable.pipe(
// .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url)
.take(1)
take(1))
.subscribe((authenticated) => {
if (!authenticated) {
this.authService.setRedirectUrl(url);

View File

@@ -1,7 +1,8 @@
import { AuthError } from './auth-error.model';
import { AuthTokenInfo } from './auth-token-info.model';
import { DSpaceObject } from '../../shared/dspace-object.model';
import { Eperson } from '../../eperson/models/eperson.model';
import { EPerson } from '../../eperson/models/eperson.model';
import { RemoteData } from '../../data/remote-data';
import { Observable } from 'rxjs';
export class AuthStatus {
@@ -13,7 +14,7 @@ export class AuthStatus {
error?: AuthError;
eperson: Eperson;
eperson: Observable<RemoteData<EPerson>>;
token?: AuthTokenInfo;

View File

@@ -1,12 +1,18 @@
import { AuthStatus } from './auth-status.model';
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { mapsTo } from '../../cache/builders/build-decorators';
import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model';
import { Eperson } from '../../eperson/models/eperson.model';
import { mapsTo, relationship } from '../../cache/builders/build-decorators';
import { ResourceType } from '../../shared/resource-type';
import { NormalizedObject } from '../../cache/models/normalized-object.model';
import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer';
@mapsTo(AuthStatus)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedAuthStatus extends NormalizedDSpaceObject {
@inheritSerialization(NormalizedObject)
export class NormalizedAuthStatus extends NormalizedObject {
@autoserialize
id: string;
@autoserializeAs(new IDToUUIDSerializer('auth-status'), 'id')
uuid: string;
/**
* True if REST API is up and running, should never return false
@@ -20,7 +26,7 @@ export class NormalizedAuthStatus extends NormalizedDSpaceObject {
@autoserialize
authenticated: boolean;
@autoserializeAs(Eperson)
eperson: Eperson;
@relationship(ResourceType.EPerson, false)
@autoserialize
eperson: string;
}

View File

@@ -1,6 +1,7 @@
import { first, map, switchMap } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { HttpHeaders } from '@angular/common/http';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { AuthStatus } from './models/auth-status.model';
@@ -8,7 +9,8 @@ import { isNotEmpty } from '../../shared/empty.util';
import { AuthService } from './auth.service';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { CheckAuthenticationTokenAction } from './auth.actions';
import { Eperson } from '../eperson/models/eperson.model';
import { EPerson } from '../eperson/models/eperson.model';
import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model';
/**
* The auth service.
@@ -20,7 +22,7 @@ export class ServerAuthService extends AuthService {
* Returns the authenticated user
* @returns {User}
*/
public authenticatedUser(token: AuthTokenInfo): Observable<Eperson> {
public authenticatedUser(token: AuthTokenInfo): Observable<EPerson> {
// Determine if the user has an existing auth session on the server
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
@@ -32,14 +34,21 @@ export class ServerAuthService extends AuthService {
headers = headers.append('X-Forwarded-For', clientIp);
options.headers = headers;
return this.authRequestService.getRequest('status', options)
.map((status: AuthStatus) => {
return this.authRequestService.getRequest('status', options).pipe(
switchMap((status: AuthStatus) => {
if (status.authenticated) {
return status.eperson;
// TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
const person$ = this.rdbService.buildSingle<NormalizedEPerson, EPerson>(status.eperson.toString());
// person$.subscribe(() => console.log('test'));
return person$.pipe(
map((eperson) => eperson.payload)
);
} else {
throw(new Error('Not authenticated'));
}
});
}));
}
/**
@@ -53,8 +62,8 @@ export class ServerAuthService extends AuthService {
* Redirect to the route navigated before the login
*/
public redirectToPreviousUrl() {
this.getRedirectUrl()
.first()
this.getRedirectUrl().pipe(
first())
.subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) {
// override the route reuse strategy

View File

@@ -1,20 +1,19 @@
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/Rx';
import { TestScheduler } from 'rxjs/testing';
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { BrowseEndpointRequest, BrowseEntriesRequest } from '../data/request.models';
import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from '../data/request.models';
import { RequestService } from '../data/request.service';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { BrowseService } from './browse.service';
import { RequestEntry } from '../data/request.reducer';
import { of as observableOf } from 'rxjs';
describe('BrowseService', () => {
let scheduler: TestScheduler;
let service: BrowseService;
let responseCache: ResponseCacheService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
@@ -79,22 +78,14 @@ describe('BrowseService', () => {
})
];
function initMockResponseCacheService(isSuccessful: boolean) {
const rcs = getMockResponseCacheService();
(rcs.get as any).and.returnValue(cold('b-', {
b: {
response: {
isSuccessful,
payload: browseDefinitions,
}
}
}));
return rcs;
}
const getRequestEntry$ = (successful: boolean) => {
return observableOf({
response: { isSuccessful: successful, payload: browseDefinitions } as any
} as RequestEntry)
};
function initTestService() {
return new BrowseService(
responseCache,
requestService,
halService,
rdbService
@@ -108,8 +99,7 @@ describe('BrowseService', () => {
describe('getBrowseDefinitions', () => {
beforeEach(() => {
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService();
requestService = getMockRequestService(getRequestEntry$(true));
rdbService = getMockRemoteDataBuildService();
service = initTestService();
spyOn(halService, 'getEndpoint').and
@@ -143,10 +133,11 @@ describe('BrowseService', () => {
});
describe('getBrowseEntriesFor', () => {
describe('getBrowseEntriesFor and getBrowseItemsFor', () => {
const mockAuthorName = 'Donald Smith';
beforeEach(() => {
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService();
requestService = getMockRequestService(getRequestEntry$(true));
rdbService = getMockRemoteDataBuildService();
service = initTestService();
spyOn(service, 'getBrowseDefinitions').and
@@ -156,7 +147,7 @@ describe('BrowseService', () => {
spyOn(rdbService, 'toRemoteDataObservable').and.callThrough();
});
describe('when called with a valid browse definition id', () => {
describe('when getBrowseEntriesFor is called with a valid browse definition id', () => {
it('should configure a new BrowseEntriesRequest', () => {
const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries);
@@ -175,7 +166,26 @@ describe('BrowseService', () => {
});
describe('when called with an invalid browse definition id', () => {
describe('when getBrowseItemsFor is called with a valid browse definition id', () => {
it('should configure a new BrowseItemsRequest', () => {
const expected = new BrowseItemsRequest(requestService.generateRequestId(), browseDefinitions[1]._links.items + '?filterValue=' + mockAuthorName);
scheduler.schedule(() => service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
it('should call RemoteDataBuildService to create the RemoteData Observable', () => {
service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName);
expect(rdbService.toRemoteDataObservable).toHaveBeenCalled();
});
});
describe('when getBrowseEntriesFor is called with an invalid browse definition id', () => {
it('should throw an Error', () => {
const definitionID = 'invalidID';
@@ -184,14 +194,23 @@ describe('BrowseService', () => {
expect(service.getBrowseEntriesFor(definitionID)).toBeObservable(expected);
});
});
describe('when getBrowseItemsFor is called with an invalid browse definition id', () => {
it('should throw an Error', () => {
const definitionID = 'invalidID';
const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`))
expect(service.getBrowseItemsFor(definitionID, mockAuthorName)).toBeObservable(expected);
});
});
});
describe('getBrowseURLFor', () => {
describe('if getBrowseDefinitions fires', () => {
beforeEach(() => {
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService();
requestService = getMockRequestService(getRequestEntry$(true));
rdbService = getMockRemoteDataBuildService();
service = initTestService();
spyOn(service, 'getBrowseDefinitions').and
@@ -246,8 +265,7 @@ describe('BrowseService', () => {
describe('if getBrowseDefinitions doesn\'t fire', () => {
it('should return undefined', () => {
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService();
requestService = getMockRequestService(getRequestEntry$(true));
rdbService = getMockRemoteDataBuildService();
service = initTestService();
spyOn(service, 'getBrowseDefinitions').and

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { distinctUntilChanged, map, startWith } from 'rxjs/operators';
import {
ensureArrayHasValue,
@@ -11,12 +11,15 @@ import {
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortOptions } from '../cache/models/sort-options.model';
import { GenericSuccessResponse } from '../cache/response-cache.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { ResponseCacheService } from '../cache/response-cache.service';
import { GenericSuccessResponse } from '../cache/response.models';
import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data';
import { BrowseEndpointRequest, BrowseEntriesRequest, RestRequest } from '../data/request.models';
import {
BrowseEndpointRequest,
BrowseEntriesRequest,
BrowseItemsRequest,
RestRequest
} from '../data/request.models';
import { RequestService } from '../data/request.service';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { BrowseEntry } from '../shared/browse-entry.model';
@@ -24,11 +27,13 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import {
configureRequest,
filterSuccessfulResponses,
getBrowseDefinitionLinks,
getRemoteDataPayload,
getRequestFromSelflink,
getResponseFromSelflink
getRequestFromSelflink
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import { Item } from '../shared/item.model';
import { DSpaceObject } from '../shared/dspace-object.model';
@Injectable()
export class BrowseService {
@@ -48,7 +53,6 @@ export class BrowseService {
}
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected halService: HALEndpointService,
private rdb: RemoteDataBuildService,
@@ -65,16 +69,16 @@ export class BrowseService {
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const payload$ = responseCache$.pipe(
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<BrowseDefinition[]>) => response.payload),
ensureArrayHasValue(),
map((definitions: BrowseDefinition[]) => definitions
.map((definition: BrowseDefinition) => Object.assign(new BrowseDefinition(), definition))),
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
getBrowseEntriesFor(definitionID: string, options: {
@@ -82,17 +86,7 @@ export class BrowseService {
sort?: SortOptions;
} = {}): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
const request$ = this.getBrowseDefinitions().pipe(
getRemoteDataPayload(),
map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
.find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true)
),
map((def: BrowseDefinition) => {
if (isNotEmpty(def)) {
return def._links;
} else {
throw new Error(`No metadata browse definition could be found for id '${definitionID}'`);
}
}),
getBrowseDefinitionLinks(definitionID),
hasValueOperator(),
map((_links: any) => _links.entries),
hasValueOperator(),
@@ -118,16 +112,72 @@ export class BrowseService {
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const payload$ = responseCache$.pipe(
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<BrowseEntry[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<BrowseEntry>) => Object.assign(list, {
page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page
})),
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
/**
* Get all items linked to a certain metadata value
* @param {string} definitionID definition ID to define the metadata-field (e.g. author)
* @param {string} filterValue metadata value to filter by (e.g. author's name)
* @param options Options to narrow down your search:
* { pagination: PaginationComponentOptions,
* sort: SortOptions }
* @returns {Observable<RemoteData<PaginatedList<Item>>>}
*/
getBrowseItemsFor(definitionID: string, filterValue: string, options: {
pagination?: PaginationComponentOptions;
sort?: SortOptions;
} = {}): Observable<RemoteData<PaginatedList<Item>>> {
const request$ = this.getBrowseDefinitions().pipe(
getBrowseDefinitionLinks(definitionID),
hasValueOperator(),
map((_links: any) => _links.items),
hasValueOperator(),
map((href: string) => {
const args = [];
if (isNotEmpty(options.sort)) {
args.push(`sort=${options.sort.field},${options.sort.direction}`);
}
if (isNotEmpty(options.pagination)) {
args.push(`page=${options.pagination.currentPage - 1}`);
args.push(`size=${options.pagination.pageSize}`);
}
if (isNotEmpty(filterValue)) {
args.push(`filterValue=${filterValue}`);
}
if (isNotEmpty(args)) {
href = new URLCombiner(href, `?${args.join('&')}`).toString();
}
return href;
}),
map((endpointURL: string) => new BrowseItemsRequest(this.requestService.generateRequestId(), endpointURL)),
configureRequest(this.requestService)
);
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((response: GenericSuccessResponse<Item[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<Item>) => Object.assign(list, {
page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page
})),
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
getBrowseURLFor(metadatumKey: string, linkPath: string): Observable<string> {

View File

@@ -1,6 +1,19 @@
import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
race as observableRace
} from 'rxjs';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { distinctUntilChanged, flatMap, map, startWith } from 'rxjs/operators';
import {
distinctUntilChanged,
first,
flatMap,
map,
startWith,
switchMap,
takeUntil, tap
} from 'rxjs/operators';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { PaginatedList } from '../../data/paginated-list';
import { RemoteData } from '../../data/remote-data';
@@ -11,72 +24,63 @@ import { RequestService } from '../../data/request.service';
import { NormalizedObject } from '../models/normalized-object.model';
import { ObjectCacheService } from '../object-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models';
import { ResponseCacheEntry } from '../response-cache.reducer';
import { ResponseCacheService } from '../response-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response.models';
import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators';
import { PageInfo } from '../../shared/page-info.model';
import {
filterSuccessfulResponses,
getRequestFromSelflink,
getResourceLinksFromResponse,
getResponseFromSelflink,
filterSuccessfulResponses
getResourceLinksFromResponse
} from '../../shared/operators';
import { ReplaySubject } from 'rxjs/ReplaySubject';
@Injectable()
export class RemoteDataBuildService {
constructor(protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService) {
}
buildSingle<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<TDomain>> {
if (typeof href$ === 'string') {
href$ = Observable.of(href$);
href$ = observableOf(href$);
}
href$ = href$.multicast(new ReplaySubject(1)).refCount();
const requestHref$ = href$.pipe(flatMap((href: string) =>
this.objectCache.getRequestHrefBySelfLink(href)));
const requestEntry$ = Observable.race(
href$.pipe(getRequestFromSelflink(this.requestService)),
requestHref$.pipe(getRequestFromSelflink(this.requestService))
const requestHref$ = href$.pipe(
switchMap((href: string) =>
this.objectCache.getRequestHrefBySelfLink(href)),
);
const responseCache$ = Observable.race(
href$.pipe(getResponseFromSelflink(this.responseCache)),
requestHref$.pipe(getResponseFromSelflink(this.responseCache))
const requestEntry$ = observableRace(
href$.pipe(getRequestFromSelflink(this.requestService)),
requestHref$.pipe(getRequestFromSelflink(this.requestService)),
).pipe(
first()
);
// always use self link if that is cached, only if it isn't, get it via the response.
const payload$ =
Observable.combineLatest(
observableCombineLatest(
href$.pipe(
flatMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)),
startWith(undefined)
),
responseCache$.pipe(
switchMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)),
startWith(undefined)),
requestEntry$.pipe(
getResourceLinksFromResponse(),
flatMap((resourceSelfLinks: string[]) => {
switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getBySelfLink(resourceSelfLinks[0]);
} else {
return Observable.of(undefined);
return observableOf(undefined);
}
}),
distinctUntilChanged(),
startWith(undefined)
),
(fromSelfLink, fromResponse) => {
)
).pipe(
map(([fromSelfLink, fromResponse]) => {
if (hasValue(fromSelfLink)) {
return fromSelfLink;
} else {
return fromResponse;
}
}
).pipe(
}),
hasValueOperator(),
map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized);
@@ -84,21 +88,21 @@ export class RemoteDataBuildService {
startWith(undefined),
distinctUntilChanged()
);
return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
return this.toRemoteDataObservable(requestEntry$, payload$);
}
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, responseCache$: Observable<ResponseCacheEntry>, payload$: Observable<T>) {
return Observable.combineLatest(requestEntry$, responseCache$.startWith(undefined), payload$,
(reqEntry: RequestEntry, resEntry: ResponseCacheEntry, payload: T) => {
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, payload$: Observable<T>) {
return observableCombineLatest(requestEntry$, payload$).pipe(
map(([reqEntry, payload]) => {
const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
let isSuccessful: boolean;
let error: RemoteDataError;
if (hasValue(resEntry) && hasValue(resEntry.response)) {
isSuccessful = resEntry.response.isSuccessful;
const errorMessage = isSuccessful === false ? (resEntry.response as ErrorResponse).errorMessage : undefined;
if (hasValue(reqEntry) && hasValue(reqEntry.response)) {
isSuccessful = reqEntry.response.isSuccessful;
const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined;
if (hasValue(errorMessage)) {
error = new RemoteDataError(resEntry.response.statusCode, errorMessage);
error = new RemoteDataError(reqEntry.response.statusCode, errorMessage);
}
}
return new RemoteData(
@@ -108,37 +112,34 @@ export class RemoteDataBuildService {
error,
payload
);
});
})
);
}
buildList<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<PaginatedList<TDomain>>> {
if (typeof href$ === 'string') {
href$ = Observable.of(href$);
href$ = observableOf(href$);
}
href$ = href$.shareReplay();
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const tDomainList$ = responseCache$.pipe(
const tDomainList$ = requestEntry$.pipe(
getResourceLinksFromResponse(),
flatMap((resourceUUIDs: string[]) => {
return this.objectCache.getList(resourceUUIDs)
.map((normList: TNormalized[]) => {
return this.objectCache.getList(resourceUUIDs).pipe(
map((normList: TNormalized[]) => {
return normList.map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized);
});
});
}));
}),
startWith([]),
distinctUntilChanged()
distinctUntilChanged(),
);
const pageInfo$ = responseCache$.pipe(
const pageInfo$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => {
if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) {
const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo;
map((response: DSOSuccessResponse) => {
if (hasValue((response as DSOSuccessResponse).pageInfo)) {
const resPageInfo = (response as DSOSuccessResponse).pageInfo;
if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) {
return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 });
} else {
@@ -146,18 +147,19 @@ export class RemoteDataBuildService {
}
}
})
);
);
const payload$ = Observable.combineLatest(tDomainList$, pageInfo$, (tDomainList, pageInfo) => {
return new PaginatedList(pageInfo, tDomainList);
});
const payload$ = observableCombineLatest(tDomainList$, pageInfo$).pipe(
map(([tDomainList, pageInfo]) => {
return new PaginatedList(pageInfo, tDomainList);
})
);
return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
return this.toRemoteDataObservable(requestEntry$, payload$);
}
build<TNormalized, TDomain>(normalized: TNormalized): TDomain {
const links: any = {};
const relationships = getRelationships(normalized.constructor) || [];
relationships.forEach((relationship: string) => {
@@ -200,7 +202,6 @@ export class RemoteDataBuildService {
}
}
});
const domainModel = getMapsTo(normalized.constructor);
return Object.assign(new domainModel(), normalized, links);
}
@@ -208,12 +209,11 @@ export class RemoteDataBuildService {
aggregate<T>(input: Array<Observable<RemoteData<T>>>): Observable<RemoteData<T[]>> {
if (isEmpty(input)) {
return Observable.of(new RemoteData(false, false, true, null, []));
return observableOf(new RemoteData(false, false, true, null, []));
}
return Observable.combineLatest(
...input,
(...arr: Array<RemoteData<T>>) => {
return observableCombineLatest(...input).pipe(
map((arr) => {
const requestPending: boolean = arr
.map((d: RemoteData<T>) => d.isRequestPending)
.every((b: boolean) => b === true);
@@ -255,11 +255,11 @@ export class RemoteDataBuildService {
error,
payload
);
})
}))
}
aggregatePaginatedList<T>(input: Observable<RemoteData<T[]>>, pageInfo: PageInfo): Observable<RemoteData<PaginatedList<T>>> {
return input.map((rd) => Object.assign(rd, {payload: new PaginatedList(pageInfo, rd.payload)}));
return input.pipe(map((rd) => Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload) })));
}
}

View File

@@ -8,6 +8,8 @@ import { ResourceType } from '../../shared/resource-type';
import { NormalizedObject } from './normalized-object.model';
import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model';
import { NormalizedResourcePolicy } from './normalized-resource-policy.model';
import { NormalizedEPerson } from '../../eperson/models/normalized-eperson.model';
import { NormalizedGroup } from '../../eperson/models/normalized-group.model';
export class NormalizedObjectFactory {
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject> {
@@ -33,6 +35,12 @@ export class NormalizedObjectFactory {
case ResourceType.ResourcePolicy: {
return NormalizedResourcePolicy
}
case ResourceType.EPerson: {
return NormalizedEPerson
}
case ResourceType.Group: {
return NormalizedGroup
}
default: {
return undefined;
}

View File

@@ -2,6 +2,7 @@ import { Action } from '@ngrx/store';
import { type } from '../../shared/ngrx/type';
import { CacheableObject } from './object-cache.reducer';
import { Operation } from 'fast-json-patch';
/**
* The list of ObjectCacheAction type definitions
@@ -9,7 +10,9 @@ import { CacheableObject } from './object-cache.reducer';
export const ObjectCacheActionTypes = {
ADD: type('dspace/core/cache/object/ADD'),
REMOVE: type('dspace/core/cache/object/REMOVE'),
RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS')
RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'),
ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'),
APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH')
};
/* tslint:disable:max-classes-per-file */
@@ -54,11 +57,11 @@ export class RemoveFromObjectCacheAction implements Action {
/**
* Create a new RemoveFromObjectCacheAction
*
* @param uuid
* the UUID of the object to remove
* @param href
* the unique href of the object to remove
*/
constructor(uuid: string) {
this.payload = uuid;
constructor(href: string) {
this.payload = href;
}
}
@@ -79,6 +82,48 @@ export class ResetObjectCacheTimestampsAction implements Action {
this.payload = newTimestamp;
}
}
/**
* An ngrx action to add new operations to a specified cached object
*/
export class AddPatchObjectCacheAction implements Action {
type = ObjectCacheActionTypes.ADD_PATCH;
payload: {
href: string,
operations: Operation[]
};
/**
* Create a new AddPatchObjectCacheAction
*
* @param href
* the unique href of the object that should be updated
* @param operations
* the list of operations to add
*/
constructor(href: string, operations: Operation[]) {
this.payload = { href, operations };
}
}
/**
* An ngrx action to apply all existing operations to a specified cached object
*/
export class ApplyPatchObjectCacheAction implements Action {
type = ObjectCacheActionTypes.APPLY_PATCH;
payload: string;
/**
* Create a new ApplyPatchObjectCacheAction
*
* @param href
* the unique href of the object that should be updated
*/
constructor(href: string) {
this.payload = href;
}
}
/* tslint:enable:max-classes-per-file */
/**
@@ -87,4 +132,6 @@ export class ResetObjectCacheTimestampsAction implements Action {
export type ObjectCacheAction
= AddToObjectCacheAction
| RemoveFromObjectCacheAction
| ResetObjectCacheTimestampsAction;
| ResetObjectCacheTimestampsAction
| AddPatchObjectCacheAction
| ApplyPatchObjectCacheAction;

View File

@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jasmine-marbles';
import { ObjectCacheEffects } from './object-cache.effects';

View File

@@ -1,5 +1,6 @@
import { map } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { StoreActionTypes } from '../../store.actions';
import { ResetObjectCacheTimestampsAction } from './object-cache.actions';
@@ -16,9 +17,11 @@ export class ObjectCacheEffects {
* time ago, and will likely need to be revisited later
*/
@Effect() fixTimestampsOnRehydrate = this.actions$
.ofType(StoreActionTypes.REHYDRATE)
.map(() => new ResetObjectCacheTimestampsAction(new Date().getTime()));
.pipe(ofType(StoreActionTypes.REHYDRATE),
map(() => new ResetObjectCacheTimestampsAction(new Date().getTime()))
);
constructor(private actions$: Actions) { }
constructor(private actions$: Actions) {
}
}

View File

@@ -2,8 +2,11 @@ import * as deepFreeze from 'deep-freeze';
import { objectCacheReducer } from './object-cache.reducer';
import {
AddPatchObjectCacheAction,
AddToObjectCacheAction,
RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction
ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction,
ResetObjectCacheTimestampsAction
} from './object-cache.actions';
class NullAction extends RemoveFromObjectCacheAction {
@@ -26,7 +29,9 @@ describe('objectCacheReducer', () => {
},
timeAdded: new Date().getTime(),
msToLive: 900000,
requestHref: selfLink1
requestHref: selfLink1,
patches: [],
isDirty: false
},
[selfLink2]: {
data: {
@@ -35,7 +40,9 @@ describe('objectCacheReducer', () => {
},
timeAdded: new Date().getTime(),
msToLive: 900000,
requestHref: selfLink2
requestHref: selfLink2,
patches: [],
isDirty: false
}
};
deepFreeze(testState);
@@ -132,4 +139,16 @@ describe('objectCacheReducer', () => {
objectCacheReducer(testState, action);
});
it('should perform the ADD_PATCH action without affecting the previous state', () => {
const action = new AddPatchObjectCacheAction(selfLink1, [{ op: 'replace', path: '/name', value: 'random string' }]);
// testState has already been frozen above
objectCacheReducer(testState, action);
});
it('should perform the APPLY_PATCH action without affecting the previous state', () => {
const action = new ApplyPatchObjectCacheAction(selfLink1);
// testState has already been frozen above
objectCacheReducer(testState, action);
});
});

View File

@@ -1,10 +1,15 @@
import {
ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction,
RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction
ObjectCacheAction,
ObjectCacheActionTypes,
AddToObjectCacheAction,
RemoveFromObjectCacheAction,
ResetObjectCacheTimestampsAction,
AddPatchObjectCacheAction, ApplyPatchObjectCacheAction
} from './object-cache.actions';
import { hasValue } from '../../shared/empty.util';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { CacheEntry } from './cache-entry';
import { ResourceType } from '../shared/resource-type';
import { applyPatch, Operation } from 'fast-json-patch';
export enum DirtyType {
Created = 'Created',
@@ -12,7 +17,12 @@ export enum DirtyType {
Deleted = 'Deleted'
}
/**
export interface Patch {
uuid?: string;
operations: Operation[];
}
/**conca
* An interface to represent objects that can be cached
*
* A cacheable object should have a self link
@@ -36,6 +46,8 @@ export class ObjectCacheEntry implements CacheEntry {
timeAdded: number;
msToLive: number;
requestHref: string;
patches: Patch[] = [];
isDirty: boolean;
}
/**
@@ -76,6 +88,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi
return resetObjectCacheTimestamps(state, action as ResetObjectCacheTimestampsAction)
}
case ObjectCacheActionTypes.ADD_PATCH: {
return addPatchObjectCache(state, action as AddPatchObjectCacheAction);
}
case ObjectCacheActionTypes.APPLY_PATCH: {
return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction);
}
default: {
return state;
}
@@ -93,12 +113,15 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi
* the new state, with the object added, or overwritten.
*/
function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState {
const existing = state[action.payload.objectToCache.self];
return Object.assign({}, state, {
[action.payload.objectToCache.self]: {
data: action.payload.objectToCache,
timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive,
requestHref: action.payload.requestHref
requestHref: action.payload.requestHref,
isDirty: (hasValue(existing) ? isNotEmpty(existing.patches) : false),
patches: (hasValue(existing) ? existing.patches : [])
}
});
}
@@ -143,3 +166,49 @@ function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObject
});
return newState;
}
/**
* Add the list of patch operations to a cached object
*
* @param state
* the current state
* @param action
* an AddPatchObjectCacheAction
* @return ObjectCacheState
* the new state, with the new operations added to the state of the specified ObjectCacheEntry
*/
function addPatchObjectCache(state: ObjectCacheState, action: AddPatchObjectCacheAction): ObjectCacheState {
const uuid = action.payload.href;
const operations = action.payload.operations;
const newState = Object.assign({}, state);
if (hasValue(newState[uuid])) {
const patches = newState[uuid].patches;
newState[uuid] = Object.assign({}, newState[uuid], {
patches: [...patches, { operations } as Patch],
isDirty: true
});
}
return newState;
}
/**
* Apply the list of patch operations to a cached object
*
* @param state
* the current state
* @param action
* an ApplyPatchObjectCacheAction
* @return ObjectCacheState
* the new state, with the new operations applied to the state of the specified ObjectCacheEntry
*/
function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObjectCacheAction): ObjectCacheState {
const uuid = action.payload;
const newState = Object.assign({}, state);
if (hasValue(newState[uuid])) {
// flatten two dimensional array
const flatPatch: Operation[] = [].concat(...newState[uuid].patches.map((patch) => patch.operations));
const newData = applyPatch(newState[uuid].data, flatPatch, undefined, false);
newState[uuid] = Object.assign({}, newState[uuid], { data: newData.newDocument, patches: [] });
}
return newState;
}

View File

@@ -1,11 +1,21 @@
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { of as observableOf } from 'rxjs';
import { ObjectCacheService } from './object-cache.service';
import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions';
import {
AddPatchObjectCacheAction,
AddToObjectCacheAction, ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction
} from './object-cache.actions';
import { CoreState } from '../core.reducers';
import { ResourceType } from '../shared/resource-type';
import { NormalizedItem } from './models/normalized-item.model';
import { first } from 'rxjs/operators';
import * as ngrx from '@ngrx/store';
import { Operation } from '../../../../node_modules/fast-json-patch';
import { RestRequestMethod } from '../data/rest-request-method';
import { AddToSSBAction } from './server-sync-buffer.actions';
import { Patch } from './object-cache.reducer';
describe('ObjectCacheService', () => {
let service: ObjectCacheService;
@@ -14,18 +24,29 @@ describe('ObjectCacheService', () => {
const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const timestamp = new Date().getTime();
const msToLive = 900000;
const objectToCache = {
let objectToCache = {
self: selfLink,
type: ResourceType.Item
};
const cacheEntry = {
data: objectToCache,
timeAdded: timestamp,
msToLive: msToLive
};
const invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 });
let cacheEntry;
let invalidCacheEntry;
const operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation];
function init() {
objectToCache = {
self: selfLink,
type: ResourceType.Item
};
cacheEntry = {
data: objectToCache,
timeAdded: timestamp,
msToLive: msToLive
};
invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 })
}
beforeEach(() => {
init();
store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = new ObjectCacheService(store);
@@ -51,10 +72,14 @@ describe('ObjectCacheService', () => {
describe('getBySelfLink', () => {
it('should return an observable of the cached object with the specified self link and type', () => {
spyOn(store, 'select').and.returnValue(Observable.of(cacheEntry));
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(cacheEntry);
};
});
// due to the implementation of spyOn above, this subscribe will be synchronous
service.getBySelfLink(selfLink).take(1).subscribe((o) => {
service.getBySelfLink(selfLink).pipe(first()).subscribe((o) => {
expect(o.self).toBe(selfLink);
// this only works if testObj is an instance of TestClass
expect(o instanceof NormalizedItem).toBeTruthy();
@@ -63,7 +88,11 @@ describe('ObjectCacheService', () => {
});
it('should not return a cached object that has exceeded its time to live', () => {
spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry));
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(invalidCacheEntry);
};
});
let getObsHasFired = false;
const subscription = service.getBySelfLink(selfLink).subscribe((o) => getObsHasFired = true);
@@ -76,9 +105,9 @@ describe('ObjectCacheService', () => {
it('should return an observable of the array of cached objects with the specified self link and type', () => {
const item = new NormalizedItem();
item.self = selfLink;
spyOn(service, 'getBySelfLink').and.returnValue(Observable.of(item));
spyOn(service, 'getBySelfLink').and.returnValue(observableOf(item));
service.getList([selfLink, selfLink]).take(1).subscribe((arr) => {
service.getList([selfLink, selfLink]).pipe(first()).subscribe((arr) => {
expect(arr[0].self).toBe(selfLink);
expect(arr[0] instanceof NormalizedItem).toBeTruthy();
});
@@ -87,22 +116,60 @@ describe('ObjectCacheService', () => {
describe('has', () => {
it('should return true if the object with the supplied self link is cached and still valid', () => {
spyOn(store, 'select').and.returnValue(Observable.of(cacheEntry));
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(cacheEntry);
};
});
expect(service.hasBySelfLink(selfLink)).toBe(true);
});
it("should return false if the object with the supplied self link isn't cached", () => {
spyOn(store, 'select').and.returnValue(Observable.of(undefined));
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(undefined);
};
});
expect(service.hasBySelfLink(selfLink)).toBe(false);
});
it('should return false if the object with the supplied self link is cached but has exceeded its time to live', () => {
spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry));
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(invalidCacheEntry);
};
});
expect(service.hasBySelfLink(selfLink)).toBe(false);
});
});
describe('patch methods', () => {
it('should dispatch the correct actions when addPatch is called', () => {
service.addPatch(selfLink, operations);
expect(store.dispatch).toHaveBeenCalledWith(new AddPatchObjectCacheAction(selfLink, operations));
expect(store.dispatch).toHaveBeenCalledWith(new AddToSSBAction(selfLink, RestRequestMethod.PATCH));
});
it('isDirty should return true when the patches list in the cache entry is not empty', () => {
cacheEntry.patches = [
{
operations: operations
} as Patch];
const result = (service as any).isDirty(cacheEntry);
expect(result).toBe(true);
});
it('isDirty should return false when the patches list in the cache entry is empty', () => {
cacheEntry.patches = [];
const result = (service as any).isDirty(cacheEntry);
expect(result).toBe(false);
});
it('should dispatch the correct actions when applyPatchesToCachedObject is called', () => {
(service as any).applyPatchesToCachedObject(selfLink);
expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink));
});
});
});

View File

@@ -1,25 +1,33 @@
import { Injectable } from '@angular/core';
import { MemoizedSelector, Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Observable } from 'rxjs/Observable';
import { distinctUntilChanged, filter, first, map, mergeMap, } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { IndexName } from '../index/index.reducer';
import { ObjectCacheEntry, CacheableObject } from './object-cache.reducer';
import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions';
import { hasNoValue } from '../../shared/empty.util';
import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer';
import {
AddPatchObjectCacheAction,
AddToObjectCacheAction,
ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction
} from './object-cache.actions';
import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
import { GenericConstructor } from '../shared/generic-constructor';
import { coreSelector, CoreState } from '../core.reducers';
import { pathSelector } from '../shared/selectors';
import { Item } from '../shared/item.model';
import { NormalizedObjectFactory } from './models/normalized-object-factory';
import { NormalizedObject } from './models/normalized-object.model';
import { applyPatch, Operation } from 'fast-json-patch';
import { AddToSSBAction } from './server-sync-buffer.actions';
import { RestRequestMethod } from '../data/rest-request-method';
function selfLinkFromUuidSelector(uuid: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.OBJECT, uuid);
}
function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector<CoreState, ObjectCacheEntry> {
return pathSelector<CoreState, ObjectCacheEntry>(coreSelector, 'data/object', selfLink);
return pathSelector<CoreState, ObjectCacheEntry>(coreSelector, 'cache/object', selfLink);
}
/**
@@ -47,10 +55,10 @@ export class ObjectCacheService {
}
/**
* Remove the object with the supplied UUID from the cache
* Remove the object with the supplied href from the cache
*
* @param uuid
* The UUID of the object to be removed
* @param href
* The unique href of the object to be removed
*/
remove(uuid: string): void {
this.store.dispatch(new RemoveFromObjectCacheAction(uuid));
@@ -73,33 +81,51 @@ export class ObjectCacheService {
* An observable of the requested object
*/
getByUUID<T extends NormalizedObject>(uuid: string): Observable<T> {
return this.store.select(selfLinkFromUuidSelector(uuid))
.flatMap((selfLink: string) => this.getBySelfLink(selfLink))
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
mergeMap((selfLink: string) => this.getBySelfLink(selfLink)
)
)
}
getBySelfLink<T extends NormalizedObject>(selfLink: string): Observable<T> {
return this.getEntry(selfLink)
.map((entry: ObjectCacheEntry) => {
const type: GenericConstructor<NormalizedObject>= NormalizedObjectFactory.getConstructor(entry.data.type);
return this.getEntry(selfLink).pipe(
map((entry: ObjectCacheEntry) => {
if (isNotEmpty(entry.patches)) {
const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations));
const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument;
return Object.assign({}, entry, { data: patchedData });
} else {
return entry;
}
}
),
map((entry: ObjectCacheEntry) => {
const type: GenericConstructor<NormalizedObject> = NormalizedObjectFactory.getConstructor(entry.data.type);
return Object.assign(new type(), entry.data) as T
});
})
);
}
private getEntry(selfLink: string): Observable<ObjectCacheEntry> {
return this.store.select(entryFromSelfLinkSelector(selfLink))
.filter((entry) => this.isValid(entry))
.distinctUntilChanged();
return this.store.pipe(
select(entryFromSelfLinkSelector(selfLink)),
filter((entry) => this.isValid(entry)),
distinctUntilChanged()
);
}
getRequestHrefBySelfLink(selfLink: string): Observable<string> {
return this.getEntry(selfLink)
.map((entry: ObjectCacheEntry) => entry.requestHref)
.distinctUntilChanged();
return this.getEntry(selfLink).pipe(
map((entry: ObjectCacheEntry) => entry.requestHref),
distinctUntilChanged());
}
getRequestHrefByUUID(uuid: string): Observable<string> {
return this.store.select(selfLinkFromUuidSelector(uuid))
.flatMap((selfLink: string) => this.getRequestHrefBySelfLink(selfLink));
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
mergeMap((selfLink: string) => this.getRequestHrefBySelfLink(selfLink))
);
}
/**
@@ -122,7 +148,7 @@ export class ObjectCacheService {
* @return Observable<Array<T>>
*/
getList<T extends NormalizedObject>(selfLinks: string[]): Observable<T[]> {
return Observable.combineLatest(
return observableCombineLatest(
selfLinks.map((selfLink: string) => this.getBySelfLink<T>(selfLink))
);
}
@@ -139,9 +165,10 @@ export class ObjectCacheService {
hasByUUID(uuid: string): boolean {
let result: boolean;
this.store.select(selfLinkFromUuidSelector(uuid))
.take(1)
.subscribe((selfLink: string) => result = this.hasBySelfLink(selfLink));
this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
first()
).subscribe((selfLink: string) => result = this.hasBySelfLink(selfLink));
return result;
}
@@ -158,9 +185,9 @@ export class ObjectCacheService {
hasBySelfLink(selfLink: string): boolean {
let result = false;
this.store.select(entryFromSelfLinkSelector(selfLink))
.take(1)
.subscribe((entry: ObjectCacheEntry) => result = this.isValid(entry));
this.store.pipe(select(entryFromSelfLinkSelector(selfLink)),
first()
).subscribe((entry: ObjectCacheEntry) => result = this.isValid(entry));
return result;
}
@@ -187,4 +214,39 @@ export class ObjectCacheService {
}
}
/**
* Add operations to the existing list of operations for an ObjectCacheEntry
* Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated
* @param {string} uuid
* the uuid of the ObjectCacheEntry
* @param {Operation[]} patch
* list of operations to perform
*/
public addPatch(selfLink: string, patch: Operation[]) {
this.store.dispatch(new AddPatchObjectCacheAction(selfLink, patch));
this.store.dispatch(new AddToSSBAction(selfLink, RestRequestMethod.PATCH));
}
/**
* Check whether there are any unperformed operations for an ObjectCacheEntry
*
* @param entry
* the entry to check
* @return boolean
* false if the entry is there are no operations left in the ObjectCacheEntry, true otherwise
*/
private isDirty(entry: ObjectCacheEntry): boolean {
return isNotEmpty(entry.patches);
}
/**
* Apply the existing operations on an ObjectCacheEntry in the store
* NB: this does not make any server side changes
* @param {string} uuid
* the uuid of the ObjectCacheEntry
*/
private applyPatchesToCachedObject(selfLink: string) {
this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink));
}
}

View File

@@ -1,72 +0,0 @@
import { Action } from '@ngrx/store';
import { type } from '../../shared/ngrx/type';
import { RestResponse } from './response-cache.models';
/**
* The list of ResponseCacheAction type definitions
*/
export const ResponseCacheActionTypes = {
ADD: type('dspace/core/cache/response/ADD'),
REMOVE: type('dspace/core/cache/response/REMOVE'),
RESET_TIMESTAMPS: type('dspace/core/cache/response/RESET_TIMESTAMPS')
};
/* tslint:disable:max-classes-per-file */
export class ResponseCacheAddAction implements Action {
type = ResponseCacheActionTypes.ADD;
payload: {
key: string,
response: RestResponse
timeAdded: number;
msToLive: number;
};
constructor(key: string, response: RestResponse, timeAdded: number, msToLive: number) {
this.payload = { key, response, timeAdded, msToLive };
}
}
/**
* An ngrx action to remove a request from the cache
*/
export class ResponseCacheRemoveAction implements Action {
type = ResponseCacheActionTypes.REMOVE;
payload: string;
/**
* Create a new ResponseCacheRemoveAction
* @param key
* The key of the request to remove
*/
constructor(key: string) {
this.payload = key;
}
}
/**
* An ngrx action to reset the timeAdded property of all cached objects
*/
export class ResetResponseCacheTimestampsAction implements Action {
type = ResponseCacheActionTypes.RESET_TIMESTAMPS;
payload: number;
/**
* Create a new ResetObjectCacheTimestampsAction
*
* @param newTimestamp
* the new timeAdded all objects should get
*/
constructor(newTimestamp: number) {
this.payload = newTimestamp;
}
}
/* tslint:enable:max-classes-per-file */
/**
* A type to encompass all ResponseCacheActions
*/
export type ResponseCacheAction
= ResponseCacheAddAction
| ResponseCacheRemoveAction
| ResetResponseCacheTimestampsAction;

View File

@@ -1,38 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jasmine-marbles';
import { StoreActionTypes } from '../../store.actions';
import { ResponseCacheEffects } from './response-cache.effects';
import { ResetResponseCacheTimestampsAction } from './response-cache.actions';
describe('ResponseCacheEffects', () => {
let cacheEffects: ResponseCacheEffects;
let actions: Observable<any>;
const timestamp = 10000;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ResponseCacheEffects,
provideMockActions(() => actions),
// other providers
],
});
cacheEffects = TestBed.get(ResponseCacheEffects);
});
describe('fixTimestampsOnRehydrate$', () => {
it('should return a RESET_TIMESTAMPS action in response to a REHYDRATE action', () => {
spyOn(Date.prototype, 'getTime').and.callFake(() => {
return timestamp;
});
actions = hot('--a-', { a: { type: StoreActionTypes.REHYDRATE, payload: {} } });
const expected = cold('--b-', { b: new ResetResponseCacheTimestampsAction(new Date().getTime()) });
expect(cacheEffects.fixTimestampsOnRehydrate).toBeObservable(expected);
});
});
});

View File

@@ -1,24 +0,0 @@
import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { ResetResponseCacheTimestampsAction } from './response-cache.actions';
import { StoreActionTypes } from '../../store.actions';
@Injectable()
export class ResponseCacheEffects {
/**
* When the store is rehydrated in the browser, set all cache
* timestamps to 'now', because the time zone of the server can
* differ from the client.
*
* This assumes that the server cached everything a negligible
* time ago, and will likely need to be revisited later
*/
@Effect() fixTimestampsOnRehydrate = this.actions$
.ofType(StoreActionTypes.REHYDRATE)
.map(() => new ResetResponseCacheTimestampsAction(new Date().getTime()));
constructor(private actions$: Actions, ) { }
}

View File

@@ -1,124 +0,0 @@
import * as deepFreeze from 'deep-freeze';
import { responseCacheReducer, ResponseCacheState } from './response-cache.reducer';
import {
ResponseCacheRemoveAction,
ResetResponseCacheTimestampsAction, ResponseCacheAddAction
} from './response-cache.actions';
import { RestResponse } from './response-cache.models';
class NullAction extends ResponseCacheRemoveAction {
type = null;
payload = null;
constructor() {
super(null);
}
}
describe('responseCacheReducer', () => {
const keys = ['125c17f89046283c5f0640722aac9feb', 'a06c3006a41caec5d635af099b0c780c'];
const msToLive = 900000;
const uuids = [
'9e32a2e2-6b91-4236-a361-995ccdc14c60',
'598ce822-c357-46f3-ab70-63724d02d6ad',
'be8325f7-243b-49f4-8a4b-df2b793ff3b5'
];
const testState: ResponseCacheState = {
[keys[0]]: {
key: keys[0],
response: new RestResponse(true, '200'),
timeAdded: new Date().getTime(),
msToLive: msToLive
},
[keys[1]]: {
key: keys[1],
response: new RestResponse(true, '200'),
timeAdded: new Date().getTime(),
msToLive: msToLive
}
};
deepFreeze(testState);
const errorState: {} = {
[keys[0]]: {
errorMessage: 'error',
resourceUUIDs: uuids
}
};
deepFreeze(errorState);
it('should return the current state when no valid actions have been made', () => {
const action = new NullAction();
const newState = responseCacheReducer(testState, action);
expect(newState).toEqual(testState);
});
it('should start with an empty cache', () => {
const action = new NullAction();
const initialState = responseCacheReducer(undefined, action);
expect(initialState).toEqual(Object.create(null));
});
describe('ADD', () => {
const addTimeAdded = new Date().getTime();
const addMsToLive = 5;
const addResponse = new RestResponse(true, '200');
const action = new ResponseCacheAddAction(keys[0], addResponse, addTimeAdded, addMsToLive);
it('should perform the action without affecting the previous state', () => {
// testState has already been frozen above
responseCacheReducer(testState, action);
});
it('should add the response to the cached request', () => {
const newState = responseCacheReducer(testState, action);
expect(newState[keys[0]].timeAdded).toBe(addTimeAdded);
expect(newState[keys[0]].msToLive).toBe(addMsToLive);
expect(newState[keys[0]].response).toBe(addResponse);
});
});
describe('REMOVE', () => {
it('should perform the action without affecting the previous state', () => {
const action = new ResponseCacheRemoveAction(keys[0]);
// testState has already been frozen above
responseCacheReducer(testState, action);
});
it('should remove the specified request from the cache', () => {
const action = new ResponseCacheRemoveAction(keys[0]);
const newState = responseCacheReducer(testState, action);
expect(testState[keys[0]]).not.toBeUndefined();
expect(newState[keys[0]]).toBeUndefined();
});
it('shouldn\'t do anything when the specified key isn\'t cached', () => {
const wrongKey = 'this isn\'t cached';
const action = new ResponseCacheRemoveAction(wrongKey);
const newState = responseCacheReducer(testState, action);
expect(testState[wrongKey]).toBeUndefined();
expect(newState).toEqual(testState);
});
});
describe('RESET_TIMESTAMPS', () => {
const newTimeStamp = new Date().getTime();
const action = new ResetResponseCacheTimestampsAction(newTimeStamp);
it('should perform the action without affecting the previous state', () => {
// testState has already been frozen above
responseCacheReducer(testState, action);
});
it('should set the timestamp of all requests in the cache', () => {
const newState = responseCacheReducer(testState, action);
Object.keys(newState).forEach((key) => {
expect(newState[key].timeAdded).toEqual(newTimeStamp);
});
});
});
});

View File

@@ -1,111 +0,0 @@
import {
ResponseCacheAction, ResponseCacheActionTypes,
ResponseCacheRemoveAction, ResetResponseCacheTimestampsAction,
ResponseCacheAddAction
} from './response-cache.actions';
import { CacheEntry } from './cache-entry';
import { hasValue } from '../../shared/empty.util';
import { RestResponse } from './response-cache.models';
/**
* An entry in the ResponseCache
*/
export class ResponseCacheEntry implements CacheEntry {
key: string;
response: RestResponse;
timeAdded: number;
msToLive: number;
}
/**
* The ResponseCache State
*/
export interface ResponseCacheState {
[key: string]: ResponseCacheEntry
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
/**
* The ResponseCache Reducer
*
* @param state
* the current state
* @param action
* the action to perform on the state
* @return ResponseCacheState
* the new state
*/
export function responseCacheReducer(state = initialState, action: ResponseCacheAction): ResponseCacheState {
switch (action.type) {
case ResponseCacheActionTypes.ADD: {
return addToCache(state, action as ResponseCacheAddAction);
}
case ResponseCacheActionTypes.REMOVE: {
return removeFromCache(state, action as ResponseCacheRemoveAction);
}
case ResponseCacheActionTypes.RESET_TIMESTAMPS: {
return resetResponseCacheTimestamps(state, action as ResetResponseCacheTimestampsAction)
}
default: {
return state;
}
}
}
function addToCache(state: ResponseCacheState, action: ResponseCacheAddAction): ResponseCacheState {
return Object.assign({}, state, {
[action.payload.key]: {
key: action.payload.key,
response: action.payload.response,
timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive
}
});
}
/**
* Remove a request from the cache
*
* @param state
* the current state
* @param action
* an ResponseCacheRemoveAction
* @return ResponseCacheState
* the new state, with the request removed if it existed.
*/
function removeFromCache(state: ResponseCacheState, action: ResponseCacheRemoveAction): ResponseCacheState {
if (hasValue(state[action.payload])) {
const newCache = Object.assign({}, state);
delete newCache[action.payload];
return newCache;
} else {
return state;
}
}
/**
* Set the timeAdded timestamp of every cached request to the specified value
*
* @param state
* the current state
* @param action
* a ResetResponseCacheTimestampsAction
* @return ResponseCacheState
* the new state, with all timeAdded timestamps set to the specified value
*/
function resetResponseCacheTimestamps(state: ResponseCacheState, action: ResetResponseCacheTimestampsAction): ResponseCacheState {
const newState = Object.create(null);
Object.keys(state).forEach((key) => {
newState[key] = Object.assign({}, state[key], {
timeAdded: action.payload
});
});
return newState;
}

View File

@@ -1,82 +0,0 @@
import { Store } from '@ngrx/store';
import { ResponseCacheService } from './response-cache.service';
import { Observable } from 'rxjs/Observable';
import { CoreState } from '../core.reducers';
import { RestResponse } from './response-cache.models';
import { ResponseCacheEntry } from './response-cache.reducer';
describe('ResponseCacheService', () => {
let service: ResponseCacheService;
let store: Store<CoreState>;
const keys = ['125c17f89046283c5f0640722aac9feb', 'a06c3006a41caec5d635af099b0c780c'];
const timestamp = new Date().getTime();
const validCacheEntry = (key) => {
return {
key: key,
response: new RestResponse(true, '200'),
timeAdded: timestamp,
msToLive: 24 * 60 * 60 * 1000 // a day
}
};
const invalidCacheEntry = (key) => {
return {
key: key,
response: new RestResponse(true, '200'),
timeAdded: 0,
msToLive: 0
}
};
beforeEach(() => {
store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = new ResponseCacheService(store);
spyOn(Date.prototype, 'getTime').and.callFake(() => {
return timestamp;
});
});
describe('get', () => {
it('should return an observable of the cached request with the specified key', () => {
spyOn(store, 'select').and.callFake((...args: any[]) => {
return Observable.of(validCacheEntry(keys[1]));
});
let testObj: ResponseCacheEntry;
service.get(keys[1]).first().subscribe((entry) => {
testObj = entry;
});
expect(testObj.key).toEqual(keys[1]);
});
it('should not return a cached request that has exceeded its time to live', () => {
spyOn(store, 'select').and.callFake((...args: any[]) => {
return Observable.of(invalidCacheEntry(keys[1]));
});
let getObsHasFired = false;
const subscription = service.get(keys[1]).subscribe((entry) => getObsHasFired = true);
expect(getObsHasFired).toBe(false);
subscription.unsubscribe();
});
});
describe('has', () => {
it('should return true if the request with the supplied key is cached and still valid', () => {
spyOn(store, 'select').and.returnValue(Observable.of(validCacheEntry(keys[1])));
expect(service.has(keys[1])).toBe(true);
});
it('should return false if the request with the supplied key isn\'t cached', () => {
spyOn(store, 'select').and.returnValue(Observable.of(undefined));
expect(service.has(keys[1])).toBe(false);
});
it('should return false if the request with the supplied key is cached but has exceeded its time to live', () => {
spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry(keys[1])));
expect(service.has(keys[1])).toBe(false);
});
});
});

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