diff --git a/.travis.yml b/.travis.yml index 5ebcb69d6d..bc539060e7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ language: node_js node_js: - "6" - - "7" + - "8" cache: yarn: true diff --git a/e2e/search-page/search-page.po.ts b/e2e/search-page/search-page.po.ts index b1a17ee150..5ea9a0019b 100644 --- a/e2e/search-page/search-page.po.ts +++ b/e2e/search-page/search-page.po.ts @@ -1,4 +1,4 @@ -import { browser, element, by } from 'protractor'; +import { browser, element, by, protractor } from 'protractor'; import { promise } from 'selenium-webdriver'; export class ProtractorPage { @@ -17,7 +17,9 @@ export class ProtractorPage { } getCurrentScope(): promise.Promise { - return element(by.tagName('select')).getAttribute('value'); + const scopeSelect = element(by.tagName('select')); + browser.wait(protractor.ExpectedConditions.presenceOf(scopeSelect), 10000); + return scopeSelect.getAttribute('value'); } getCurrentQuery(): promise.Promise { diff --git a/package.json b/package.json index 3959872257..2f74dd6cb8 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "angular2-template-loader": "0.6.2", "autoprefixer": "7.1.5", "awesome-typescript-loader": "3.2.3", - "caniuse-lite": "1.0.30000697", + "caniuse-lite": "1.0.30000746", "codelyzer": "3.2.1", "compression-webpack-plugin": "1.0.1", "copy-webpack-plugin": "4.1.1", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index bb02c8051c..832fbb95aa 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -118,9 +118,10 @@ "community": "Loading community...", "collection": "Loading collection...", "sub-collections": "Loading sub-collections...", - "items": "Loading items...", + "recent-submissions": "Loading recent submissions...", "item": "Loading item...", - "objects": "Loading..." + "objects": "Loading...", + "search-results": "Loading search results..." }, "error": { "default": "Error", @@ -128,8 +129,9 @@ "community": "Error fetching community", "collection": "Error fetching collection", "sub-collections": "Error fetching sub-collections", - "items": "Error fetching items", + "recent-submissions": "Error fetching recent submissions", "item": "Error fetching item", - "objects": "Error fetching objects" + "objects": "Error fetching objects", + "search-results": "Error fetching search results" } } diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index c79ec83cbc..fe5b2a1f16 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -1,52 +1,56 @@
-
-
-
+
+
+
+ [name]="collection.name"> - +
- - + +
-
-

{{'collection.page.browse.recent.head' | translate}}

- - -
- - + +
+

{{'collection.page.browse.recent.head' | translate}}

+ + +
+ + +
diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 30d9c17fe3..853bd0d154 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,43 +1,37 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - OnDestroy, - OnInit -} from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; - -import { PageInfo } from '../core/shared/page-info.model'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs/Observable'; -import { Subscription } from 'rxjs/Subscription'; -import { Collection } from '../core/shared/collection.model'; -import { Bitstream } from '../core/shared/bitstream.model'; -import { RemoteData } from '../core/data/remote-data'; +import { Subscription } from 'rxjs/Subscription'; +import { SortOptions } from '../core/cache/models/sort-options.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { ItemDataService } from '../core/data/item-data.service'; -import { Item } from '../core/shared/item.model'; -import { SortOptions, SortDirection } from '../core/cache/models/sort-options.model'; -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { hasValue, isNotEmpty, isUndefined } from '../shared/empty.util'; +import { RemoteData } from '../core/data/remote-data'; import { MetadataService } from '../core/metadata/metadata.service'; +import { Bitstream } from '../core/shared/bitstream.model'; + +import { Collection } from '../core/shared/collection.model'; +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'; @Component({ selector: 'ds-collection-page', styleUrls: ['./collection-page.component.scss'], templateUrl: './collection-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, animations: [ fadeIn, fadeInOut ] }) export class CollectionPageComponent implements OnInit, OnDestroy { - collectionData: RemoteData; - itemData: RemoteData; - logoData: RemoteData; + collectionRDObs: Observable>; + itemRDObs: Observable>; + logoRDObs: Observable>; paginationConfig: PaginationComponentOptions; sortConfig: SortOptions; private subs: Subscription[] = []; @@ -51,8 +45,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy { ) { this.paginationConfig = new PaginationComponentOptions(); this.paginationConfig.id = 'collection-page-pagination'; - this.paginationConfig.pageSizeOptions = [4]; - this.paginationConfig.pageSize = 4; + this.paginationConfig.pageSize = 5; this.paginationConfig.currentPage = 1; this.sortConfig = new SortOptions(); } @@ -67,9 +60,12 @@ export class CollectionPageComponent implements OnInit, OnDestroy { }) .subscribe((params) => { this.collectionId = params.id; - this.collectionData = this.collectionDataService.findById(this.collectionId); - this.metadata.processRemoteData(this.collectionData); - this.subs.push(this.collectionData.payload.subscribe((collection) => this.logoData = collection.logo)); + this.collectionRDObs = this.collectionDataService.findById(this.collectionId); + this.metadata.processRemoteData(this.collectionRDObs); + this.subs.push(this.collectionRDObs + .map((rd: RemoteData) => rd.payload) + .filter((collection: Collection) => hasValue(collection)) + .subscribe((collection: Collection) => this.logoRDObs = collection.logo)); const page = +params.page || this.paginationConfig.currentPage; const pageSize = +params.pageSize || this.paginationConfig.pageSize; @@ -91,7 +87,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy { } updatePage(searchOptions) { - this.itemData = this.itemDataService.findAll({ + this.itemRDObs = this.itemDataService.findAll({ scopeID: this.collectionId, currentPage: searchOptions.pagination.currentPage, elementsPerPage: searchOptions.pagination.pageSize, @@ -106,4 +102,17 @@ export class CollectionPageComponent implements OnInit, OnDestroy { isNotEmpty(object: any) { return isNotEmpty(object); } + + onPaginationChange(event) { + this.updatePage({ + pagination: { + currentPage: event.page, + pageSize: event.pageSize + }, + sort: { + field: event.sortField, + direction: event.sortDirection + } + }) + } } diff --git a/src/app/+community-page/community-page.component.html b/src/app/+community-page/community-page.component.html index 292510adaa..82939e7743 100644 --- a/src/app/+community-page/community-page.component.html +++ b/src/app/+community-page/community-page.component.html @@ -1,12 +1,12 @@ -
-
-
+
+
+
- +
- - + +
diff --git a/src/app/+community-page/community-page.component.ts b/src/app/+community-page/community-page.component.ts index 0cd94658be..605d488820 100644 --- a/src/app/+community-page/community-page.component.ts +++ b/src/app/+community-page/community-page.component.ts @@ -1,27 +1,29 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Params } from '@angular/router'; import { Subscription } from 'rxjs/Subscription'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { RemoteData } from '../core/data/remote-data'; +import { Bitstream } from '../core/shared/bitstream.model'; import { Community } from '../core/shared/community.model'; -import { Bitstream } from '../core/shared/bitstream.model'; -import { RemoteData } from '../core/data/remote-data'; -import { CommunityDataService } from '../core/data/community-data.service'; -import { hasValue } from '../shared/empty.util'; 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', styleUrls: ['./community-page.component.scss'], templateUrl: './community-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) export class CommunityPageComponent implements OnInit, OnDestroy { - communityData: RemoteData; - logoData: RemoteData; + communityRDObs: Observable>; + logoRDObs: Observable>; private subs: Subscription[] = []; constructor( @@ -34,9 +36,12 @@ export class CommunityPageComponent implements OnInit, OnDestroy { ngOnInit(): void { this.route.params.subscribe((params: Params) => { - this.communityData = this.communityDataService.findById(params.id); - this.metadata.processRemoteData(this.communityData); - this.subs.push(this.communityData.payload.subscribe((community) => this.logoData = community.logo)); + this.communityRDObs = this.communityDataService.findById(params.id); + this.metadata.processRemoteData(this.communityRDObs); + this.subs.push(this.communityRDObs + .map((rd: RemoteData) => rd.payload) + .filter((community: Community) => hasValue(community)) + .subscribe((community: Community) => this.logoRDObs = community.logo)); }); } diff --git a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html index 31dc2cd326..b04e93ff71 100644 --- a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html @@ -1,13 +1,15 @@ -
-

{{'community.sub-collection-list.head' | translate}}

- -
- - + +
+

{{'community.sub-collection-list.head' | translate}}

+ +
+ + +
diff --git a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts index 618890a60c..8edc275437 100644 --- a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts @@ -5,6 +5,7 @@ import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; import { fadeIn } from '../../shared/animations/fade'; +import { Observable } from 'rxjs/Observable'; @Component({ selector: 'ds-community-page-sub-collection-list', @@ -13,13 +14,13 @@ import { fadeIn } from '../../shared/animations/fade'; animations:[fadeIn] }) export class CommunityPageSubCollectionListComponent implements OnInit { - subCollections: RemoteData; + subCollectionsRDObs: Observable>; constructor(private cds: CollectionDataService) { } ngOnInit(): void { - this.subCollections = this.cds.findAll(); + this.subCollectionsRDObs = this.cds.findAll(); } } diff --git a/src/app/+home-page/home-page.module.ts b/src/app/+home-page/home-page.module.ts index 45c460c89c..0a513260cd 100644 --- a/src/app/+home-page/home-page.module.ts +++ b/src/app/+home-page/home-page.module.ts @@ -1,11 +1,11 @@ -import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; +import { HomeNewsComponent } from './home-news/home-news.component'; +import { HomePageRoutingModule } from './home-page-routing.module'; import { HomePageComponent } from './home-page.component'; -import { HomePageRoutingModule } from './home-page-routing.module'; import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component'; -import { HomeNewsComponent } from './home-news/home-news.component'; @NgModule({ imports: [ diff --git a/src/app/+home-page/top-level-community-list/top-level-community-list.component.html b/src/app/+home-page/top-level-community-list/top-level-community-list.component.html index a5bd6c5c5d..a34951afe0 100644 --- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.html +++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.html @@ -1,13 +1,15 @@ -
-

{{'home.top-level-communities.head' | translate}}

-

{{'home.top-level-communities.help' | translate}}

- - -
- - \ No newline at end of file + +
+

{{'home.top-level-communities.head' | translate}}

+

{{'home.top-level-communities.help' | translate}}

+ + +
+ + +
diff --git a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts index 626d3cb6d3..b364985fc1 100644 --- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts +++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts @@ -1,30 +1,30 @@ -import { Component, OnInit, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../../core/data/community-data.service'; import { RemoteData } from '../../core/data/remote-data'; -import { CommunityDataService } from '../../core/data/community-data.service'; import { Community } from '../../core/shared/community.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortOptions, SortDirection } from '../../core/cache/models/sort-options.model'; -import { ActivatedRoute } from '@angular/router'; import { fadeInOut } from '../../shared/animations/fade'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @Component({ selector: 'ds-top-level-community-list', styleUrls: ['./top-level-community-list.component.scss'], templateUrl: './top-level-community-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) export class TopLevelCommunityListComponent { - topLevelCommunities: RemoteData; + communitiesRDObs: Observable>; config: PaginationComponentOptions; sortConfig: SortOptions; constructor(private cds: CommunityDataService) { this.config = new PaginationComponentOptions(); this.config.id = 'top-level-pagination'; - this.config.pageSizeOptions = [4]; - this.config.pageSize = 4; + this.config.pageSize = 5; this.config.currentPage = 1; this.sortConfig = new SortOptions(); @@ -37,7 +37,7 @@ export class TopLevelCommunityListComponent { } updatePage(data) { - this.topLevelCommunities = this.cds.findAll({ + this.communitiesRDObs = this.cds.findAll({ currentPage: data.page, elementsPerPage: data.pageSize, sort: { field: data.sortField, direction: data.sortDirection } diff --git a/src/app/+item-page/field-components/collections/collections.component.ts b/src/app/+item-page/field-components/collections/collections.component.ts index c3db3135e3..8b7b5d7f58 100644 --- a/src/app/+item-page/field-components/collections/collections.component.ts +++ b/src/app/+item-page/field-components/collections/collections.component.ts @@ -4,6 +4,7 @@ import { Observable } from 'rxjs/Observable'; import { Collection } from '../../../core/shared/collection.model'; import { Item } from '../../../core/shared/item.model'; import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { RemoteData } from '../../../core/data/remote-data'; /** * This component renders the parent collections section of the item @@ -34,7 +35,7 @@ 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.payload.map((c) => [c]); + this.collections = this.item.owner.map((rd: RemoteData) => [rd.payload]); } } diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index 358b658a76..d5a7febeb9 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -1,5 +1,5 @@ -
+
@@ -13,7 +13,7 @@
{{"item.page.filesection.format" | translate}}
-
{{(file.mimetype)}}
+
{{(file.format | async)?.payload?.description}}
{{"item.page.filesection.description" | translate}}
diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts index 262773b1be..331e979c8f 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts @@ -22,7 +22,7 @@ export class FullFileSectionComponent extends FileSectionComponent implements On label: string; - files: Observable; + bitstreamsObs: Observable; thumbnails: Map> = new Map(); @@ -33,8 +33,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On initialize(): void { const originals = this.item.getFiles(); const licenses = this.item.getBitstreamsByBundleName('LICENSE'); - this.files = Observable.combineLatest(originals, licenses, (o, l) => [...o, ...l]); - this.files.subscribe( + this.bitstreamsObs = Observable.combineLatest(originals, licenses, (o, l) => [...o, ...l]); + this.bitstreamsObs.subscribe( (files) => files.forEach( (original) => { diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html index 307a12a40b..4c44b72602 100644 --- a/src/app/+item-page/full/full-item-page.component.html +++ b/src/app/+item-page/full/full-item-page.component.html @@ -1,25 +1,25 @@ -
-
-
- +
+
+
+ - - - - - + + + + +
{{metadatum.key}}{{metadatum.value}}{{metadatum.language}}
{{metadatum.key}}{{metadatum.value}}{{metadatum.language}}
- - + +
- - + +
diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index 270cf1fcae..aa1fc4cc78 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs/Observable'; @@ -13,6 +13,7 @@ import { Item } from '../../core/shared/item.model'; import { MetadataService } from '../../core/metadata/metadata.service'; import { fadeInOut } from '../../shared/animations/fade'; +import { hasValue } from '../../shared/empty.util'; /** * This component renders a simple item page. @@ -24,13 +25,14 @@ import { fadeInOut } from '../../shared/animations/fade'; selector: 'ds-full-item-page', styleUrls: ['./full-item-page.component.scss'], templateUrl: './full-item-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) export class FullItemPageComponent extends ItemPageComponent implements OnInit { - item: RemoteData; + itemRDObs: Observable>; - metadata: Observable; + metadataObs: Observable; constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) { super(route, items, metadataService); @@ -43,7 +45,10 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit { initialize(params) { super.initialize(params); - this.metadata = this.item.payload.map((i) => i.metadata); + this.metadataObs = this.itemRDObs + .map((rd: RemoteData) => rd.payload) + .filter((item: Item) => hasValue(item)) + .map((item: Item) => item.metadata); } } diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.html b/src/app/+item-page/simple/field-components/file-section/file-section.component.html index 0a56242075..7063bac0be 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.html @@ -1,9 +1,11 @@ - + + - + + diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts index a44d6c0d1e..b42e73940f 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts @@ -20,14 +20,14 @@ export class FileSectionComponent implements OnInit { separator = '
'; - files: Observable; + bitstreamsObs: Observable; ngOnInit(): void { this.initialize(); } initialize(): void { - this.files = this.item.getFiles(); + this.bitstreamsObs = this.item.getFiles(); } } diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html index 91ddeefa64..5f938364e2 100644 --- a/src/app/+item-page/simple/item-page.component.html +++ b/src/app/+item-page/simple/item-page.component.html @@ -1,22 +1,22 @@ -
-
-
- +
+
+
+
- + - - - + + +
- - - + + + @@ -24,6 +24,6 @@
- - + +
diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index ce9283e144..58a056a5dd 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -1,16 +1,17 @@ -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs/Observable'; - -import { Item } from '../../core/shared/item.model'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Bitstream } from '../../core/shared/bitstream.model'; +import { Item } from '../../core/shared/item.model'; + import { MetadataService } from '../../core/metadata/metadata.service'; import { fadeInOut } from '../../shared/animations/fade'; +import { hasValue } from '../../shared/empty.util'; /** * This component renders a simple item page. @@ -21,6 +22,7 @@ import { fadeInOut } from '../../shared/animations/fade'; selector: 'ds-item-page', styleUrls: ['./item-page.component.scss'], templateUrl: './item-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) export class ItemPageComponent implements OnInit { @@ -29,9 +31,9 @@ export class ItemPageComponent implements OnInit { private sub: any; - item: RemoteData; + itemRDObs: Observable>; - thumbnail: Observable; + thumbnailObs: Observable; constructor( private route: ActivatedRoute, @@ -49,9 +51,12 @@ export class ItemPageComponent implements OnInit { initialize(params) { this.id = +params.id; - this.item = this.items.findById(params.id); - this.metadataService.processRemoteData(this.item); - this.thumbnail = this.item.payload.flatMap((i) => i.getThumbnail()); + this.itemRDObs = this.items.findById(params.id); + this.metadataService.processRemoteData(this.itemRDObs); + this.thumbnailObs = this.itemRDObs + .map((rd: RemoteData) => rd.payload) + .filter((item: Item) => hasValue(item)) + .flatMap((item: Item) => item.getThumbnail()); } } diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 881efbf0c1..56c1b51fed 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -6,9 +6,9 @@
+ [scopes]="(scopeListRDObs | async)?.payload">
-
@@ -36,4 +36,3 @@
- diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts index 536d8a5f7d..f1b86e3270 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search-page.component.spec.ts @@ -28,7 +28,7 @@ describe('SearchPageComponent', () => { /* tslint:enable:no-empty */ select: Observable.of(true) }); - const mockResults = ['test', 'data']; + const mockResults = Observable.of(['test', 'data']); const searchServiceStub = { search: () => mockResults }; @@ -48,8 +48,8 @@ describe('SearchPageComponent', () => { const mockCommunityList = []; const communityDataServiceStub = { - findAll: () => mockCommunityList, - findById: () => new Community() + findAll: () => Observable.of(mockCommunityList), + findById: () => Observable.of(new Community()) }; class RouterStub { @@ -140,7 +140,7 @@ describe('SearchPageComponent', () => { (comp as any).updateSearchResults({}); - expect(comp.results as any).toBe(mockResults); + expect(comp.resultsRDObs as any).toBe(mockResults); }); }); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index c4b272937c..04d8f5ce3d 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -1,15 +1,16 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { SearchService } from './search-service/search.service'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { RemoteData } from '../core/data/remote-data'; -import { SearchResult } from './search-result.model'; -import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { Observable } from 'rxjs/Observable'; import { SortOptions } from '../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../core/data/community-data.service'; +import { RemoteData } from '../core/data/remote-data'; +import { Community } from '../core/shared/community.model'; +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { isNotEmpty } from '../shared/empty.util'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { SearchOptions } from './search-options.model'; -import { CommunityDataService } from '../core/data/community-data.service'; -import { isNotEmpty } from '../shared/empty.util'; -import { Community } from '../core/shared/community.model'; +import { SearchResult } from './search-result.model'; +import { SearchService } from './search-service/search.service'; import { Observable } from 'rxjs/Observable'; import { pushInOut } from '../shared/animations/push'; import { HostWindowService } from '../shared/host-window.service'; @@ -25,6 +26,7 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; selector: 'ds-search-page', styleUrls: ['./search-page.component.scss'], templateUrl: './search-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, animations: [pushInOut] }) export class SearchPageComponent implements OnInit, OnDestroy { @@ -33,11 +35,11 @@ export class SearchPageComponent implements OnInit, OnDestroy { private scope: string; query: string; - scopeObject: RemoteData; - results: RemoteData>>; + scopeObjectRDObs: Observable>; + resultsRDObs: Observable>>>; currentParams = {}; searchOptions: SearchOptions; - scopeList: RemoteData; + scopeListRDObs: Observable>; isMobileView: Observable; constructor(private service: SearchService, @@ -46,7 +48,7 @@ export class SearchPageComponent implements OnInit, OnDestroy { private sidebarService: SearchSidebarService, private windowService: HostWindowService) { this.isMobileView = this.windowService.isXs(); - this.scopeList = communityService.findAll(); + this.scopeListRDObs = communityService.findAll(); // Initial pagination config const pagination: PaginationComponentOptions = new PaginationComponentOptions(); pagination.id = 'search-results-pagination'; @@ -80,9 +82,9 @@ export class SearchPageComponent implements OnInit, OnDestroy { sort: sort }); if (isNotEmpty(this.scope)) { - this.scopeObject = this.communityService.findById(this.scope); + this.scopeObjectRDObs = this.communityService.findById(this.scope); } else { - this.scopeObject = undefined; + this.scopeObjectRDObs = Observable.of(undefined); } } ); @@ -90,7 +92,7 @@ export class SearchPageComponent implements OnInit, OnDestroy { private updateSearchResults(searchOptions) { // Resolve search results - this.results = this.service.search(this.query, this.scope, searchOptions); + this.resultsRDObs = this.service.search(this.query, this.scope, searchOptions); } ngOnDestroy() { diff --git a/src/app/+search-page/search-results/search-results.component.html b/src/app/+search-page/search-results/search-results.component.html index 233f22d850..20b2cb8699 100644 --- a/src/app/+search-page/search-results/search-results.component.html +++ b/src/app/+search-page/search-results/search-results.component.html @@ -1,7 +1,11 @@ -

{{ 'search.results.head' | translate }}

- - \ No newline at end of file +
+

{{ 'search.results.head' | translate }}

+ + +
+ + \ No newline at end of file diff --git a/src/app/+search-page/search-results/search-results.component.ts b/src/app/+search-page/search-results/search-results.component.ts index c645489694..4733699f95 100644 --- a/src/app/+search-page/search-results/search-results.component.ts +++ b/src/app/+search-page/search-results/search-results.component.ts @@ -1,8 +1,9 @@ import { Component, Input } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; -import { SearchResult } from '../search-result.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { fadeIn, fadeInOut } from '../../shared/animations/fade'; import { SearchOptions } from '../search-options.model'; +import { SearchResult } from '../search-result.model'; /** * This component renders a simple item page. @@ -12,6 +13,10 @@ import { SearchOptions } from '../search-options.model'; @Component({ selector: 'ds-search-results', templateUrl: './search-results.component.html', + animations: [ + fadeIn, + fadeInOut + ] }) export class SearchResultsComponent { @Input() searchResults: RemoteData>>; diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 77e7c55185..2d34b3a98f 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -86,7 +86,7 @@ export class SearchService implements OnDestroy { } - search(query: string, scopeId?: string, searchOptions?: SearchOptions): RemoteData>> { + search(query: string, scopeId?: string, searchOptions?: SearchOptions): Observable>>> { let self = `https://dspace7.4science.it/dspace-spring-rest/api/search?query=${query}`; if (hasValue(scopeId)) { self += `&scope=${scopeId}`; @@ -104,8 +104,8 @@ export class SearchService implements OnDestroy { self += `&sortField=${searchOptions.sort.field}`; } - const errorMessage = Observable.of(undefined); - const statusCode = Observable.of('200'); + const errorMessage = undefined; + const statusCode = '200'; const returningPageInfo = new PageInfo(); if (isNotEmpty(searchOptions)) { @@ -116,19 +116,20 @@ export class SearchService implements OnDestroy { returningPageInfo.currentPage = 1; } - const itemsRD = this.itemDataService.findAll({ + const itemsObs = this.itemDataService.findAll({ scopeID: scopeId, currentPage: returningPageInfo.currentPage, elementsPerPage: returningPageInfo.elementsPerPage }); - const pageInfo = itemsRD.pageInfo.map((info: PageInfo) => { - const totalElements = info.totalElements > 20 ? 20 : info.totalElements; - return Object.assign({}, info, { totalElements: totalElements }); - }); + return itemsObs + .filter((rd: RemoteData) => rd.hasSucceeded) + .map((rd: RemoteData) => { - const payload = itemsRD.payload.map((items: Item[]) => { - return shuffle(items) + const totalElements = rd.pageInfo.totalElements > 20 ? 20 : rd.pageInfo.totalElements; + const pageInfo = Object.assign({}, rd.pageInfo, { totalElements: totalElements }); + + const payload = shuffle(rd.payload) .map((item: Item, index: number) => { const mockResult: SearchResult = new ItemSearchResult(); mockResult.dspaceObject = item; @@ -138,43 +139,51 @@ export class SearchService implements OnDestroy { mockResult.hitHighlights = new Array(highlight); return mockResult; }); - }); - return new RemoteData( - Observable.of(self), - itemsRD.isRequestPending, - itemsRD.isResponsePending, - itemsRD.hasSucceeded, - errorMessage, - statusCode, - pageInfo, - payload - ) + return new RemoteData( + self, + rd.isRequestPending, + rd.isResponsePending, + rd.hasSucceeded, + errorMessage, + statusCode, + pageInfo, + payload + ) + }).startWith(new RemoteData( + '', + true, + false, + undefined, + undefined, + undefined, + undefined, + undefined + )); } - getConfig(): RemoteData { - const requestPending = Observable.of(false); - const responsePending = Observable.of(false); - const isSuccessful = Observable.of(true); - const errorMessage = Observable.of(undefined); - const statusCode = Observable.of('200'); - const returningPageInfo = Observable.of(new PageInfo()); - return new RemoteData( - Observable.of('https://dspace7.4science.it/dspace-spring-rest/api/search'), + getConfig(): Observable> { + const requestPending = false; + const responsePending = false; + const isSuccessful = true; + const errorMessage = undefined; + const statusCode = '200'; + const returningPageInfo = new PageInfo(); + return Observable.of(new RemoteData( + 'https://dspace7.4science.it/dspace-spring-rest/api/search', requestPending, responsePending, isSuccessful, errorMessage, statusCode, returningPageInfo, - Observable.of(this.config) - ); + this.config + )); } - getFacetValuesFor(searchFilterConfigName: string): RemoteData { + getFacetValuesFor(searchFilterConfigName: string): Observable> { const filterConfig = this.config.find((config: SearchFilterConfig) => config.name === searchFilterConfigName); - const values: FacetValue[] = []; for (let i = 0; i < 5; i++) { const value = searchFilterConfigName + ' ' + (i + 1); @@ -184,22 +193,22 @@ export class SearchService implements OnDestroy { search: decodeURI(this.router.url) + (this.router.url.includes('?') ? '&' : '?') + filterConfig.paramName + '=' + value }); } - const requestPending = Observable.of(false); - const responsePending = Observable.of(false); - const isSuccessful = Observable.of(true); - const errorMessage = Observable.of(undefined); - const statusCode = Observable.of('200'); - const returningPageInfo = Observable.of(new PageInfo()); - return new RemoteData( - Observable.of('https://dspace7.4science.it/dspace-spring-rest/api/search'), + const requestPending = false; + const responsePending = false; + const isSuccessful = true; + const errorMessage = undefined; + const statusCode = '200'; + const returningPageInfo = new PageInfo(); + return Observable.of(new RemoteData( + 'https://dspace7.4science.it/dspace-spring-rest/api/search', requestPending, responsePending, isSuccessful, errorMessage, statusCode, returningPageInfo, - Observable.of(values) - ); + values + )); } getViewMode(): Observable { diff --git a/src/app/app.component.ts b/src/app/app.component.ts index fe9ab3d7db..f7e30674b8 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,29 +1,29 @@ import { ChangeDetectionStrategy, Component, + HostListener, Inject, - ViewEncapsulation, OnInit, - HostListener + ViewEncapsulation } from '@angular/core'; -import { TranslateService } from '@ngx-translate/core'; - import { Store } from '@ngrx/store'; -import { TransferState } from '../modules/transfer-state/transfer-state'; -import { HostWindowState } from './shared/host-window.reducer'; -import { HostWindowResizeAction } from './shared/host-window.actions'; -import { NativeWindowRef, NativeWindowService } from './shared/window.service'; -import { MetadataService } from './core/metadata/metadata.service'; +import { TranslateService } from '@ngx-translate/core'; import { GLOBAL_CONFIG, GlobalConfig } from '../config'; +import { TransferState } from '../modules/transfer-state/transfer-state'; +import { MetadataService } from './core/metadata/metadata.service'; +import { HostWindowResizeAction } from './shared/host-window.actions'; +import { HostWindowState } from './shared/host-window.reducer'; +import { NativeWindowRef, NativeWindowService } from './shared/window.service'; + @Component({ selector: 'ds-app', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], - changeDetection: ChangeDetectionStrategy.Default, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) export class AppComponent implements OnInit { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index a80a173297..14719ed266 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -40,10 +40,7 @@ export function getBase() { export function getMetaReducers(config: GlobalConfig): Array> { const metaReducers: Array> = config.production ? appMetaReducers : [...appMetaReducers, storeFreeze]; - if (config.debug) { - metaReducers.concat(debugMetaReducers) - } - return metaReducers; + return config.debug ? [...metaReducers, ...debugMetaReducers] : metaReducers; } const DEV_MODULES: any[] = []; diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts new file mode 100644 index 0000000000..65b32b3c0b --- /dev/null +++ b/src/app/core/browse/browse.service.spec.ts @@ -0,0 +1,205 @@ +import { BrowseService } from './browse.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { RequestService } from '../data/request.service'; +import { GlobalConfig } from '../../../config'; +import { hot, cold, getTestScheduler } from 'jasmine-marbles'; +import { BrowseDefinition } from '../shared/browse-definition.model'; +import { BrowseEndpointRequest } from '../data/request.models'; +import { TestScheduler } from 'rxjs/Rx'; + +describe('BrowseService', () => { + let scheduler: TestScheduler; + let service: BrowseService; + let responseCache: ResponseCacheService; + let requestService: RequestService; + + const envConfig = {} as GlobalConfig; + const browsesEndpointURL = 'https://rest.api/browses'; + const browseDefinitions = [ + Object.assign(new BrowseDefinition(), { + metadataBrowse: false, + sortOptions: [ + { + name: 'title', + metadata: 'dc.title' + }, + { + name: 'dateissued', + metadata: 'dc.date.issued' + }, + { + name: 'dateaccessioned', + metadata: 'dc.date.accessioned' + } + ], + defaultSortOrder: 'ASC', + type: 'browse', + metadataKeys: [ + 'dc.date.issued' + ], + _links: { + self: 'https://rest.api/discover/browses/dateissued', + items: 'https://rest.api/discover/browses/dateissued/items' + } + }), + Object.assign(new BrowseDefinition(), { + metadataBrowse: true, + sortOptions: [ + { + name: 'title', + metadata: 'dc.title' + }, + { + name: 'dateissued', + metadata: 'dc.date.issued' + }, + { + name: 'dateaccessioned', + metadata: 'dc.date.accessioned' + } + ], + defaultSortOrder: 'ASC', + type: 'browse', + metadataKeys: [ + 'dc.contributor.*', + 'dc.creator' + ], + _links: { + self: 'https://rest.api/discover/browses/author', + entries: 'https://rest.api/discover/browses/author/entries', + items: 'https://rest.api/discover/browses/author/items' + } + }) + ]; + + function initMockResponseCacheService(isSuccessful: boolean) { + return jasmine.createSpyObj('responseCache', { + get: cold('b-', { + b: { + response: { + isSuccessful, + browseDefinitions, + } + } + }) + }); + } + + function initMockRequestService() { + return jasmine.createSpyObj('requestService', ['configure']); + } + + function initTestService() { + return new BrowseService( + responseCache, + requestService, + envConfig + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + }); + + describe('getBrowseURLFor', () => { + + describe('if getEndpoint fires', () => { + beforeEach(() => { + responseCache = initMockResponseCacheService(true); + requestService = initMockRequestService(); + service = initTestService(); + spyOn(service, 'getEndpoint').and + .returnValue(hot('--a-', { a: browsesEndpointURL })); + }); + + it('should return the URL for the given metadatumKey and linkName', () => { + const metadatumKey = 'dc.date.issued'; + const linkName = 'items'; + const expectedURL = browseDefinitions[0]._links[linkName]; + + const result = service.getBrowseURLFor(metadatumKey, linkName); + const expected = cold('c-d-', { c: undefined, d: expectedURL }); + + expect(result).toBeObservable(expected); + }); + + it('should work when the definition uses a wildcard in the metadatumKey', () => { + const metadatumKey = 'dc.contributor.author'; // should match dc.contributor.* in the definition + const linkName = 'items'; + const expectedURL = browseDefinitions[1]._links[linkName]; + + const result = service.getBrowseURLFor(metadatumKey, linkName); + const expected = cold('c-d-', { c: undefined, d: expectedURL }); + + expect(result).toBeObservable(expected); + }); + + it('should throw an error when the key doesn\'t match', () => { + const metadatumKey = 'dc.title'; // isn't in the definitions + const linkName = 'items'; + + const result = service.getBrowseURLFor(metadatumKey, linkName); + const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkName} on ${metadatumKey} isn't configured`)); + + expect(result).toBeObservable(expected); + }); + + it('should throw an error when the link doesn\'t match', () => { + const metadatumKey = 'dc.date.issued'; + const linkName = 'collections'; // isn't in the definitions + + const result = service.getBrowseURLFor(metadatumKey, linkName); + const expected = cold('c-#-', { c: undefined }, new Error(`A browse endpoint for ${linkName} on ${metadatumKey} isn't configured`)); + + expect(result).toBeObservable(expected); + }); + + it('should configure a new BrowseEndpointRequest', () => { + const metadatumKey = 'dc.date.issued'; + const linkName = 'items'; + const expected = new BrowseEndpointRequest(browsesEndpointURL); + + scheduler.schedule(() => service.getBrowseURLFor(metadatumKey, linkName).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + + }); + + }); + + describe('if getEndpoint doesn\'t fire', () => { + it('should return undefined', () => { + responseCache = initMockResponseCacheService(true); + requestService = initMockRequestService(); + service = initTestService(); + spyOn(service, 'getEndpoint').and + .returnValue(hot('----')); + + const metadatumKey = 'dc.date.issued'; + const linkName = 'items'; + + const result = service.getBrowseURLFor(metadatumKey, linkName); + const expected = cold('b---', { b: undefined }); + expect(result).toBeObservable(expected); + }); + }); + + describe('if the browses endpoint can\'t be retrieved', () => { + it('should throw an error', () => { + responseCache = initMockResponseCacheService(false); + requestService = initMockRequestService(); + service = initTestService(); + spyOn(service, 'getEndpoint').and + .returnValue(hot('--a-', { a: browsesEndpointURL })); + + const metadatumKey = 'dc.date.issued'; + const linkName = 'items'; + + const result = service.getBrowseURLFor(metadatumKey, linkName); + const expected = cold('c-#-', { c: undefined }, new Error(`Couldn't retrieve the browses endpoint`)); + expect(result).toBeObservable(expected); + }); + }); + }); +}); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts new file mode 100644 index 0000000000..6d8d504b82 --- /dev/null +++ b/src/app/core/browse/browse.service.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { BrowseSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { BrowseEndpointRequest, RestRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { BrowseDefinition } from '../shared/browse-definition.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; + +@Injectable() +export class BrowseService extends HALEndpointService { + protected linkName = 'browses'; + + private static toSearchKeyArray(metadatumKey: string): string[] { + const keyParts = metadatumKey.split('.'); + const searchFor = []; + searchFor.push('*'); + for (let i = 0; i < keyParts.length - 1; i++) { + const prevParts = keyParts.slice(0, i + 1); + const nextPart = [...prevParts, '*'].join('.'); + searchFor.push(nextPart); + } + searchFor.push(metadatumKey); + return searchFor; + } + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) { + super(); + } + + getBrowseURLFor(metadatumKey: string, linkName: string): Observable { + const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey); + return this.getEndpoint() + .filter((href: string) => isNotEmpty(href)) + .distinctUntilChanged() + .map((endpointURL: string) => new BrowseEndpointRequest(endpointURL)) + .do((request: RestRequest) => this.requestService.configure(request)) + .flatMap((request: RestRequest) => { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .partition((response: RestResponse) => response.isSuccessful); + + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.throw(new Error(`Couldn't retrieve the browses endpoint`))), + successResponse + .filter((response: BrowseSuccessResponse) => isNotEmpty(response.browseDefinitions)) + .map((response: BrowseSuccessResponse) => response.browseDefinitions) + .map((browseDefinitions: BrowseDefinition[]) => browseDefinitions + .find((def: BrowseDefinition) => { + const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); + return isNotEmpty(matchingKeys); + }) + ).map((def: BrowseDefinition) => { + if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkName])) { + throw new Error(`A browse endpoint for ${linkName} on ${metadatumKey} isn't configured`); + } else { + return def._links[linkName]; + } + }) + ); + }).startWith(undefined) + .distinctUntilChanged(); + } + +} diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 046250efda..2e3fc01b52 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -28,7 +28,7 @@ export class RemoteDataBuildService { buildSingle( hrefObs: string | Observable, normalizedType: GenericConstructor - ): RemoteData { + ): Observable> { if (typeof hrefObs === 'string') { hrefObs = Observable.of(hrefObs); } @@ -49,46 +49,8 @@ export class RemoteDataBuildService { requestHrefObs.flatMap((requestHref) => this.responseCache.get(requestHref)).filter((entry) => hasValue(entry)) ); - const requestPending = requestObs - .map((entry: RequestEntry) => entry.requestPending) - .startWith(true) - .distinctUntilChanged(); - - const responsePending = requestObs - .map((entry: RequestEntry) => entry.responsePending) - .startWith(false) - .distinctUntilChanged(); - - const isSuccessFul = responseCacheObs - .map((entry: ResponseCacheEntry) => entry.response.isSuccessful) - .startWith(false) - .distinctUntilChanged(); - - const errorMessage = responseCacheObs - .filter((entry: ResponseCacheEntry) => !entry.response.isSuccessful) - .map((entry: ResponseCacheEntry) => (entry.response as ErrorResponse).errorMessage) - .distinctUntilChanged(); - - const statusCode = responseCacheObs - .map((entry: ResponseCacheEntry) => entry.response.statusCode) - .distinctUntilChanged(); - - /* tslint:disable:no-string-literal */ - const pageInfo = responseCacheObs - .filter((entry: ResponseCacheEntry) => hasValue(entry.response) && hasValue(entry.response['pageInfo'])) - .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).pageInfo) - .map((pInfo: PageInfo) => { - if (isNotEmpty(pageInfo) && pInfo.currentPage >= 0) { - return Object.assign({}, pInfo, {currentPage: pInfo.currentPage + 1}); - } else { - return pInfo; - } - }) - .distinctUntilChanged(); - /* tslint:enable:no-string-literal */ - // always use self link if that is cached, only if it isn't, get it via the response. - const payload = + const payloadObs = Observable.combineLatest( hrefObs.flatMap((href: string) => this.objectCache.getBySelfLink(href, normalizedType)) .startWith(undefined), @@ -114,24 +76,53 @@ export class RemoteDataBuildService { ).filter((normalized) => hasValue(normalized)) .map((normalized: TNormalized) => { return this.build(normalized); - }).distinctUntilChanged(); + }) + .startWith(undefined) + .distinctUntilChanged(); + return this.toRemoteDataObservable(hrefObs, requestObs, responseCacheObs, payloadObs); + } - return new RemoteData( - hrefObs, - requestPending, - responsePending, - isSuccessFul, - errorMessage, - statusCode, - pageInfo, - payload - ); + private toRemoteDataObservable(hrefObs: Observable, requestObs: Observable, responseCacheObs: Observable, payloadObs: Observable) { + return Observable.combineLatest(hrefObs, requestObs, responseCacheObs.startWith(undefined), payloadObs, + (href: string, reqEntry: RequestEntry, resEntry: ResponseCacheEntry, payload: T) => { + const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; + const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; + let isSuccessFul: boolean; + let errorMessage: string; + let statusCode: string; + let pageInfo: PageInfo; + if (hasValue(resEntry) && hasValue(resEntry.response)) { + isSuccessFul = resEntry.response.isSuccessful; + errorMessage = isSuccessFul === false ? (resEntry.response as ErrorResponse).errorMessage : undefined; + statusCode = resEntry.response.statusCode; + + if (hasValue((resEntry.response as DSOSuccessResponse).pageInfo)) { + const resPageInfo = (resEntry.response as DSOSuccessResponse).pageInfo; + if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { + pageInfo = Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); + } else { + pageInfo = resPageInfo; + } + } + } + + return new RemoteData( + href, + requestPending, + responsePending, + isSuccessFul, + errorMessage, + statusCode, + pageInfo, + payload + ); + }); } buildList( hrefObs: string | Observable, normalizedType: GenericConstructor - ): RemoteData { + ): Observable> { if (typeof hrefObs === 'string') { hrefObs = Observable.of(hrefObs); } @@ -141,38 +132,7 @@ export class RemoteDataBuildService { const responseCacheObs = hrefObs.flatMap((href: string) => this.responseCache.get(href)) .filter((entry) => hasValue(entry)); - const requestPending = requestObs - .map((entry: RequestEntry) => entry.requestPending) - .startWith(true) - .distinctUntilChanged(); - - const responsePending = requestObs - .map((entry: RequestEntry) => entry.responsePending) - .startWith(false) - .distinctUntilChanged(); - - const isSuccessFul = responseCacheObs - .map((entry: ResponseCacheEntry) => entry.response.isSuccessful) - .startWith(false) - .distinctUntilChanged(); - - const errorMessage = responseCacheObs - .filter((entry: ResponseCacheEntry) => !entry.response.isSuccessful) - .map((entry: ResponseCacheEntry) => (entry.response as ErrorResponse).errorMessage) - .distinctUntilChanged(); - - const statusCode = responseCacheObs - .map((entry: ResponseCacheEntry) => entry.response.statusCode) - .distinctUntilChanged(); - - /* tslint:disable:no-string-literal */ - const pageInfo = responseCacheObs - .filter((entry: ResponseCacheEntry) => hasValue(entry.response) && hasValue(entry.response['pageInfo'])) - .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).pageInfo) - .distinctUntilChanged(); - /* tslint:enable:no-string-literal */ - - const payload = responseCacheObs + const payloadObs = responseCacheObs .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks) .flatMap((resourceUUIDs: string[]) => { @@ -183,18 +143,10 @@ export class RemoteDataBuildService { }); }); }) + .startWith([]) .distinctUntilChanged(); - return new RemoteData( - hrefObs, - requestPending, - responsePending, - isSuccessFul, - errorMessage, - statusCode, - pageInfo, - payload - ); + return this.toRemoteDataObservable(hrefObs, requestObs, responseCacheObs, payloadObs); } build(normalized: TNormalized): TDomain { @@ -207,13 +159,9 @@ export class RemoteDataBuildService { const { resourceType, isList } = getRelationMetadata(normalized, relationship); const resourceConstructor = NormalizedObjectFactory.getConstructor(resourceType); if (Array.isArray(normalized[relationship])) { - // without the setTimeout, the actions inside requestService.configure - // are dispatched, but sometimes don't arrive. I'm unsure why atm. - setTimeout(() => { - normalized[relationship].forEach((href: string) => { - this.requestService.configure(new RestRequest(href)) - }); - }, 0); + normalized[relationship].forEach((href: string) => { + this.requestService.configure(new RestRequest(href)) + }); const rdArr = []; normalized[relationship].forEach((href: string) => { @@ -226,11 +174,7 @@ export class RemoteDataBuildService { links[relationship] = rdArr[0]; } } else { - // without the setTimeout, the actions inside requestService.configure - // are dispatched, but sometimes don't arrive. I'm unsure why atm. - setTimeout(() => { - this.requestService.configure(new RestRequest(normalized[relationship])); - }, 0); + this.requestService.configure(new RestRequest(normalized[relationship])); // The rest API can return a single URL to represent a list of resources (e.g. /items/:id/bitstreams) // in that case only 1 href will be stored in the normalized obj (so the isArray above fails), @@ -248,63 +192,55 @@ export class RemoteDataBuildService { return Object.assign(new domainModel(), normalized, links); } - aggregate(input: Array>): RemoteData { - const requestPending = Observable.combineLatest( - ...input.map((rd) => rd.isRequestPending), - ).map((...pendingArray) => pendingArray.every((e) => e === true)) - .distinctUntilChanged(); + aggregate(input: Array>>): Observable> { + return Observable.combineLatest( + ...input, + (...arr: Array>) => { + const requestPending: boolean = arr + .map((d: RemoteData) => d.isRequestPending) + .every((b: boolean) => b === true); - const responsePending = Observable.combineLatest( - ...input.map((rd) => rd.isResponsePending), - ).map((...pendingArray) => pendingArray.every((e) => e === true)) - .distinctUntilChanged(); + const responsePending: boolean = arr + .map((d: RemoteData) => d.isResponsePending) + .every((b: boolean) => b === true); - const isSuccessFul = Observable.combineLatest( - ...input.map((rd) => rd.hasSucceeded), - ).map((...successArray) => successArray.every((e) => e === true)) - .distinctUntilChanged(); + const isSuccessFul: boolean = arr + .map((d: RemoteData) => d.hasSucceeded) + .every((b: boolean) => b === true); - const errorMessage = Observable.combineLatest( - ...input.map((rd) => rd.errorMessage), - ).map((...errors) => errors - .map((e, idx) => { - if (hasValue(e)) { - return `[${idx}]: ${e}`; - } + const errorMessage: string = arr + .map((d: RemoteData) => d.errorMessage) + .map((e: string, idx: number) => { + if (hasValue(e)) { + return `[${idx}]: ${e}`; + } + }).filter((e: string) => hasValue(e)) + .join(', '); + + const statusCode: string = arr + .map((d: RemoteData) => d.statusCode) + .map((c: string, idx: number) => { + if (hasValue(c)) { + return `[${idx}]: ${c}`; + } + }).filter((c: string) => hasValue(c)) + .join(', '); + + const pageInfo = undefined; + + const payload: T[] = arr.map((d: RemoteData) => d.payload); + + return new RemoteData( + `dspace-angular://aggregated/object/${new Date().getTime()}`, + requestPending, + responsePending, + isSuccessFul, + errorMessage, + statusCode, + pageInfo, + payload + ); }) - .filter((e) => hasValue(e)) - .join(', ') - ); - - const statusCode = Observable.combineLatest( - ...input.map((rd) => rd.statusCode), - ).map((...statusCodes) => statusCodes - .map((code, idx) => { - if (hasValue(code)) { - return `[${idx}]: ${code}`; - } - }) - .filter((c) => hasValue(c)) - .join(', ') - ); - - const pageInfo = Observable.of(undefined); - - const payload = Observable.combineLatest(...input.map((rd) => rd.payload)) as Observable; - - return new RemoteData( - // This is an aggregated object, it doesn't necessarily correspond - // to a single REST endpoint, so instead of a self link, use the - // current time in ms for a somewhat unique id - Observable.of(`${new Date().getTime()}`), - requestPending, - responsePending, - isSuccessFul, - errorMessage, - statusCode, - pageInfo, - payload - ); } } diff --git a/src/app/core/cache/models/normalized-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts index 3c6ca663c6..da42ea5a9b 100644 --- a/src/app/core/cache/models/normalized-dspace-object.model.ts +++ b/src/app/core/cache/models/normalized-dspace-object.model.ts @@ -66,4 +66,14 @@ export abstract class NormalizedDSpaceObject extends NormalizedObject { @autoserialize owner: string; + /** + * The links to all related resources returned by the rest api. + * + * Repeated here to make the serialization work, + * inheritSerialization doesn't seem to work for more than one level + */ + @autoserialize + _links: { + [name: string]: string + } } diff --git a/src/app/core/cache/models/normalized-object.model.ts b/src/app/core/cache/models/normalized-object.model.ts index 7c4154eae9..b26bd90b2a 100644 --- a/src/app/core/cache/models/normalized-object.model.ts +++ b/src/app/core/cache/models/normalized-object.model.ts @@ -17,4 +17,8 @@ export abstract class NormalizedObject implements CacheableObject { @autoserialize uuid: string; + @autoserialize + _links: { + [name: string]: string + } } diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index 8444a86490..4e3939b425 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -1,5 +1,6 @@ import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; +import { BrowseDefinition } from '../shared/browse-definition.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -32,6 +33,15 @@ export class RootSuccessResponse extends RestResponse { } } +export class BrowseSuccessResponse extends RestResponse { + constructor( + public browseDefinitions: BrowseDefinition[], + public statusCode: string + ) { + super(true, statusCode); + } +} + export class ErrorResponse extends RestResponse { errorMessage: string; diff --git a/src/app/core/cache/response-cache.service.ts b/src/app/core/cache/response-cache.service.ts index eac76c519e..d734940496 100644 --- a/src/app/core/cache/response-cache.service.ts +++ b/src/app/core/cache/response-cache.service.ts @@ -4,7 +4,7 @@ import { MemoizedSelector, Store } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; import { ResponseCacheEntry } from './response-cache.reducer'; -import { hasNoValue } from '../../shared/empty.util'; +import { hasNoValue, hasValue } from '../../shared/empty.util'; import { ResponseCacheRemoveAction, ResponseCacheAddAction } from './response-cache.actions'; import { RestResponse } from './response-cache.models'; import { CoreState } from '../core.reducers'; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index b782f1d4fc..bfd5cebcb6 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -30,6 +30,8 @@ import { ResponseCacheService } from './cache/response-cache.service'; import { RootResponseParsingService } from './data/root-response-parsing.service'; import { ServerResponseService } from '../shared/server-response.service'; import { NativeWindowFactory, NativeWindowService } from '../shared/window.service'; +import { BrowseService } from './browse/browse.service'; +import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; const IMPORTS = [ CommonModule, @@ -61,6 +63,8 @@ const PROVIDERS = [ ResponseCacheService, RootResponseParsingService, ServerResponseService, + BrowseResponseParsingService, + BrowseService, { provide: NativeWindowService, useFactory: NativeWindowFactory } ]; diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts new file mode 100644 index 0000000000..5f27519a93 --- /dev/null +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -0,0 +1,164 @@ +import { BrowseResponseParsingService } from './browse-response-parsing.service'; +import { BrowseEndpointRequest } from './request.models'; +import { BrowseSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; +import { BrowseDefinition } from '../shared/browse-definition.model'; + +describe('BrowseResponseParsingService', () => { + let service: BrowseResponseParsingService; + + beforeEach(() => { + service = new BrowseResponseParsingService(); + }); + + describe('parse', () => { + const validRequest = new BrowseEndpointRequest('https://rest.api/discover/browses'); + + const validResponse = { + payload: { + _embedded: { + browses: [{ + metadataBrowse: false, + sortOptions: [{ name: 'title', metadata: 'dc.title' }, { + name: 'dateissued', + metadata: 'dc.date.issued' + }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], + order: 'ASC', + type: 'browse', + metadata: ['dc.date.issued'], + _links: { + self: { href: 'https://rest.api/discover/browses/dateissued' }, + items: { href: 'https://rest.api/discover/browses/dateissued/items' } + } + }, { + metadataBrowse: true, + sortOptions: [{ name: 'title', metadata: 'dc.title' }, { + name: 'dateissued', + metadata: 'dc.date.issued' + }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], + order: 'ASC', + type: 'browse', + metadata: ['dc.contributor.*', 'dc.creator'], + _links: { + self: { href: 'https://rest.api/discover/browses/author' }, + entries: { href: 'https://rest.api/discover/browses/author/entries' }, + items: { href: 'https://rest.api/discover/browses/author/items' } + } + }] + }, + _links: { self: { href: 'https://rest.api/discover/browses' } }, + page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } + }, statusCode: '200' + }; + + const invalidResponse1 = { + payload: { + _embedded: { + browse: { + metadataBrowse: false, + sortOptions: [{ name: 'title', metadata: 'dc.title' }, { + name: 'dateissued', + metadata: 'dc.date.issued' + }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], + order: 'ASC', + type: 'browse', + metadata: ['dc.date.issued'], + _links: { + self: { href: 'https://rest.api/discover/browses/dateissued' }, + items: { href: 'https://rest.api/discover/browses/dateissued/items' } + } + } + }, + _links: { self: { href: 'https://rest.api/discover/browses' } }, + page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } + }, statusCode: '200' + }; + + const invalidResponse2 = { + payload: { + browses: [{}, {}], + _links: { self: { href: 'https://rest.api/discover/browses' } }, + page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } + }, statusCode: '200' + }; + + const invalidResponse3 = { + payload: { + _links: { self: { href: 'https://rest.api/discover/browses' } }, + page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } + }, statusCode: '500' + }; + + const definitions = [ + Object.assign(new BrowseDefinition(), { + metadataBrowse: false, + sortOptions: [ + { + name: 'title', + metadata: 'dc.title' + }, + { + name: 'dateissued', + metadata: 'dc.date.issued' + }, + { + name: 'dateaccessioned', + metadata: 'dc.date.accessioned' + } + ], + defaultSortOrder: 'ASC', + type: 'browse', + metadataKeys: [ + 'dc.date.issued' + ], + _links: { } + }), + Object.assign(new BrowseDefinition(), { + metadataBrowse: true, + sortOptions: [ + { + name: 'title', + metadata: 'dc.title' + }, + { + name: 'dateissued', + metadata: 'dc.date.issued' + }, + { + name: 'dateaccessioned', + metadata: 'dc.date.accessioned' + } + ], + defaultSortOrder: 'ASC', + type: 'browse', + metadataKeys: [ + 'dc.contributor.*', + 'dc.creator' + ], + _links: { } + }) + ]; + + it('should return a BrowseSuccessResponse if data contains a valid browse endpoint response', () => { + const response = service.parse(validRequest, validResponse); + expect(response.constructor).toBe(BrowseSuccessResponse); + }); + + it('should return an ErrorResponse if data contains an invalid browse endpoint response', () => { + const response1 = service.parse(validRequest, invalidResponse1); + const response2 = service.parse(validRequest, invalidResponse2); + expect(response1.constructor).toBe(ErrorResponse); + expect(response2.constructor).toBe(ErrorResponse); + }); + + it('should return an ErrorResponse if data contains a statuscode other than 200', () => { + const response = service.parse(validRequest, invalidResponse3); + expect(response.constructor).toBe(ErrorResponse); + }); + + it('should return a BrowseSuccessResponse with the BrowseDefinitions in data', () => { + const response = service.parse(validRequest, validResponse); + expect((response as BrowseSuccessResponse).browseDefinitions).toEqual(definitions); + }); + + }); +}); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts new file mode 100644 index 0000000000..8633e7269a --- /dev/null +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { BrowseSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { BrowseDefinition } from '../shared/browse-definition.model'; + +@Injectable() +export class BrowseResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) + && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceRESTv2Serializer(BrowseDefinition); + const browseDefinitions = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + return new BrowseSuccessResponse(browseDefinitions, data.statusCode); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from browse endpoint'), + { statusText: data.statusCode } + ) + ); + } + } +} diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index ec765c3cb1..f9f581128b 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,28 +1,29 @@ import { Inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; - -import { DataService } from './data.service'; -import { Collection } from '../shared/collection.model'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { NormalizedCollection } from '../cache/models/normalized-collection.model'; -import { CoreState } from '../core.reducers'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedCollection } from '../cache/models/normalized-collection.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { CoreState } from '../core.reducers'; +import { Collection } from '../shared/collection.model'; +import { ComColDataService } from './comcol-data.service'; +import { CommunityDataService } from './community-data.service'; +import { RequestService } from './request.service'; @Injectable() -export class CollectionDataService extends DataService { +export class CollectionDataService extends ComColDataService { protected linkName = 'collections'; - protected browseEndpoint = '/discover/browses/dateissued/collections'; constructor( protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, - @Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected cds: CommunityDataService, + protected objectCache: ObjectCacheService ) { - super(NormalizedCollection, EnvConfig); + super(NormalizedCollection); } - } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts new file mode 100644 index 0000000000..be7949826f --- /dev/null +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -0,0 +1,181 @@ +import { Store } from '@ngrx/store'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/Rx'; +import { GlobalConfig } from '../../../config'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedCommunity } from '../cache/models/normalized-community.model'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { CoreState } from '../core.reducers'; +import { ComColDataService } from './comcol-data.service'; +import { CommunityDataService } from './community-data.service'; +import { FindByIDRequest } from './request.models'; +import { RequestService } from './request.service'; + +const LINK_NAME = 'test'; + +/* tslint:disable:max-classes-per-file */ +class NormalizedTestObject implements CacheableObject { + self: string; +} + +class TestService extends ComColDataService { + protected linkName = LINK_NAME; + + constructor( + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected EnvConfig: GlobalConfig, + protected cds: CommunityDataService, + protected objectCache: ObjectCacheService + ) { + super(NormalizedTestObject); + } +} +/* tslint:enable:max-classes-per-file */ + +describe('ComColDataService', () => { + let scheduler: TestScheduler; + let service: TestService; + let responseCache: ResponseCacheService; + let requestService: RequestService; + let cds: CommunityDataService; + let objectCache: ObjectCacheService; + + const rdbService = {} as RemoteDataBuildService; + const store = {} as Store; + const EnvConfig = {} as GlobalConfig; + + const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; + const communitiesEndpoint = 'https://rest.api/core/communities'; + const communityEndpoint = `${communitiesEndpoint}/${scopeID}`; + const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; + const serviceEndpoint = `https://rest.api/core/${LINK_NAME}`; + + function initMockCommunityDataService(): CommunityDataService { + return jasmine.createSpyObj('responseCache', { + getEndpoint: hot('--a-', { a: communitiesEndpoint }), + getFindByIDHref: cold('b-', { b: communityEndpoint }) + }); + } + + function initMockRequestService(): RequestService { + return jasmine.createSpyObj('requestService', ['configure']); + } + + function initMockResponceCacheService(isSuccessful: boolean): ResponseCacheService { + return jasmine.createSpyObj('responseCache', { + get: cold('c-', { + c: { response: { isSuccessful } } + }) + }); + } + + function initMockObjectCacheService(): ObjectCacheService { + return jasmine.createSpyObj('objectCache', { + getByUUID: cold('d-', { + d: { + _links: { + [LINK_NAME]: scopedEndpoint + } + } + }) + }); + } + + function initTestService(): TestService { + return new TestService( + responseCache, + requestService, + rdbService, + store, + EnvConfig, + cds, + objectCache + ); + } + + describe('getScopedEndpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + }); + + it('should configure a new FindByIDRequest for the scope Community', () => { + cds = initMockCommunityDataService(); + requestService = initMockRequestService(); + objectCache = initMockObjectCacheService(); + responseCache = initMockResponceCacheService(true); + service = initTestService(); + + const expected = new FindByIDRequest(communityEndpoint, scopeID); + + scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + describe('if the scope Community can be found', () => { + beforeEach(() => { + cds = initMockCommunityDataService(); + requestService = initMockRequestService(); + objectCache = initMockObjectCacheService(); + responseCache = initMockResponceCacheService(true); + service = initTestService(); + }); + + it('should fetch the scope Community from the cache', () => { + scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe()); + scheduler.flush(); + expect(objectCache.getByUUID).toHaveBeenCalledWith(scopeID, NormalizedCommunity); + }); + + it('should return the endpoint to fetch resources within the given scope', () => { + const result = service.getScopedEndpoint(scopeID); + const expected = cold('--e-', { e: scopedEndpoint }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('if the scope Community can\'t be found', () => { + beforeEach(() => { + cds = initMockCommunityDataService(); + requestService = initMockRequestService(); + objectCache = initMockObjectCacheService(); + responseCache = initMockResponceCacheService(false); + service = initTestService(); + }); + + it('should throw an error', () => { + const result = service.getScopedEndpoint(scopeID); + const expected = cold('--#-', undefined, new Error(`The Community with scope ${scopeID} couldn't be retrieved`)); + + expect(result).toBeObservable(expected); + }); + }); + + describe('if the scope is not specified', () => { + beforeEach(() => { + cds = initMockCommunityDataService(); + requestService = initMockRequestService(); + objectCache = initMockObjectCacheService(); + responseCache = initMockResponceCacheService(true); + service = initTestService(); + }); + + it('should return this.getEndpoint()', () => { + spyOn(service, 'getEndpoint').and.returnValue(cold('--e-', { e: serviceEndpoint })) + + const result = service.getScopedEndpoint(undefined); + const expected = cold('--f-', { f: serviceEndpoint }); + + expect(result).toBeObservable(expected); + }); + }); + + }); +}); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts new file mode 100644 index 0000000000..17d2fb313c --- /dev/null +++ b/src/app/core/data/comcol-data.service.ts @@ -0,0 +1,56 @@ +import { Observable } from 'rxjs/Observable'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { NormalizedCommunity } from '../cache/models/normalized-community.model'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { CommunityDataService } from './community-data.service'; + +import { DataService } from './data.service'; +import { FindByIDRequest } from './request.models'; + +export abstract class ComColDataService extends DataService { + protected abstract cds: CommunityDataService; + protected abstract objectCache: ObjectCacheService; + + /** + * Get the scoped endpoint URL by fetching the object with + * the given scopeID and returning its HAL link with this + * data-service's linkName + * + * @param {string} scopeID + * the id of the scope object + * @return { Observable } + * an Observable containing the scoped URL + */ + public getScopedEndpoint(scopeID: string): Observable { + if (isEmpty(scopeID)) { + return this.getEndpoint(); + } else { + const scopeCommunityHrefObs = this.cds.getEndpoint() + .flatMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, scopeID)) + .filter((href: string) => isNotEmpty(href)) + .take(1) + .do((href: string) => { + const request = new FindByIDRequest(href, scopeID); + this.requestService.configure(request); + }); + + const [successResponse, errorResponse] = scopeCommunityHrefObs + .flatMap((href: string) => this.responseCache.get(href)) + .map((entry: ResponseCacheEntry) => entry.response) + .share() + .partition((response: RestResponse) => response.isSuccessful); + + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.throw(new Error(`The Community with scope ${scopeID} couldn't be retrieved`))), + successResponse + .flatMap((response: DSOSuccessResponse) => this.objectCache.getByUUID(scopeID, NormalizedCommunity)) + .map((nc: NormalizedCommunity) => nc._links[this.linkName]) + .filter((href) => isNotEmpty(href)) + ).distinctUntilChanged(); + } + } +} diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 532bce5ee6..bbee96ab47 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,29 +1,29 @@ import { Inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; - -import { DataService } from './data.service'; -import { Community } from '../shared/community.model'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { NormalizedCommunity } from '../cache/models/normalized-community.model'; -import { CoreState } from '../core.reducers'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedCommunity } from '../cache/models/normalized-community.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { CoreState } from '../core.reducers'; +import { Community } from '../shared/community.model'; +import { ComColDataService } from './comcol-data.service'; +import { RequestService } from './request.service'; @Injectable() -export class CommunityDataService extends DataService { +export class CommunityDataService extends ComColDataService { protected linkName = 'communities'; - protected browseEndpoint = '/discover/browses/dateissued/communities'; + protected cds = this; constructor( protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, - @Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService ) { - super(NormalizedCommunity, EnvConfig); + super(NormalizedCommunity); } - } diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index d1054d69ef..e2f41f5962 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,74 +1,42 @@ -import { ResponseCacheService } from '../cache/response-cache.service'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { RemoteData } from './remote-data'; -import { - FindAllOptions, - FindAllRequest, - FindByIDRequest, - RestRequest, - RootEndpointRequest -} from './request.models'; import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { GlobalConfig } from '../../../config'; -import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { Observable } from 'rxjs/Observable'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { EndpointMap, RootSuccessResponse } from '../cache/response-cache.models'; +import { GlobalConfig } from '../../../config'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { CoreState } from '../core.reducers'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteData } from './remote-data'; +import { FindAllOptions, FindAllRequest, FindByIDRequest, RestRequest } from './request.models'; +import { RequestService } from './request.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; -export abstract class DataService { +export abstract class DataService extends HALEndpointService { protected abstract responseCache: ResponseCacheService; protected abstract requestService: RequestService; protected abstract rdbService: RemoteDataBuildService; protected abstract store: Store; protected abstract linkName: string; - protected abstract browseEndpoint: string; + protected abstract EnvConfig: GlobalConfig; constructor( - private normalizedResourceType: GenericConstructor, - protected EnvConfig: GlobalConfig + protected normalizedResourceType: GenericConstructor, ) { - + super(); } - private getEndpointMap(): Observable { - const request = new RootEndpointRequest(this.EnvConfig); - this.requestService.configure(request); - return this.responseCache.get(request.href) - .map((entry: ResponseCacheEntry) => entry.response) - .filter((response: RootSuccessResponse) => isNotEmpty(response) && isNotEmpty(response.endpointMap)) - .map((response: RootSuccessResponse) => response.endpointMap) - .distinctUntilChanged(); - } + public abstract getScopedEndpoint(scope: string): Observable - public getEndpoint(): Observable { - const request = new RootEndpointRequest(this.EnvConfig); - this.requestService.configure(request); - return this.getEndpointMap() - .map((map: EndpointMap) => map[this.linkName]) - .distinctUntilChanged(); - } - - public isEnabledOnRestApi(): Observable { - return this.getEndpointMap() - .map((map: EndpointMap) => isNotEmpty(map[this.linkName])) - .startWith(undefined) - .distinctUntilChanged(); - } - - protected getFindAllHref(endpoint, options: FindAllOptions = {}): string { - let result; + protected getFindAllHref(endpoint, options: FindAllOptions = {}): Observable { + let result: Observable; const args = []; if (hasValue(options.scopeID)) { - result = new RESTURLCombiner(this.EnvConfig, this.browseEndpoint).toString(); - args.push(`scope=${options.scopeID}`); + result = this.getScopedEndpoint(options.scopeID).distinctUntilChanged(); } else { - result = endpoint; + result = Observable.of(endpoint); } if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { @@ -89,16 +57,19 @@ export abstract class DataService } if (isNotEmpty(args)) { - result = `${result}?${args.join('&')}`; + return result.map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString()); + } else { + return result; } - return result; } - findAll(options: FindAllOptions = {}): RemoteData { - const hrefObs = this.getEndpoint() - .map((endpoint: string) => this.getFindAllHref(endpoint, options)); + findAll(options: FindAllOptions = {}): Observable> { + const hrefObs = this.getEndpoint().filter((href: string) => isNotEmpty(href)) + .flatMap((endpoint: string) => this.getFindAllHref(endpoint, options)); hrefObs + .filter((href: string) => hasValue(href)) + .take(1) .subscribe((href: string) => { const request = new FindAllRequest(href, options); this.requestService.configure(request); @@ -107,15 +78,17 @@ export abstract class DataService return this.rdbService.buildList(hrefObs, this.normalizedResourceType); } - protected getFindByIDHref(endpoint, resourceID): string { + getFindByIDHref(endpoint, resourceID): string { return `${endpoint}/${resourceID}`; } - findById(id: string): RemoteData { + findById(id: string): Observable> { const hrefObs = this.getEndpoint() .map((endpoint: string) => this.getFindByIDHref(endpoint, id)); hrefObs + .filter((href: string) => hasValue(href)) + .take(1) .subscribe((href: string) => { const request = new FindByIDRequest(href, id); this.requestService.configure(request); @@ -124,10 +97,9 @@ export abstract class DataService return this.rdbService.buildSingle(hrefObs, this.normalizedResourceType); } - findByHref(href: string): RemoteData { + findByHref(href: string): Observable> { this.requestService.configure(new RestRequest(href)); return this.rdbService.buildSingle(href, this.normalizedResourceType); - // return this.rdbService.buildSingle(href)); } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts new file mode 100644 index 0000000000..7d610bfaae --- /dev/null +++ b/src/app/core/data/item-data.service.spec.ts @@ -0,0 +1,94 @@ +import { Store } from '@ngrx/store'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/Rx'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { CoreState } from '../core.reducers'; +import { ItemDataService } from './item-data.service'; +import { RequestService } from './request.service'; + +describe('ItemDataService', () => { + let scheduler: TestScheduler; + let service: ItemDataService; + let bs: BrowseService; + + const requestService = {} as RequestService; + const responseCache = {} as ResponseCacheService; + const rdbService = {} as RemoteDataBuildService; + const store = {} as Store; + const EnvConfig = {} as GlobalConfig; + + const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; + const browsesEndpoint = 'https://rest.api/discover/browses'; + const itemBrowseEndpoint = `${browsesEndpoint}/author/items`; + const scopedEndpoint = `${itemBrowseEndpoint}?scope=${scopeID}`; + const serviceEndpoint = `https://rest.api/core/items`; + const browseError = new Error('getBrowseURL failed'); + + function initMockBrowseService(isSuccessful: boolean) { + const obs = isSuccessful ? + cold('--a-', { a: itemBrowseEndpoint }) : + cold('--#-', undefined, browseError); + return jasmine.createSpyObj('bs', { + getBrowseURLFor: obs + }); + } + + function initTestService() { + return new ItemDataService( + responseCache, + requestService, + rdbService, + store, + EnvConfig, + bs + ); + } + + describe('getScopedEndpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + }); + + it('should return the endpoint to fetch Items within the given scope', () => { + bs = initMockBrowseService(true); + service = initTestService(); + + const result = service.getScopedEndpoint(scopeID); + const expected = cold('--b-', { b: scopedEndpoint }); + + expect(result).toBeObservable(expected); + }); + + describe('if the dc.date.issue browse isn\'t configured for items', () => { + beforeEach(() => { + bs = initMockBrowseService(false); + service = initTestService(); + }); + it('should throw an error', () => { + const result = service.getScopedEndpoint(scopeID); + const expected = cold('--#-', undefined, browseError); + + expect(result).toBeObservable(expected); + }); + }); + + describe('if the scope is not specified', () => { + beforeEach(() => { + bs = initMockBrowseService(true); + service = initTestService(); + spyOn(service, 'getEndpoint').and.returnValue(cold('--b-', { b: serviceEndpoint })) + }); + + it('should return this.getEndpoint()', () => { + const result = service.getScopedEndpoint(undefined); + const expected = cold('--c-', { c: serviceEndpoint }); + + expect(result).toBeObservable(expected); + }); + }); + + }); +}); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index d155910b4e..7e978e0879 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,28 +1,44 @@ import { Inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; - -import { DataService } from './data.service'; -import { Item } from '../shared/item.model'; +import { Observable } from 'rxjs/Observable'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedItem } from '../cache/models/normalized-item.model'; import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; -import { NormalizedItem } from '../cache/models/normalized-item.model'; +import { Item } from '../shared/item.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; + +import { DataService } from './data.service'; import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; @Injectable() export class ItemDataService extends DataService { protected linkName = 'items'; - protected browseEndpoint = '/discover/browses/dateissued/items'; constructor( protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, - @Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private bs: BrowseService ) { - super(NormalizedItem, EnvConfig); + super(NormalizedItem); } + + public getScopedEndpoint(scopeID: string): Observable { + if (isEmpty(scopeID)) { + return this.getEndpoint(); + } else { + return this.bs.getBrowseURLFor('dc.date.issued', this.linkName) + .filter((href: string) => isNotEmpty(href)) + .map((href: string) => new URLCombiner(href, `?scope=${scopeID}`).toString()) + .distinctUntilChanged(); + } + } + } diff --git a/src/app/core/data/remote-data.ts b/src/app/core/data/remote-data.ts index b9f58a5567..d8a2f79e66 100644 --- a/src/app/core/data/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -1,12 +1,11 @@ -import { Observable } from 'rxjs/Observable'; - import { PageInfo } from '../shared/page-info.model'; +import { hasValue } from '../../shared/empty.util'; export enum RemoteDataState { - RequestPending = 'RequestPending' as any, - ResponsePending = 'ResponsePending' as any, - Failed = 'Failed' as any, - Success = 'Success' as any + RequestPending = 'RequestPending', + ResponsePending = 'ResponsePending', + Failed = 'Failed', + Success = 'Success' } /** @@ -14,57 +13,48 @@ export enum RemoteDataState { */ export class RemoteData { constructor( - public self: Observable, - private requestPending: Observable, - private responsePending: Observable, - private isSuccessFul: Observable, - public errorMessage: Observable, - public statusCode: Observable, - public pageInfo: Observable, - public payload: Observable + public self: string, + private requestPending: boolean, + private responsePending: boolean, + private isSuccessFul: boolean, + public errorMessage: string, + public statusCode: string, + public pageInfo: PageInfo, + public payload: T ) { } - get state(): Observable { - return Observable.combineLatest( - this.requestPending, - this.responsePending, - this.isSuccessFul, - (requestPending, responsePending, isSuccessFul) => { - if (requestPending) { - return RemoteDataState.RequestPending - } else if (responsePending) { - return RemoteDataState.ResponsePending - } else if (!isSuccessFul) { - return RemoteDataState.Failed - } else { - return RemoteDataState.Success - } - } - ).distinctUntilChanged(); + get state(): RemoteDataState { + if (this.isSuccessFul === true && hasValue(this.payload)) { + return RemoteDataState.Success + } else if (this.isSuccessFul === false) { + return RemoteDataState.Failed + } else if (this.requestPending === true) { + return RemoteDataState.RequestPending + } else { + return RemoteDataState.ResponsePending + } } - get isRequestPending(): Observable { - return this.state.map((state) => state === RemoteDataState.RequestPending).distinctUntilChanged(); + get isRequestPending(): boolean { + return this.state === RemoteDataState.RequestPending; } - get isResponsePending(): Observable { - return this.state.map((state) => state === RemoteDataState.ResponsePending).distinctUntilChanged(); + get isResponsePending(): boolean { + return this.state === RemoteDataState.ResponsePending; } - get isLoading(): Observable { - return this.state.map((state) => { - return state === RemoteDataState.RequestPending - || state === RemoteDataState.ResponsePending - }).distinctUntilChanged(); + get isLoading(): boolean { + return this.state === RemoteDataState.RequestPending + || this.state === RemoteDataState.ResponsePending; } - get hasFailed(): Observable { - return this.state.map((state) => state === RemoteDataState.Failed).distinctUntilChanged(); + get hasFailed(): boolean { + return this.state === RemoteDataState.Failed; } - get hasSucceeded(): Observable { - return this.state.map((state) => state === RemoteDataState.Success).distinctUntilChanged(); + get hasSucceeded(): boolean { + return this.state === RemoteDataState.Success; } } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 8c415e71ef..ab3e38d9cd 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -5,6 +5,7 @@ import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RootResponseParsingService } from './root-response-parsing.service'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; /* tslint:disable:max-classes-per-file */ export class RestRequest { @@ -53,6 +54,16 @@ export class RootEndpointRequest extends RestRequest { } } +export class BrowseEndpointRequest extends RestRequest { + constructor(href: string) { + super(href); + } + + getResponseParser(): GenericConstructor { + return BrowseResponseParsingService; + } +} + export class RequestError extends Error { statusText: string; } diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index e6b4f816f1..3036c7be21 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -1,27 +1,35 @@ import { Injectable } from '@angular/core'; -import { MemoizedSelector, Store } from '@ngrx/store'; +import { createSelector, MemoizedSelector, Store } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; import { hasValue } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { DSOSuccessResponse } from '../cache/response-cache.models'; +import { DSOSuccessResponse, RestResponse } from '../cache/response-cache.models'; import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheService } from '../cache/response-cache.service'; -import { CoreState } from '../core.reducers'; +import { coreSelector, CoreState } from '../core.reducers'; import { keySelector } from '../shared/selectors'; import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; import { RestRequest } from './request.models'; -import { RequestEntry } from './request.reducer'; +import { RequestEntry, RequestState } from './request.reducer'; function entryFromHrefSelector(href: string): MemoizedSelector { return keySelector('data/request', href); } +export function requestStateSelector(): MemoizedSelector { + return createSelector(coreSelector, (state: CoreState) => { + return state['data/request'] as RequestState; + }); +} + + @Injectable() export class RequestService { + private requestsOnTheirWayToTheStore: string[] = []; constructor( private objectCache: ObjectCacheService, @@ -31,6 +39,12 @@ export class RequestService { } isPending(href: string): boolean { + // first check requests that haven't made it to the store yet + if (this.requestsOnTheirWayToTheStore.includes(href)) { + return true; + } + + // then check the store let isPending = false; this.store.select(entryFromHrefSelector(href)) .take(1) @@ -47,16 +61,19 @@ export class RequestService { configure(request: RestRequest): void { let isCached = this.objectCache.hasBySelfLink(request.href); - if (!isCached && this.responseCache.has(request.href)) { - const [dsoSuccessResponse, otherSuccessResponse] = this.responseCache.get(request.href) + const [successResponse, errorResponse] = this.responseCache.get(request.href) .take(1) - .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) .map((entry: ResponseCacheEntry) => entry.response) + .share() + .partition((response: RestResponse) => response.isSuccessful); + + const [dsoSuccessResponse, otherSuccessResponse] = successResponse .share() .partition((response: DSOSuccessResponse) => hasValue(response.resourceSelfLinks)); Observable.merge( + errorResponse.map(() => true), // TODO add a configurable number of retries in case of an error. otherSuccessResponse.map(() => true), dsoSuccessResponse // a DSOSuccessResponse should only be considered cached if all its resources are cached .map((response: DSOSuccessResponse) => response.resourceSelfLinks) @@ -71,6 +88,24 @@ export class RequestService { if (!(isCached || isPending)) { this.store.dispatch(new RequestConfigureAction(request)); this.store.dispatch(new RequestExecuteAction(request.href)); + this.trackRequestsOnTheirWayToTheStore(request.href); } } + + /** + * ngrx action dispatches are asynchronous. But this.isPending needs to return true as soon as the + * configure method for a request has been executed, otherwise certain requests will happen multiple times. + * + * This method will store the href of every request that gets configured in a local variable, and + * remove it as soon as it can be found in the store. + */ + private trackRequestsOnTheirWayToTheStore(href: string) { + this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, href]; + this.store.select(entryFromHrefSelector(href)) + .filter((re: RequestEntry) => hasValue(re)) + .take(1) + .subscribe((re: RequestEntry) => { + this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== href) + }); + } } diff --git a/src/app/core/data/root-response-parsing.service.ts b/src/app/core/data/root-response-parsing.service.ts index 016a501685..a3e7fc22a3 100644 --- a/src/app/core/data/root-response-parsing.service.ts +++ b/src/app/core/data/root-response-parsing.service.ts @@ -19,12 +19,7 @@ export class RootResponseParsingService implements ResponseParsingService { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { const links = data.payload._links; for (const link of Object.keys(links)) { - let href = links[link].href; - // TODO temporary workaround as these endpoint paths are relative, but should be absolute - if (isNotEmpty(href) && !href.startsWith('http')) { - href = new RESTURLCombiner(this.EnvConfig, href.substring(this.EnvConfig.rest.nameSpace.length)).toString(); - } - links[link] = href; + links[link] = links[link].href; } return new RootSuccessResponse(links, data.statusCode); } else { diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 1258751f58..4c8775fcfb 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -30,6 +30,8 @@ import { Item } from '../../core/shared/item.model'; import { MockItem } from '../../shared/mocks/mock-item'; import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { BrowseService } from '../browse/browse.service'; +import { PageInfo } from '../shared/page-info.model'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -111,6 +113,7 @@ describe('MetadataService', () => { Meta, Title, ItemDataService, + BrowseService, MetadataService ], schemas: [CUSTOM_ELEMENTS_SCHEMA] @@ -173,33 +176,17 @@ describe('MetadataService', () => { expect(tagStore.get('description')[0].content).toEqual('This is a dummy item component for testing!'); })); - const mockRemoteData = (mockItem: Item): RemoteData => { - return new RemoteData( - Observable.create((observer) => { - observer.next(''); - }), - Observable.create((observer) => { - observer.next(false); - }), - Observable.create((observer) => { - observer.next(false); - }), - Observable.create((observer) => { - observer.next(true); - }), - Observable.create((observer) => { - observer.next(''); - }), - Observable.create((observer) => { - observer.next(200); - }), - Observable.create((observer) => { - observer.next({}); - }), - Observable.create((observer) => { - observer.next(MockItem); - }) - ); + const mockRemoteData = (mockItem: Item): Observable> => { + return Observable.of(new RemoteData( + '', + false, + false, + true, + '', + '200', + {} as PageInfo, + MockItem + )); } const mockType = (mockItem: Item, type: string): Item => { diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 32b002e721..39fb454ac5 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -25,6 +25,8 @@ import { Item } from '../shared/item.model'; import { Metadatum } from '../shared/metadatum.model'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { hasValue } from '../../shared/empty.util'; @Injectable() export class MetadataService { @@ -64,13 +66,16 @@ export class MetadataService { }); } - public processRemoteData(remoteData: RemoteData): void { - remoteData.payload.take(1).subscribe((dspaceObject: DSpaceObject) => { - if (!this.initialized) { - this.initialize(dspaceObject); - } - this.currentObject.next(dspaceObject); - }); + public processRemoteData(remoteData: Observable>): void { + remoteData.map((rd: RemoteData) => rd.payload) + .filter((co: CacheableObject) => hasValue(co)) + .take(1) + .subscribe((dspaceObject: DSpaceObject) => { + if (!this.initialized) { + this.initialize(dspaceObject); + } + this.currentObject.next(dspaceObject); + }); } private processRouteChange(routeInfo: any): void { @@ -268,11 +273,14 @@ export class MetadataService { // taking only two, fist one is empty array item.getFiles().take(2).subscribe((bitstreams: Bitstream[]) => { for (const bitstream of bitstreams) { - bitstream.format.payload.take(1).subscribe((format) => { - if (format.mimetype === 'application/pdf') { - this.addMetaTag('citation_pdf_url', bitstream.content); - } - }); + bitstream.format.take(1) + .map((rd: RemoteData) => rd.payload) + .filter((format: BitstreamFormat) => hasValue(format)) + .subscribe((format: BitstreamFormat) => { + if (format.mimetype === 'application/pdf') { + this.addMetaTag('citation_pdf_url', bitstream.content); + } + }); } }); } diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index 0b77a7b032..511c2c5cd2 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -2,6 +2,7 @@ import { DSpaceObject } from './dspace-object.model'; import { RemoteData } from '../data/remote-data'; import { Item } from './item.model'; import { BitstreamFormat } from './bitstream-format.model'; +import { Observable } from 'rxjs/Observable'; export class Bitstream extends DSpaceObject { @@ -23,17 +24,17 @@ export class Bitstream extends DSpaceObject { /** * An array of Bitstream Format of this Bitstream */ - format: RemoteData; + format: Observable>; /** * An array of Items that are direct parents of this Bitstream */ - parents: RemoteData; + parents: Observable>; /** * The Bundle that owns this Bitstream */ - owner: RemoteData; + owner: Observable>; /** * The URL to retrieve this Bitstream's file diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts new file mode 100644 index 0000000000..bdb91167b0 --- /dev/null +++ b/src/app/core/shared/browse-definition.model.ts @@ -0,0 +1,24 @@ +import { autoserialize, autoserializeAs } from 'cerialize'; +import { SortOption } from './sort-option.model'; + +export class BrowseDefinition { + @autoserialize + metadataBrowse: boolean; + + @autoserialize + sortOptions: SortOption[]; + + @autoserializeAs('order') + defaultSortOrder: string; + + @autoserialize + type: string; + + @autoserializeAs('metadata') + metadataKeys: string[]; + + @autoserialize + _links: { + [name: string]: string + } +} diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts index 798b7c402c..9a8afb2661 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -2,23 +2,24 @@ import { DSpaceObject } from './dspace-object.model'; import { Bitstream } from './bitstream.model'; import { Item } from './item.model'; import { RemoteData } from '../data/remote-data'; +import { Observable } from 'rxjs/Observable'; export class Bundle extends DSpaceObject { /** * The primary bitstream of this Bundle */ - primaryBitstream: RemoteData; + primaryBitstream: Observable>; /** * An array of Items that are direct parents of this Bundle */ - parents: RemoteData; + parents: Observable>; /** * The Item that owns this Bundle */ - owner: RemoteData; + owner: Observable>; - bitstreams: RemoteData + bitstreams: Observable> } diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index 207837ef04..b2f8d90a65 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -2,6 +2,7 @@ import { DSpaceObject } from './dspace-object.model'; import { Bitstream } from './bitstream.model'; import { Item } from './item.model'; import { RemoteData } from '../data/remote-data'; +import { Observable } from 'rxjs/Observable'; export class Collection extends DSpaceObject { @@ -53,18 +54,18 @@ export class Collection extends DSpaceObject { /** * The Bitstream that represents the logo of this Collection */ - logo: RemoteData; + logo: Observable>; /** * An array of Collections that are direct parents of this Collection */ - parents: RemoteData; + parents: Observable>; /** * The Collection that owns this Collection */ - owner: RemoteData; + owner: Observable>; - items: RemoteData; + items: Observable>; } diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts index afe9fd734e..c34666b0f0 100644 --- a/src/app/core/shared/community.model.ts +++ b/src/app/core/shared/community.model.ts @@ -2,6 +2,7 @@ import { DSpaceObject } from './dspace-object.model'; import { Bitstream } from './bitstream.model'; import { Collection } from './collection.model'; import { RemoteData } from '../data/remote-data'; +import { Observable } from 'rxjs/Observable'; export class Community extends DSpaceObject { @@ -45,18 +46,18 @@ export class Community extends DSpaceObject { /** * The Bitstream that represents the logo of this Community */ - logo: RemoteData; + logo: Observable>; /** * An array of Communities that are direct parents of this Community */ - parents: RemoteData; + parents: Observable>; /** * The Community that owns this Community */ - owner: RemoteData; + owner: Observable>; - collections: RemoteData; + collections: Observable>; } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 8584c179dc..572efecada 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -4,6 +4,7 @@ import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; import { ListableObject } from '../../object-list/listable-object/listable-object.model'; +import { Observable } from 'rxjs/Observable'; /** * An abstract model class for a DSpaceObject. @@ -40,12 +41,12 @@ export abstract class DSpaceObject implements CacheableObject, ListableObject { /** * An array of DSpaceObjects that are direct parents of this DSpaceObject */ - parents: RemoteData; + parents: Observable>; /** * The DSpaceObject that owns this DSpaceObject */ - owner: RemoteData; + owner: Observable>; /** * Find a metadata field by key and language diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts new file mode 100644 index 0000000000..f7adc1eccf --- /dev/null +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -0,0 +1,135 @@ +import { cold, hot } from 'jasmine-marbles'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { RootEndpointRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from './hal-endpoint.service'; + +describe('HALEndpointService', () => { + let service: HALEndpointService; + let responseCache: ResponseCacheService; + let requestService: RequestService; + let envConfig: GlobalConfig; + + const endpointMap = { + test: 'https://rest.api/test', + }; + + /* tslint:disable:no-shadowed-variable */ + class TestService extends HALEndpointService { + protected linkName = 'test'; + + constructor(protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected EnvConfig: GlobalConfig) { + super(); + } + } + + /* tslint:enable:no-shadowed-variable */ + + describe('getEndpointMap', () => { + beforeEach(() => { + responseCache = jasmine.createSpyObj('responseCache', { + get: hot('--a-', { + a: { + response: { endpointMap: endpointMap } + } + }) + }); + + requestService = jasmine.createSpyObj('requestService', ['configure']); + + envConfig = { + rest: { baseUrl: 'https://rest.api/' } + } as any; + + service = new TestService( + responseCache, + requestService, + envConfig + ); + }); + + it('should configure a new RootEndpointRequest', () => { + (service as any).getEndpointMap(); + const expected = new RootEndpointRequest(envConfig); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should return an Observable of the endpoint map', () => { + const result = (service as any).getEndpointMap(); + const expected = cold('--b-', { b: endpointMap }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('getEndpoint', () => { + beforeEach(() => { + service = new TestService( + responseCache, + requestService, + envConfig + ); + + spyOn(service as any, 'getEndpointMap').and + .returnValue(hot('--a-', { a: endpointMap })); + }); + + it('should return the endpoint URL for the service\'s linkName', () => { + const result = service.getEndpoint(); + const expected = cold('--b-', { b: endpointMap.test }); + expect(result).toBeObservable(expected); + }); + + it('should return undefined for a linkName that isn\'t in the endpoint map', () => { + (service as any).linkName = 'unknown'; + const result = service.getEndpoint(); + const expected = cold('--b-', { b: undefined }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('isEnabledOnRestApi', () => { + beforeEach(() => { + service = new TestService( + responseCache, + requestService, + envConfig + ); + + }); + + it('should return undefined as long as getEndpointMap hasn\'t fired', () => { + spyOn(service as any, 'getEndpointMap').and + .returnValue(hot('----')); + + const result = service.isEnabledOnRestApi(); + const expected = cold('b---', { b: undefined }); + expect(result).toBeObservable(expected); + }); + + it('should return true if the service\'s linkName is in the endpoint map', () => { + spyOn(service as any, 'getEndpointMap').and + .returnValue(hot('--a-', { a: endpointMap })); + + const result = service.isEnabledOnRestApi(); + const expected = cold('b-c-', { b: undefined, c: true }); + expect(result).toBeObservable(expected); + }); + + it('should return false if the service\'s linkName isn\'t in the endpoint map', () => { + spyOn(service as any, 'getEndpointMap').and + .returnValue(hot('--a-', { a: endpointMap })); + + (service as any).linkName = 'unknown'; + const result = service.isEnabledOnRestApi(); + const expected = cold('b-c-', { b: undefined, c: false }); + expect(result).toBeObservable(expected); + }); + + }); + +}); diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts new file mode 100644 index 0000000000..fa11fed308 --- /dev/null +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -0,0 +1,39 @@ +import { Observable } from 'rxjs/Observable'; +import { RequestService } from '../data/request.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { EndpointMap, RootSuccessResponse } from '../cache/response-cache.models'; +import { RootEndpointRequest } from '../data/request.models'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { isNotEmpty } from '../../shared/empty.util'; + +export abstract class HALEndpointService { + protected abstract responseCache: ResponseCacheService; + protected abstract requestService: RequestService; + protected abstract linkName: string; + protected abstract EnvConfig: GlobalConfig; + + protected getEndpointMap(): Observable { + const request = new RootEndpointRequest(this.EnvConfig); + this.requestService.configure(request); + return this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .filter((response: RootSuccessResponse) => isNotEmpty(response) && isNotEmpty(response.endpointMap)) + .map((response: RootSuccessResponse) => response.endpointMap) + .distinctUntilChanged(); + } + + public getEndpoint(): Observable { + return this.getEndpointMap() + .map((map: EndpointMap) => map[this.linkName]) + .distinctUntilChanged(); + } + + public isEnabledOnRestApi(): Observable { + return this.getEndpointMap() + .map((map: EndpointMap) => isNotEmpty(map[this.linkName])) + .startWith(undefined) + .distinctUntilChanged(); + } + +} diff --git a/src/app/core/shared/item.model.spec.ts b/src/app/core/shared/item.model.spec.ts index 0c89828c9e..1e962f7038 100644 --- a/src/app/core/shared/item.model.spec.ts +++ b/src/app/core/shared/item.model.spec.ts @@ -103,23 +103,15 @@ describe('Item', () => { }); function createRemoteDataObject(object: any) { - const self = Observable.of(''); - const requestPending = Observable.of(false); - const responsePending = Observable.of(false); - const isSuccessful = Observable.of(true); - const errorMessage = Observable.of(undefined); - const statusCode = Observable.of('200'); - const pageInfo = Observable.of(new PageInfo()); - const payload = Observable.of(object); - return new RemoteData( - self, - requestPending, - responsePending, - isSuccessful, - errorMessage, - statusCode, - pageInfo, - payload - ); + return Observable.of(new RemoteData( + '', + false, + false, + true, + undefined, + '200', + new PageInfo(), + object + )); } diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 16cce9b610..dd60ad9b01 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -4,7 +4,7 @@ import { DSpaceObject } from './dspace-object.model'; import { Collection } from './collection.model'; import { RemoteData } from '../data/remote-data'; import { Bitstream } from './bitstream.model'; -import { isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; export class Item extends DSpaceObject { @@ -36,18 +36,18 @@ export class Item extends DSpaceObject { /** * An array of Collections that are direct parents of this Item */ - parents: RemoteData; + parents: Observable>; /** * The Collection that owns this Item */ - owningCollection: RemoteData; + owningCollection: Observable>; - get owner(): RemoteData { + get owner(): Observable> { return this.owningCollection; } - bitstreams: RemoteData; + bitstreams: Observable>; /** * Retrieves the thumbnail of this item @@ -87,9 +87,14 @@ export class Item extends DSpaceObject { * @returns {Observable} the bitstreams with the given bundleName */ getBitstreamsByBundleName(bundleName: string): Observable { - return this.bitstreams.payload.startWith([]) + return this.bitstreams + .map((rd: RemoteData) => rd.payload) + .filter((bitstreams: Bitstream[]) => hasValue(bitstreams)) + .startWith([]) .map((bitstreams) => { - return bitstreams.filter((bitstream) => bitstream.bundleName === bundleName) + return bitstreams + .filter((bitstream) => hasValue(bitstream)) + .filter((bitstream) => bitstream.bundleName === bundleName) }); } diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index 15e707b016..f3554e18cf 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -3,10 +3,10 @@ * https://github.com/Microsoft/TypeScript/pull/15486 */ export enum ResourceType { - Bundle = 'bundle' as any, - Bitstream = 'bitstream' as any, - BitstreamFormat = 'bitstreamformat' as any, - Item = 'item' as any, - Collection = 'collection' as any, - Community = 'community' as any, + Bundle = 'bundle', + Bitstream = 'bitstream', + BitstreamFormat = 'bitstreamformat', + Item = 'item', + Collection = 'collection', + Community = 'community', } diff --git a/src/app/core/shared/sort-option.model.ts b/src/app/core/shared/sort-option.model.ts new file mode 100644 index 0000000000..c735e87b9a --- /dev/null +++ b/src/app/core/shared/sort-option.model.ts @@ -0,0 +1,9 @@ +import { autoserialize } from 'cerialize'; + +export class SortOption { + @autoserialize + name: string; + + @autoserialize + metadata: string; +} diff --git a/src/app/object-list/object-list.component.html b/src/app/object-list/object-list.component.html index 84c800fef4..b97524d58c 100644 --- a/src/app/object-list/object-list.component.html +++ b/src/app/object-list/object-list.component.html @@ -1,7 +1,7 @@ -
    -
  • +
      +
    - - diff --git a/src/app/object-list/object-list.component.ts b/src/app/object-list/object-list.component.ts index c3a42a2e2a..0f7decadd7 100644 --- a/src/app/object-list/object-list.component.ts +++ b/src/app/object-list/object-list.component.ts @@ -1,24 +1,22 @@ import { + ChangeDetectionStrategy, Component, EventEmitter, Input, - ViewEncapsulation, - ChangeDetectionStrategy, - OnInit, - Output, SimpleChanges, OnChanges, ChangeDetectorRef, DoCheck + Output, + ViewEncapsulation } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { RemoteData } from '../core/data/remote-data'; import { PageInfo } from '../core/shared/page-info.model'; - -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; - -import { SortOptions, SortDirection } from '../core/cache/models/sort-options.model'; import { ListableObject } from '../object-list/listable-object/listable-object.model'; import { fadeIn } from '../shared/animations/fade'; +import { hasValue } from '../shared/empty.util'; + +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; @Component({ changeDetection: ChangeDetectionStrategy.Default, @@ -28,14 +26,35 @@ import { fadeIn } from '../shared/animations/fade'; templateUrl: './object-list.component.html', animations: [fadeIn] }) -export class ObjectListComponent implements OnChanges, OnInit { +export class ObjectListComponent { - @Input() objects: RemoteData; @Input() config: PaginationComponentOptions; @Input() sortConfig: SortOptions; @Input() hideGear = false; @Input() hidePagerWhenSinglePage = true; - pageInfo: Observable; + private _objects: RemoteData; + pageInfo: PageInfo; + @Input() set objects(objects: RemoteData) { + this._objects = objects; + if (hasValue(objects)) { + this.pageInfo = objects.pageInfo; + } + } + get objects() { + return this._objects; + } + + /** + * An event fired when the page is changed. + * Event's payload equals to the newly selected page. + */ + @Output() change: EventEmitter<{ + pagination: PaginationComponentOptions, + sort: SortOptions + }> = new EventEmitter<{ + pagination: PaginationComponentOptions, + sort: SortOptions + }>(); /** * An event fired when the page is changed. @@ -64,26 +83,6 @@ export class ObjectListComponent implements OnChanges, OnInit { @Output() sortFieldChange: EventEmitter = new EventEmitter(); data: any = {}; - ngOnChanges(changes: SimpleChanges) { - if (changes.objects && !changes.objects.isFirstChange()) { - this.pageInfo = this.objects.pageInfo; - } - } - - ngOnInit(): void { - this.pageInfo = this.objects.pageInfo; - } - - /** - * @param route - * Route is a singleton service provided by Angular. - * @param router - * Router is a singleton service provided by Angular. - */ - constructor( - private cdRef: ChangeDetectorRef) { - } - onPageChange(event) { this.pageChange.emit(event); } diff --git a/src/app/pagenotfound/pagenotfound.component.ts b/src/app/pagenotfound/pagenotfound.component.ts index 762a4000a1..bd119a4de9 100644 --- a/src/app/pagenotfound/pagenotfound.component.ts +++ b/src/app/pagenotfound/pagenotfound.component.ts @@ -5,7 +5,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; selector: 'ds-pagenotfound', styleUrls: ['./pagenotfound.component.scss'], templateUrl: './pagenotfound.component.html', - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.Default }) export class PageNotFoundComponent { constructor(responseService: ServerResponseService) { diff --git a/src/app/shared/animations/fade.ts b/src/app/shared/animations/fade.ts index e5272cc123..09a0be66ba 100644 --- a/src/app/shared/animations/fade.ts +++ b/src/app/shared/animations/fade.ts @@ -1,4 +1,4 @@ -import { animate, state, transition, trigger, style, keyframes } from '@angular/animations'; +import { animate, style, transition, trigger } from '@angular/animations'; const fadeEnter = transition(':enter', [ style({ opacity: 0 }), diff --git a/src/app/shared/mocks/mock-item.ts b/src/app/shared/mocks/mock-item.ts index 0331491aa0..6b34a31888 100644 --- a/src/app/shared/mocks/mock-item.ts +++ b/src/app/shared/mocks/mock-item.ts @@ -9,141 +9,87 @@ export const MockItem: Item = Object.assign(new Item(), { isArchived: true, isDiscoverable: true, isWithdrawn: false, - bitstreams: { - self: { - _isScalar: true, - value: '1507836003548', - scheduler: null - }, - requestPending: Observable.create((observer) => { - observer.next(false); - }), - responsePending: Observable.create((observer) => { - observer.next(false); - }), - isSuccessFul: Observable.create((observer) => { - observer.next(true); - }), - errorMessage: Observable.create((observer) => { - observer.next(''); - }), - statusCode: Observable.create((observer) => { - observer.next(202); - }), - pageInfo: Observable.create((observer) => { - observer.next({}); - }), - payload: Observable.create((observer) => { - observer.next([ - { - sizeBytes: 10201, - content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', - format: { - self: { - _isScalar: true, - value: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/10', - scheduler: null - }, - requestPending: Observable.create((observer) => { - observer.next(false); - }), - responsePending: Observable.create((observer) => { - observer.next(false); - }), - isSuccessFul: Observable.create((observer) => { - observer.next(true); - }), - errorMessage: Observable.create((observer) => { - observer.next(''); - }), - statusCode: Observable.create((observer) => { - observer.next(202); - }), - pageInfo: Observable.create((observer) => { - observer.next({}); - }), - payload: Observable.create((observer) => { - observer.next({ - shortDescription: 'Microsoft Word XML', - description: 'Microsoft Word XML', - mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - supportLevel: 0, - internal: false, - extensions: null, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/10' - }); - }) - }, - bundleName: 'ORIGINAL', - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', - id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - type: 'bitstream', - name: 'test_word.docx', - metadata: [ - { - key: 'dc.title', - language: null, - value: 'test_word.docx' - } - ] - }, - { - sizeBytes: 31302, - content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content', - format: { - self: { - _isScalar: true, - value: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4', - scheduler: null - }, - requestPending: Observable.create((observer) => { - observer.next(false); - }), - responsePending: Observable.create((observer) => { - observer.next(false); - }), - isSuccessFul: Observable.create((observer) => { - observer.next(true); - }), - errorMessage: Observable.create((observer) => { - observer.next(''); - }), - statusCode: Observable.create((observer) => { - observer.next(202); - }), - pageInfo: Observable.create((observer) => { - observer.next({}); - }), - payload: Observable.create((observer) => { - observer.next({ - shortDescription: 'Adobe PDF', - description: 'Adobe Portable Document Format', - mimetype: 'application/pdf', - supportLevel: 0, - internal: false, - extensions: null, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4' - }); - }) - }, - bundleName: 'ORIGINAL', - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28', - id: '99b00f3c-1cc6-4689-8158-91965bee6b28', - uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28', - type: 'bitstream', - name: 'test_pdf.pdf', - metadata: [ - { - key: 'dc.title', - language: null, - value: 'test_pdf.pdf' - } - ] - } - ]); - }) - }, + bitstreams: Observable.of({ + self: 'dspace-angular://aggregated/object/1507836003548', + requestPending: false, + responsePending: false, + isSuccessFul: true, + errorMessage: '', + statusCode: '202', + pageInfo: {}, + payload: [ + { + sizeBytes: 10201, + content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: Observable.of({ + self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/10', + requestPending: false, + responsePending: false, + isSuccessFul: true, + errorMessage: '', + statusCode: '202', + pageInfo: {}, + payload: { + shortDescription: 'Microsoft Word XML', + description: 'Microsoft Word XML', + mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + supportLevel: 0, + internal: false, + extensions: null, + self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/10' + } + }), + bundleName: 'ORIGINAL', + self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + name: 'test_word.docx', + metadata: [ + { + key: 'dc.title', + language: null, + value: 'test_word.docx' + } + ] + }, + { + sizeBytes: 31302, + content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content', + format: Observable.of({ + self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4', + requestPending: false, + responsePending: false, + isSuccessFul: true, + errorMessage: '', + statusCode: '202', + pageInfo: {}, + payload: { + shortDescription: 'Adobe PDF', + description: 'Adobe Portable Document Format', + mimetype: 'application/pdf', + supportLevel: 0, + internal: false, + extensions: null, + self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4' + } + }), + bundleName: 'ORIGINAL', + self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28', + id: '99b00f3c-1cc6-4689-8158-91965bee6b28', + uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28', + type: 'bitstream', + name: 'test_pdf.pdf', + metadata: [ + { + key: 'dc.title', + language: null, + value: 'test_pdf.pdf' + } + ] + } + ] + }), self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', @@ -241,33 +187,15 @@ export const MockItem: Item = Object.assign(new Item(), { value: 'text' } ], - owningCollection: { - self: { - _isScalar: true, - value: 'https://dspace7.4science.it/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', - scheduler: null - }, - requestPending: Observable.create((observer) => { - observer.next(false); - }), - responsePending: Observable.create((observer) => { - observer.next(false); - }), - isSuccessFul: Observable.create((observer) => { - observer.next(true); - }), - errorMessage: Observable.create((observer) => { - observer.next(''); - }), - statusCode: Observable.create((observer) => { - observer.next(202); - }), - pageInfo: Observable.create((observer) => { - observer.next({}); - }), - payload: Observable.create((observer) => { - observer.next([]); - }) - } -}) + owningCollection: Observable.of({ + self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', + requestPending: false, + responsePending: false, + isSuccessFul: true, + errorMessage: '', + statusCode: '202', + pageInfo: {}, + payload: [] + } + )}); /* tslint:enable:no-shadowed-variable */ diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html index 76b234ec0c..18469f686a 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -1,11 +1,11 @@ -
    -
    + +
    -
    +
    diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 0da50658a5..ee1a8cd8f5 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -8,6 +8,7 @@ import { ResourceType } from '../../core/shared/resource-type'; import { RouterTestingModule } from '@angular/router/testing'; import { Community } from '../../core/shared/community.model'; import { TranslateModule } from '@ngx-translate/core'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; describe('SearchFormComponent', () => { let comp: SearchFormComponent; @@ -30,7 +31,7 @@ describe('SearchFormComponent', () => { }); it('should display scopes when available with default and all scopes', () => { - comp.scopes = Observable.of(objects); + comp.scopes = objects; fixture.detectChanges(); const select: HTMLElement = de.query(By.css('select')).nativeElement; expect(select).toBeDefined(); @@ -64,7 +65,7 @@ describe('SearchFormComponent', () => { })); it('should select correct scope option in scope select', fakeAsync(() => { - comp.scopes = Observable.of(objects); + comp.scopes = objects; fixture.detectChanges(); const testCommunity = objects[1]; @@ -100,7 +101,7 @@ describe('SearchFormComponent', () => { // })); }); -export const objects = [ +export const objects: DSpaceObject[] = [ Object.assign(new Community(), { handle: '10673/11', logo: { diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index a30e0f0745..76b33a8fd6 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -20,7 +20,7 @@ export class SearchFormComponent { selectedId = ''; // Optional existing search parameters @Input() currentParams: {}; - @Input() scopes: Observable; + @Input() scopes: DSpaceObject[]; @Input() set scope(dso: DSpaceObject) { diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 2d77a0a400..85aeca5390 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -30,6 +30,7 @@ import { SearchResultListElementComponent } from '../object-list/search-result-l import { SearchFormComponent } from './search-form/search-form.component'; import { WrapperListElementComponent } from '../object-list/wrapper-list-element/wrapper-list-element.component'; import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; +import { VarDirective } from './utils/var.directive'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -77,6 +78,10 @@ const ENTRY_COMPONENTS = [ SearchResultListElementComponent ]; +const DIRECTIVES = [ + VarDirective +]; + @NgModule({ imports: [ ...MODULES @@ -84,6 +89,7 @@ const ENTRY_COMPONENTS = [ declarations: [ ...PIPES, ...COMPONENTS, + ...DIRECTIVES, ...ENTRY_COMPONENTS, ...DIRECTIVES ], diff --git a/src/app/shared/utils/var.directive.ts b/src/app/shared/utils/var.directive.ts new file mode 100644 index 0000000000..f6ef731042 --- /dev/null +++ b/src/app/shared/utils/var.directive.ts @@ -0,0 +1,23 @@ +import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'; + +/* tslint:disable:directive-selector */ +@Directive({ + selector: '[ngVar]', +}) +export class VarDirective { + @Input() + set ngVar(context: any) { + this.context.$implicit = this.context.ngVar = context; + this.updateView(); + } + + context: any = {}; + + constructor(private vcRef: ViewContainerRef, private templateRef: TemplateRef) {} + + updateView() { + this.vcRef.clear(); + this.vcRef.createEmbeddedView(this.templateRef, this.context); + } +} +/* tslint:enable:directive-selector */ diff --git a/src/main.server.ts b/src/main.server.ts index aae5b89a62..be2d89fbf3 100644 --- a/src/main.server.ts +++ b/src/main.server.ts @@ -8,19 +8,15 @@ import * as https from 'https'; import * as morgan from 'morgan'; import * as express from 'express'; import * as bodyParser from 'body-parser'; -import * as session from 'express-session'; import * as compression from 'compression'; import * as cookieParser from 'cookie-parser'; -import { platformServer, renderModuleFactory } from '@angular/platform-server'; import { enableProdMode } from '@angular/core'; import { ngExpressEngine } from '@nguniversal/express-engine'; import { ServerAppModule } from './modules/app/server-app.module'; -import { serverApi, createMockApi } from './backend/api'; - import { ROUTES } from './routes'; import { ENV_CONFIG } from './config'; diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index aad1af9f4e..61f4cf8499 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -1,26 +1,25 @@ -import { NgModule, APP_INITIALIZER } from '@angular/core'; -import { HttpClientModule, HttpClient } from '@angular/common/http'; -import { RouterModule } from '@angular/router'; +import { HttpClient, HttpClientModule } from '@angular/common/http'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterModule } from '@angular/router'; import { IdlePreload, IdlePreloadModule } from '@angularclass/idle-preload'; -import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; -import { TranslateHttpLoader } from '@ngx-translate/http-loader'; - import { EffectsModule } from '@ngrx/effects'; -import { TransferState } from '../transfer-state/transfer-state'; -import { BrowserTransferStateModule } from '../transfer-state/browser-transfer-state.module'; -import { BrowserTransferStoreEffects } from '../transfer-store/browser-transfer-store.effects'; -import { BrowserTransferStoreModule } from '../transfer-store/browser-transfer-store.module'; - -import { AppModule } from '../../app/app.module'; -import { CoreModule } from '../../app/core/core.module'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { AppComponent } from '../../app/app.component'; +import { AppModule } from '../../app/app.module'; +import { BrowserTransferStateModule } from '../transfer-state/browser-transfer-state.module'; + +import { TransferState } from '../transfer-state/transfer-state'; +import { BrowserTransferStoreEffects } from '../transfer-store/browser-transfer-store.effects'; +import { BrowserTransferStoreModule } from '../transfer-store/browser-transfer-store.module'; + export function init(cache: TransferState) { return () => { cache.initialize(); @@ -41,6 +40,7 @@ export function createTranslateLoader(http: HttpClient) { // forRoot ensures the providers are only created once IdlePreloadModule.forRoot(), RouterModule.forRoot([], { + // enableTracing: true, useHash: false, preloadingStrategy: IdlePreload diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index d97c13a7bb..eaac592dff 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -36,8 +36,16 @@ export function boot(cache: TransferState, appRef: ApplicationRef, store: Store< // authentication mechanism goes here return () => { appRef.isStable.filter((stable: boolean) => stable).first().subscribe(() => { - cache.inject(); - }); + // isStable == true doesn't guarantee that all dispatched actions have been + // processed yet. So in those cases the store snapshot wouldn't be complete + // and a rehydrate would leave the app in a broken state + // + // This setTimeout without delay schedules the cache.inject() to happen ASAP + // after everything that's already scheduled, and it solves that problem. + setTimeout(() => { + cache.inject(); + }, 0); + }); }; } diff --git a/yarn.lock b/yarn.lock index 2fcafccd02..91b2a787e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1073,9 +1073,9 @@ caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639: version "1.0.30000740" resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000740.tgz#03fcaaa176e3ed075895f72d46c1a12149bbeac9" -caniuse-lite@1.0.30000697: - version "1.0.30000697" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000697.tgz#125fb00604b63fbb188db96a667ce2922dcd6cdd" +caniuse-lite@1.0.30000746: + version "1.0.30000746" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000746.tgz#c64f95a3925cfd30207a308ed76c1ae96ea09ea0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000744: version "1.0.30000745"