Merge branch 'live-rest-backend' into w2p-41914_DSO-list-component

This commit is contained in:
Art Lowel
2017-06-20 11:39:52 +02:00
56 changed files with 505 additions and 245 deletions

View File

@@ -10,6 +10,15 @@
"license": "License"
}
},
"community": {
"page": {
"news": "News",
"license": "License"
},
"sub-collection-list": {
"head": "Collections of this Community"
}
},
"item": {
"page": {
"author": "Author",

View File

@@ -10,6 +10,7 @@ import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component';
import { CollectionPageModule } from './collection-page/collection-page.module';
import { CommunityPageModule } from './community-page/community-page.module';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
@@ -24,6 +25,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
HomeModule,
ItemPageModule,
CollectionPageModule,
CommunityPageModule,
CoreModule.forRoot(),
AppRoutingModule
],

View File

@@ -1,16 +1,32 @@
<div class="collection-page" *ngIf="collectionData.hasSucceeded | async">
<ds-collection-page-name [name]="(collectionData.payload | async)?.name"></ds-collection-page-name>
<ds-collection-page-logo *ngIf="logoData" [logo]="logoData.payload | async"></ds-collection-page-logo>
<ds-collection-page-introductory-text
[introductoryText]="(collectionData.payload | async)?.introductoryText">
</ds-collection-page-introductory-text>
<ds-collection-page-news
[sidebarText]="(collectionData.payload | async)?.sidebarText">
</ds-collection-page-news>
<ds-collection-page-copyright
[copyrightText]="(collectionData.payload | async)?.copyrightText">
</ds-collection-page-copyright>
<ds-collection-page-license
[license]="(collectionData.payload | async)?.license">
</ds-collection-page-license>
<!-- Collection Name -->
<ds-comcol-page-header
[name]="(collectionData.payload | async)?.name">
</ds-comcol-page-header>
<!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logoData"
[logo]="logoData.payload | async"
[alternateText]="'Collection Logo'">
</ds-comcol-page-logo>
<!-- Introductionary text -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.introductoryText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- News -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.sidebarText"
[hasInnerHtml]="true"
[title]="'community.page.news'">
</ds-comcol-page-content>
<!-- Copyright -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.copyrightText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- Licence -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.license"
[title]="'collection.page.license'">
</ds-comcol-page-content>
</div>

View File

@@ -1,19 +1,21 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Collection } from "../core/shared/collection.model";
import { Bitstream } from "../core/shared/bitstream.model";
import { RemoteData } from "../core/data/remote-data";
import { CollectionDataService } from "../core/data/collection-data.service";
import { Subscription } from "rxjs/Subscription";
@Component({
selector: 'ds-collection-page',
styleUrls: ['./collection-page.component.css'],
templateUrl: './collection-page.component.html',
})
export class CollectionPageComponent implements OnInit {
export class CollectionPageComponent implements OnInit, OnDestroy {
collectionData: RemoteData<Collection>;
logoData: RemoteData<Bitstream>;
private subs: Subscription[] = [];
constructor(
private collectionDataService: CollectionDataService,
@@ -24,12 +26,16 @@ export class CollectionPageComponent implements OnInit {
ngOnInit(): void {
this.route.params.subscribe((params: Params) => {
this.collectionData = this.collectionDataService.findById(params['id'])
this.collectionData.payload
.subscribe(collection => this.logoData = collection.logo);
this.collectionData = this.collectionDataService.findById(params['id']);
this.subs.push(this.collectionData.payload
.subscribe(collection => this.logoData = collection.logo));
});
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
universalInit() {
}
}

View File

@@ -3,14 +3,8 @@ import { CommonModule } from '@angular/common';
import { TranslateModule } from "@ngx-translate/core";
import { SharedModule } from '../shared/shared.module';
import { CollectionPageComponent } from './collection-page.component';
import { FieldWrapperComponent } from './field-wrapper/field-wrapper.component';
import { CollectionPageNameComponent } from './name/collection-page-name.component';
import { CollectionPageLogoComponent } from './logo/collection-page-logo.component';
import { CollectionPageIntroductoryTextComponent } from './introductory-text/collection-page-introductory-text.component';
import { CollectionPageNewsComponent } from './news/collection-page-news.component';
import { CollectionPageCopyrightComponent } from './copyright/collection-page-copyright.component';
import { CollectionPageLicenseComponent } from './license/collection-page-license.component';
import { CollectionPageRoutingModule } from './collection-page-routing.module';
@NgModule({
@@ -18,16 +12,10 @@ import { CollectionPageRoutingModule } from './collection-page-routing.module';
CollectionPageRoutingModule,
CommonModule,
TranslateModule,
SharedModule,
],
declarations: [
CollectionPageComponent,
FieldWrapperComponent,
CollectionPageNameComponent,
CollectionPageLogoComponent,
CollectionPageIntroductoryTextComponent,
CollectionPageNewsComponent,
CollectionPageCopyrightComponent,
CollectionPageLicenseComponent,
]
})
export class CollectionPageModule { }

View File

@@ -1,3 +0,0 @@
<ds-field-wrapper *ngIf="copyrightText" class="collection-page-copyright">
<p [innerHtml]="copyrightText"></p>
</ds-field-wrapper>

View File

@@ -1,11 +0,0 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-collection-page-copyright',
styleUrls: ['./collection-page-copyright.component.css'],
templateUrl: './collection-page-copyright.component.html',
})
export class CollectionPageCopyrightComponent {
@Input() copyrightText: String;
}

View File

@@ -1,3 +0,0 @@
<div class="collection-page-field-wrapper">
<ng-content></ng-content>
</div>

View File

@@ -1,11 +0,0 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-field-wrapper',
styleUrls: ['./field-wrapper.component.css'],
templateUrl: './field-wrapper.component.html',
})
export class FieldWrapperComponent {
@Input() name: String;
}

View File

@@ -1,3 +0,0 @@
<ds-field-wrapper *ngIf="introductoryText" class="collection-page-introductory-text">
<p [innerHtml]="introductoryText"></p>
</ds-field-wrapper>

View File

@@ -1,11 +0,0 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-collection-page-introductory-text',
styleUrls: ['./collection-page-introductory-text.component.css'],
templateUrl: './collection-page-introductory-text.component.html',
})
export class CollectionPageIntroductoryTextComponent {
@Input() introductoryText: String;
}

View File

@@ -1,4 +0,0 @@
<ds-field-wrapper *ngIf="license" class="collection-page-license">
<h2>{{ 'collection.page.license' | translate }}</h2>
<p>{{ license }}</p>
</ds-field-wrapper>

View File

@@ -1,11 +0,0 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-collection-page-license',
styleUrls: ['./collection-page-license.component.css'],
templateUrl: './collection-page-license.component.html',
})
export class CollectionPageLicenseComponent {
@Input() license: String;
}

View File

@@ -1,3 +0,0 @@
<ds-field-wrapper *ngIf="logo" class="collection-page-logo">
<img [src]="logo.url" class="img-responsive" alt="Collection logo" />
</ds-field-wrapper>

View File

@@ -1 +0,0 @@
@import '../../../styles/variables.scss';

View File

@@ -1,13 +0,0 @@
import { Component, Input } from '@angular/core';
import { Bitstream } from "../../core/shared/bitstream.model";
@Component({
selector: 'ds-collection-page-logo',
styleUrls: ['./collection-page-logo.component.css'],
templateUrl: './collection-page-logo.component.html',
})
export class CollectionPageLogoComponent {
@Input() logo: Bitstream;
}

View File

@@ -1 +0,0 @@
@import '../../../styles/variables.scss';

View File

@@ -1,11 +0,0 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-collection-page-name',
styleUrls: ['./collection-page-name.component.css'],
templateUrl: './collection-page-name.component.html',
})
export class CollectionPageNameComponent {
@Input() name: String;
}

View File

@@ -1,4 +0,0 @@
<ds-field-wrapper *ngIf="sidebarText" class="collection-page-news">
<h2>{{ 'collection.page.news' | translate }}</h2>
<p [innerHtml]="sidebarText"></p>
</ds-field-wrapper>

View File

@@ -1 +0,0 @@
@import '../../../styles/variables.scss';

View File

@@ -1,11 +0,0 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-collection-page-news',
styleUrls: ['./collection-page-news.component.css'],
templateUrl: './collection-page-news.component.html',
})
export class CollectionPageNewsComponent {
@Input() sidebarText: String;
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CommunityPageComponent } from './community-page.component';
@NgModule({
imports: [
RouterModule.forChild([
{ path: 'communities/:id', component: CommunityPageComponent }
])
]
})
export class CommunityPageRoutingModule { }

View File

@@ -0,0 +1,26 @@
<div class="community-page" *ngIf="communityData.hasSucceeded | async">
<!-- Community name -->
<ds-comcol-page-header [name]="(communityData.payload | async)?.name"></ds-comcol-page-header>
<!-- Community logo -->
<ds-comcol-page-logo *ngIf="logoData"
[logo]="logoData.payload | async"
[alternateText]="'Community Logo'">
</ds-comcol-page-logo>
<!-- Introductionary text -->
<ds-comcol-page-content
[content]="(communityData.payload | async)?.introductoryText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- News -->
<ds-comcol-page-content
[content]="(communityData.payload | async)?.sidebarText"
[hasInnerHtml]="true"
[title]="'community.page.news'">
</ds-comcol-page-content>
<!-- Copyright -->
<ds-comcol-page-content
[content]="(communityData.payload | async)?.copyrightText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<ds-community-page-sub-collection-list></ds-community-page-sub-collection-list>
</div>

View File

@@ -0,0 +1 @@
@import '../../styles/variables.scss';

View File

@@ -0,0 +1,41 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
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 { Subscription } from "rxjs/Subscription";
@Component({
selector: 'ds-community-page',
styleUrls: ['./community-page.component.css'],
templateUrl: './community-page.component.html',
})
export class CommunityPageComponent implements OnInit, OnDestroy {
communityData: RemoteData<Community>;
logoData: RemoteData<Bitstream>;
private subs: Subscription[] = [];
constructor(
private communityDataService: CommunityDataService,
private route: ActivatedRoute
) {
this.universalInit();
}
ngOnInit(): void {
this.route.params.subscribe((params: Params) => {
this.communityData = this.communityDataService.findById(params['id']);
this.subs.push(this.communityData.payload
.subscribe(community => this.logoData = community.logo));
});
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
universalInit() {
}
}

View File

@@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from "@angular/router";
import { TranslateModule } from "@ngx-translate/core";
import { SharedModule } from '../shared/shared.module';
import { CommunityPageComponent } from './community-page.component';
import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component';
import { CommunityPageRoutingModule } from './community-page-routing.module';
@NgModule({
imports: [
CommunityPageRoutingModule,
CommonModule,
TranslateModule,
RouterModule,
SharedModule,
],
declarations: [
CommunityPageComponent,
CommunityPageSubCollectionListComponent,
]
})
export class CommunityPageModule { }

View File

@@ -0,0 +1,11 @@
<div *ngIf="subCollections.hasSucceeded | async">
<h2>{{'community.sub-collection-list.head' | translate}}</h2>
<ul>
<li *ngFor="let collection of (subCollections.payload | async)">
<p>
<span class="lead"><a [routerLink]="['/collections', collection.id]">{{collection.name}}</a></span><br>
<span class="text-muted">{{collection.shortDescription}}</span>
</p>
</li>
</ul>
</div>

View File

@@ -0,0 +1,28 @@
import { Component, OnInit } from '@angular/core';
import { CollectionDataService } from "../../core/data/collection-data.service";
import { RemoteData } from "../../core/data/remote-data";
import { Collection } from "../../core/shared/collection.model";
@Component({
selector: 'ds-community-page-sub-collection-list',
styleUrls: ['./community-page-sub-collection-list.component.css'],
templateUrl: './community-page-sub-collection-list.component.html',
})
export class CommunityPageSubCollectionListComponent implements OnInit {
subCollections: RemoteData<Collection[]>;
constructor(
private cds: CollectionDataService
) {
this.universalInit();
}
universalInit() {
}
ngOnInit(): void {
this.subCollections = this.cds.findAll();
}
}

View File

@@ -26,53 +26,46 @@ export class RemoteDataBuildService {
) {
}
//TODO refactor, nearly identical to buildList, only payload differs
buildSingle<TNormalized extends CacheableObject, TDomain>(
href: string,
normalizedType: GenericConstructor<TNormalized>
): RemoteData<TDomain> {
const requestObs = this.store.select<RequestEntry>('core', 'data', 'request', href);
const responseCacheObs = this.responseCache.get(href);
const requestHrefObs = this.objectCache.getRequestHrefBySelfLink(href);
const requestPending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.requestPending).distinctUntilChanged();
const requestObs = Observable.race(
this.store.select<RequestEntry>('core', 'data', 'request', href).filter(entry => hasValue(entry)),
requestHrefObs.flatMap(requestHref =>
this.store.select<RequestEntry>('core', 'data', 'request', requestHref)).filter(entry => hasValue(entry))
);
const responsePending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.responsePending).distinctUntilChanged();
const responseCacheObs = Observable.race(
this.responseCache.get(href).filter(entry => hasValue(entry)),
requestHrefObs.flatMap(requestHref => this.responseCache.get(requestHref)).filter(entry => hasValue(entry))
);
const requestPending = requestObs.map((entry: RequestEntry) => entry.requestPending).distinctUntilChanged();
const responsePending = requestObs.map((entry: RequestEntry) => entry.responsePending).distinctUntilChanged();
const isSuccessFul = responseCacheObs
.map((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful).distinctUntilChanged();
.map((entry: ResponseCacheEntry) => entry.response.isSuccessful).distinctUntilChanged();
const errorMessage = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry) && !entry.response.isSuccessful)
.filter((entry: ResponseCacheEntry) => !entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged();
const statusCode = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry))
.map((entry: ResponseCacheEntry) => entry.response.statusCode)
.distinctUntilChanged();
const pageInfo = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry)
&& hasValue(entry.response) && hasValue(entry.response['pageInfo']))
.filter((entry: ResponseCacheEntry) => hasValue(entry.response) && hasValue(entry.response['pageInfo']))
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).pageInfo)
.distinctUntilChanged();
const payload =
Observable.race(
this.objectCache.getBySelfLink<TNormalized>(href, normalizedType),
responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
if (isNotEmpty(resourceUUIDs)) {
return this.objectCache.get(resourceUUIDs[0], normalizedType);
}
else {
return Observable.of(undefined);
}
})
.distinctUntilChanged()
).map((normalized: TNormalized) => {
const payload = this.objectCache.getBySelfLink<TNormalized>(href, normalizedType)
.map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized);
});
@@ -88,39 +81,37 @@ export class RemoteDataBuildService {
);
}
//TODO refactor, nearly identical to buildSingle, only payload differs
buildList<TNormalized extends CacheableObject, TDomain>(
href: string,
normalizedType: GenericConstructor<TNormalized>
): RemoteData<TDomain[]> {
const requestObs = this.store.select<RequestEntry>('core', 'data', 'request', href);
const responseCacheObs = this.responseCache.get(href);
const requestObs = this.store.select<RequestEntry>('core', 'data', 'request', href)
.filter(entry => hasValue(entry));
const responseCacheObs = this.responseCache.get(href).filter(entry => hasValue(entry));
const requestPending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.requestPending).distinctUntilChanged();
const requestPending = requestObs.map((entry: RequestEntry) => entry.requestPending).distinctUntilChanged();
const responsePending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.responsePending).distinctUntilChanged();
const responsePending = requestObs.map((entry: RequestEntry) => entry.responsePending).distinctUntilChanged();
const isSuccessFul = responseCacheObs
.map((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful).distinctUntilChanged();
.map((entry: ResponseCacheEntry) => entry.response.isSuccessful).distinctUntilChanged();
const errorMessage = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry) && !entry.response.isSuccessful)
.filter((entry: ResponseCacheEntry) => !entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged();
const statusCode = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry))
.map((entry: ResponseCacheEntry) => entry.response.statusCode)
.distinctUntilChanged();
const pageInfo = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry)
&& hasValue(entry.response) && hasValue(entry.response['pageInfo']))
.filter((entry: ResponseCacheEntry) => hasValue(entry.response) && hasValue(entry.response['pageInfo']))
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).pageInfo)
.distinctUntilChanged();
const payload = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful)
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
return this.objectCache.getList(resourceUUIDs, normalizedType)

View File

@@ -18,7 +18,7 @@ export class NormalizedBitstream extends NormalizedDSpaceObject {
* The relative path to this Bitstream's file
*/
@autoserialize
url: string;
retrieve: string;
/**
* The mime type of this Bitstream

View File

@@ -17,6 +17,8 @@ export class NormalizedCommunity extends NormalizedDSpaceObject {
/**
* The Bitstream that represents the logo of this Community
*/
@autoserialize
@relationship(ResourceType.Bitstream)
logo: string;
/**

View File

@@ -20,6 +20,7 @@ export class AddToObjectCacheAction implements Action {
objectToCache: CacheableObject;
timeAdded: number;
msToLive: number;
requestHref: string;
};
/**
@@ -31,9 +32,13 @@ export class AddToObjectCacheAction implements Action {
* the time it was added
* @param msToLive
* the amount of milliseconds before it should expire
* @param requestHref
* The href of the request that resulted in this object
* This isn't necessarily the same as the object's self
* link, it could have been part of a list for example
*/
constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number) {
this.payload = { objectToCache, timeAdded, msToLive };
constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestHref: string) {
this.payload = { objectToCache, timeAdded, msToLive, requestHref };
}
}

View File

@@ -24,7 +24,8 @@ describe("objectCacheReducer", () => {
foo: "bar"
},
timeAdded: new Date().getTime(),
msToLive: 900000
msToLive: 900000,
requestHref: "https://rest.api/endpoint/uuid1"
},
[uuid2]: {
data: {
@@ -32,7 +33,8 @@ describe("objectCacheReducer", () => {
foo: "baz"
},
timeAdded: new Date().getTime(),
msToLive: 900000
msToLive: 900000,
requestHref: "https://rest.api/endpoint/uuid2"
}
};
deepFreeze(testState);
@@ -56,7 +58,8 @@ describe("objectCacheReducer", () => {
const objectToCache = {uuid: uuid1};
const timeAdded = new Date().getTime();
const msToLive = 900000;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive);
const requestHref = "https://rest.api/endpoint/uuid1";
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref);
const newState = objectCacheReducer(state, action);
expect(newState[uuid1].data).toEqual(objectToCache);
@@ -68,7 +71,8 @@ describe("objectCacheReducer", () => {
const objectToCache = {uuid: uuid1, foo: "baz", somethingElse: true};
const timeAdded = new Date().getTime();
const msToLive = 900000;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive);
const requestHref = "https://rest.api/endpoint/uuid1";
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref);
const newState = objectCacheReducer(testState, action);
expect(newState[uuid1].data['foo']).toBe("baz");
@@ -80,7 +84,8 @@ describe("objectCacheReducer", () => {
const objectToCache = {uuid: uuid1};
const timeAdded = new Date().getTime();
const msToLive = 900000;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive);
const requestHref = "https://rest.api/endpoint/uuid1";
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref);
deepFreeze(state);
objectCacheReducer(state, action);

View File

@@ -22,6 +22,7 @@ export class ObjectCacheEntry implements CacheEntry {
data: CacheableObject;
timeAdded: number;
msToLive: number;
requestHref: string;
}
/**
@@ -83,7 +84,8 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio
[action.payload.objectToCache.uuid]: {
data: action.payload.objectToCache,
timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive
msToLive: action.payload.msToLive,
requestHref: action.payload.requestHref
}
});
}

View File

@@ -20,6 +20,7 @@ describe("ObjectCacheService", () => {
let store: Store<ObjectCacheState>;
const uuid = '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const requestHref = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const timestamp = new Date().getTime();
const msToLive = 900000;
const objectToCache = {
@@ -44,8 +45,8 @@ describe("ObjectCacheService", () => {
describe("add", () => {
it("should dispatch an ADD action with the object to add, the time to live, and the current timestamp", () => {
service.add(objectToCache, msToLive);
expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive));
service.add(objectToCache, msToLive, requestHref);
expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, requestHref));
});
});

View File

@@ -22,9 +22,13 @@ export class ObjectCacheService {
* The object to add
* @param msToLive
* The number of milliseconds it should be cached for
* @param requestHref
* The href of the request that resulted in this object
* This isn't necessarily the same as the object's self
* link, it could have been part of a list for example
*/
add(objectToCache: CacheableObject, msToLive: number): void {
this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive));
add(objectToCache: CacheableObject, msToLive: number, requestHref: string): void {
this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestHref));
}
/**
@@ -54,9 +58,7 @@ export class ObjectCacheService {
* An observable of the requested object
*/
get<T extends CacheableObject>(uuid: string, type: GenericConstructor<T>): Observable<T> {
return this.store.select<ObjectCacheEntry>('core', 'cache', 'object', uuid)
.filter(entry => this.isValid(entry))
.distinctUntilChanged()
return this.getEntry(uuid)
.map((entry: ObjectCacheEntry) => <T> Object.assign(new type(), entry.data));
}
@@ -65,6 +67,23 @@ export class ObjectCacheService {
.flatMap((uuid: string) => this.get(uuid, type))
}
private getEntry(uuid: string): Observable<ObjectCacheEntry> {
return this.store.select<ObjectCacheEntry>('core', 'cache', 'object', uuid)
.filter(entry => this.isValid(entry))
.distinctUntilChanged();
}
getRequestHref(uuid: string): Observable<string> {
return this.getEntry(uuid)
.map((entry: ObjectCacheEntry) => entry.requestHref)
.distinctUntilChanged();
}
getRequestHrefBySelfLink(self: string): Observable<string> {
return this.store.select<string>('core', 'index', 'href', self)
.flatMap((uuid: string) => this.getRequestHref(uuid));
}
/**
* Get an observable for an array of objects of the same type
* with the specified UUIDs

View File

@@ -49,7 +49,7 @@ export class RequestEffects {
})
.flatMap((entry: RequestEntry) => {
return this.restApi.get(entry.request.href)
.map((data: DSpaceRESTV2Response) => new SuccessResponse(this.process(data.payload), data.statusCode, this.processPageInfo(data.payload.page)))
.map((data: DSpaceRESTV2Response) => new SuccessResponse(this.process(data.payload, entry.request.href), data.statusCode, this.processPageInfo(data.payload.page)))
.do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((response: Response) => new RequestCompleteAction(entry.request.href))
.catch((error: RequestError) => Observable.of(new ErrorResponse(error))
@@ -57,14 +57,14 @@ export class RequestEffects {
.map((response: Response) => new RequestCompleteAction(entry.request.href)));
});
protected process(data: any): Array<string> {
protected process(data: any, requestHref: string): Array<string> {
if (isNotEmpty(data)) {
if (isPaginatedResponse(data)) {
return this.process(data._embedded);
return this.process(data._embedded, requestHref);
}
else if (isObjectLevel(data)) {
return this.deserializeAndCache(data);
return this.deserializeAndCache(data, requestHref);
}
else {
let uuids = [];
@@ -72,14 +72,14 @@ export class RequestEffects {
.filter(property => data.hasOwnProperty(property))
.filter(property => hasValue(data[property]))
.forEach(property => {
uuids = [...uuids, ...this.deserializeAndCache(data[property])];
uuids = [...uuids, ...this.deserializeAndCache(data[property], requestHref)];
});
return uuids;
}
}
}
protected deserializeAndCache(obj): Array<string> {
protected deserializeAndCache(obj, requestHref: string): Array<string> {
let type: ResourceType;
const isArray = Array.isArray(obj);
@@ -103,19 +103,19 @@ export class RequestEffects {
if (isArray) {
obj.forEach(o => {
if (isNotEmpty(o._embedded)) {
this.process(o._embedded);
this.process(o._embedded, requestHref);
}
});
const normalizedObjArr = serializer.deserializeArray(obj);
normalizedObjArr.forEach(t => this.addToObjectCache(t));
normalizedObjArr.forEach(t => this.addToObjectCache(t, requestHref));
return normalizedObjArr.map(t => t.uuid);
}
else {
if (isNotEmpty(obj._embedded)) {
this.process(obj._embedded);
this.process(obj._embedded, requestHref);
}
const normalizedObj = serializer.deserialize(obj);
this.addToObjectCache(normalizedObj);
this.addToObjectCache(normalizedObj, requestHref);
return [normalizedObj.uuid];
}
@@ -132,11 +132,11 @@ export class RequestEffects {
}
}
protected addToObjectCache(co: CacheableObject): void {
protected addToObjectCache(co: CacheableObject, requestHref: string): void {
if (hasNoValue(co) || hasNoValue(co.uuid)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(co, this.EnvConfig.cache.msToLive);
this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref);
}
protected processPageInfo(pageObj: any): PageInfo {

View File

@@ -8,6 +8,9 @@ import { RequestConfigureAction, RequestExecuteAction } from "./request.actions"
import { ResponseCacheService } from "../cache/response-cache.service";
import { ObjectCacheService } from "../cache/object-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer";
import { ResponseCacheEntry } from "../cache/response-cache.reducer";
import { request } from "http";
import { SuccessResponse } from "../cache/response-cache.models";
@Injectable()
export class RequestService {
@@ -35,7 +38,19 @@ export class RequestService {
}
configure<T extends CacheableObject>(request: Request<T>): void {
const isCached = this.objectCache.hasBySelfLink(request.href);
let isCached = this.objectCache.hasBySelfLink(request.href);
if (!isCached && this.responseCache.has(request.href)) {
//if it isn't cached it may be a list endpoint, if so verify
//every object included in the response is still cached
this.responseCache.get(request.href)
.take(1)
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs)
.map((resourceUUIDs: Array<string>) => resourceUUIDs.every(uuid => this.objectCache.has(uuid)))
.subscribe(c => isCached = c);
}
const isPending = this.isPending(request.href);
if (!(isCached || isPending)) {

View File

@@ -60,8 +60,8 @@ describe("DSpaceRESTv2Serializer", () => {
it("should turn a model in to a valid document", () => {
const serializer = new DSpaceRESTv2Serializer(TestModel);
const doc = serializer.serialize(testModels[0]);
expect(testModels[0].id).toBe(doc._embedded.id);
expect(testModels[0].name).toBe(doc._embedded.name);
expect(testModels[0].id).toBe(doc.id);
expect(testModels[0].name).toBe(doc.name);
});
});
@@ -72,10 +72,10 @@ describe("DSpaceRESTv2Serializer", () => {
const serializer = new DSpaceRESTv2Serializer(TestModel);
const doc = serializer.serializeArray(testModels);
expect(testModels[0].id).toBe(doc._embedded[0].id);
expect(testModels[0].name).toBe(doc._embedded[0].name);
expect(testModels[1].id).toBe(doc._embedded[1].id);
expect(testModels[1].name).toBe(doc._embedded[1].name);
expect(testModels[0].id).toBe(doc[0].id);
expect(testModels[0].name).toBe(doc[0].name);
expect(testModels[1].id).toBe(doc[1].id);
expect(testModels[1].name).toBe(doc[1].name);
});
});

View File

@@ -9,11 +9,6 @@ export class Bitstream extends DSpaceObject {
*/
size: number;
/**
* The relative path to this Bitstream's file
*/
url: string;
/**
* The mime type of this Bitstream
*/
@@ -40,7 +35,7 @@ export class Bitstream extends DSpaceObject {
owner: RemoteData<Item>;
/**
* The Bundle that owns this Bitstream
* The URL to retrieve this Bitstream's file
*/
retrieve: string;

View File

@@ -1,9 +1,9 @@
import { TestBed, async } from '@angular/core/testing';
import { Item } from "./item.model";
import { Bundle } from "./bundle.model";
import { Observable } from "rxjs";
import { RemoteData } from "../data/remote-data";
import { Bitstream } from "./bitstream.model";
import { isEmpty } from "../../shared/empty.util";
import { PageInfo } from "./page-info.model";
describe('Item', () => {
@@ -17,23 +17,25 @@ describe('Item', () => {
const bitstream2Path = "otherfile.doc";
const nonExistingBundleName = "c1e568f7-d14e-496b-bdd7-07026998cc00";
let remoteBundles;
let thumbnailBundle;
let originalBundle;
let bitstreams;
let remoteDataThumbnail;
let remoteDataFiles;
let remoteDataAll;
beforeEach(() => {
const thumbnail = {
retrieve: thumbnailPath
};
const bitstreams = [{
bitstreams = [{
retrieve: bitstream1Path
}, {
retrieve: bitstream2Path
}];
const remoteDataThumbnail = createRemoteDataObject(thumbnail);
const remoteDataFiles = createRemoteDataObject(bitstreams);
remoteDataThumbnail = createRemoteDataObject(thumbnail);
remoteDataFiles = createRemoteDataObject(bitstreams);
remoteDataAll = createRemoteDataObject([...bitstreams, thumbnail]);
// Create Bundles
@@ -50,32 +52,30 @@ describe('Item', () => {
bitstreams: remoteDataFiles
}];
remoteBundles = createRemoteDataObject(bundles);
item = Object.assign(new Item(), { bundles: remoteBundles });
item = Object.assign(new Item(), { bitstreams: remoteDataAll});
});
it('should return the bundle with the given name of this item when the bundle exists', () => {
let name: string = thumbnailBundleName;
let bundle: Observable<Bundle> = item.getBundle(name);
bundle.map(b => expect(b.name).toBe(name));
it('should return the bitstreams related to this item with the specified bundle name', () => {
const bitObs: Observable<Bitstream[]> = item.getBitstreamsByBundleName(thumbnailBundleName);
bitObs.take(1).subscribe(bs =>
expect(bs.every(b => b.name === thumbnailBundleName)).toBeTruthy());
});
it('should return null when no bundle with this name exists for this item', () => {
let name: string = nonExistingBundleName;
let bundle: Observable<Bundle> = item.getBundle(name);
bundle.map(b => expect(b).toBeUndefined());
it('should return an empty array when no bitstreams with this bundleName exist for this item', () => {
const bitstreams: Observable<Bitstream[]> = item.getBitstreamsByBundleName(nonExistingBundleName);
bitstreams.take(1).subscribe(bs => expect(isEmpty(bs)).toBeTruthy());
});
describe("get thumbnail", () => {
beforeEach(() => {
spyOn(item, 'getBundle').and.returnValue(Observable.of(thumbnailBundle));
spyOn(item, 'getBitstreamsByBundleName').and.returnValue(Observable.of([remoteDataThumbnail]));
});
it('should return the thumbnail (the primaryBitstream in the bundle "THUMBNAIL") of this item', () => {
it('should return the thumbnail of this item', () => {
let path: string = thumbnailPath;
let bitstream: Observable<Bitstream> = item.getThumbnail();
bitstream.map(b => expect(b.retrieve).toBe(path));
@@ -85,10 +85,10 @@ describe('Item', () => {
describe("get files", () => {
beforeEach(() => {
spyOn(item, 'getBundle').and.returnValue(Observable.of(originalBundle));
spyOn(item, 'getBitstreamsByBundleName').and.returnValue(Observable.of(bitstreams));
});
it('should return all files in the ORIGINAL bundle', () => {
it('should return all bitstreams with "ORIGINAL" as bundleName', () => {
let paths = [bitstream1Path, bitstream2Path];
let files: Observable<Bitstream[]> = item.getFiles();
@@ -110,6 +110,23 @@ describe('Item', () => {
});
function createRemoteDataObject(object: Object) {
return new RemoteData("", Observable.of(false), Observable.of(false), Observable.of(true), Observable.of(undefined), Observable.of(object));
const self = "";
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
);
}

View File

@@ -0,0 +1,5 @@
<div *ngIf="content" class="content-with-optional-title">
<h2 *ngIf="title">{{ title | translate }}</h2>
<div *ngIf="hasInnerHtml" [innerHtml]="content"></div>
<div *ngIf="!hasInnerHtml">{{content}}</div>
</div>

View File

@@ -0,0 +1,37 @@
import { Component, Input } from '@angular/core';
/**
* This component renders any content inside of this component.
* If there is a title set it will render the title.
* If hasInnerHtml is true the content will be handled as html.
* To see how it is used see collection-page or community-page.
*/
@Component({
selector: 'ds-comcol-page-content',
styleUrls: ['./comcol-page-content.component.css'],
templateUrl: './comcol-page-content.component.html'
})
export class ComcolPageContentComponent {
// Optional title
@Input() title: string;
// The content to render. Might be html
@Input() content: string;
// flag whether the content contains html syntax or not
@Input() hasInnerHtml: boolean;
constructor() {
this.universalInit();
}
universalInit() {
}
}

View File

@@ -0,0 +1,11 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-comcol-page-header',
styleUrls: ['./comcol-page-header.component.css'],
templateUrl: './comcol-page-header.component.html',
})
export class ComcolPageHeaderComponent {
@Input() name: String;
}

View File

@@ -0,0 +1,3 @@
<div *ngIf="logo" class="dso-logo">
<img [src]="logo.retrieve" class="img-responsive" [attr.alt]="alternateText ? alternateText : null" />
</div>

View File

@@ -0,0 +1,15 @@
import { Component, Input } from '@angular/core';
import { Bitstream } from "../../core/shared/bitstream.model";
@Component({
selector: 'ds-comcol-page-logo',
styleUrls: ['./comcol-page-logo.component.css'],
templateUrl: './comcol-page-logo.component.html',
})
export class ComcolPageLogoComponent {
@Input() logo: Bitstream;
@Input() alternateText: string;
}

View File

@@ -14,6 +14,9 @@ import { ThumbnailComponent } from "../thumbnail/thumbnail.component";
import { SafeUrlPipe } from "./utils/safe-url-pipe";
import { HostWindowService } from "./host-window.service";
import { NativeWindowFactory, NativeWindowService } from "./window.service";
import { ComcolPageContentComponent } from "./comcol-page-content/comcol-page-content.component";
import { ComcolPageHeaderComponent } from "./comcol-page-header/comcol-page-header.component";
import { ComcolPageLogoComponent } from "./comcol-page-logo/comcol-page-logo.component";
import { TRUNCATE_PIPES } from "ng2-truncate";
import { EnumKeysPipe } from "./utils/enum-keys-pipe";
@@ -39,7 +42,10 @@ const PIPES = [
const COMPONENTS = [
// put shared components here
PaginationComponent,
ThumbnailComponent
ThumbnailComponent,
ComcolPageContentComponent,
ComcolPageHeaderComponent,
ComcolPageLogoComponent
];
const PROVIDERS = [

View File

@@ -35,11 +35,12 @@ export const COMMUNITIES = {
],
"_links": {
"self": {
"href": "http://dspace7.4science.it/dspace-spring-rest/api/core/community/9076bd16-e69a-48d6-9e41-0238cb40d863"
"href": "/communities/6631"
},
"collections": [
{ "href": "/collections/5179" }
]
],
"logo": { "href": "/bitstreams/4688" }
}
},
{
@@ -77,7 +78,7 @@ export const COMMUNITIES = {
],
"_links": {
"self": {
"href": "http://dspace7.4science.it/dspace-spring-rest/api/core/community/9076bd16-e69a-48d6-9e41-0238cb40d863"
"href": "/communities/2365"
},
"collections": [
{ "href": "/collections/6547" }

View File

@@ -94,7 +94,6 @@ export const ITEMS = {
],
"_embedded": {
"parents": [
{
"_links": {
"self": { "href": "/collections/6547" },
@@ -108,6 +107,28 @@ export const ITEMS = {
"type": "collection",
"name": "Another Test Collection",
"handle": "123456789/6547",
"metadata": [
{
"key": "dc.rights",
"value": "<p>© 2005-2016 JOHN DOE SOME RIGHTS RESERVED</p>",
"language": null
},
{
"key": "dc.description",
"value": "<p class='lead'>Another introductory text dolor sit amet, consectetur adipiscing elit. Duis laoreet lorem erat, eget auctor est ultrices quis. Nullam ac tincidunt quam. In nec nisl odio. In egestas aliquam tincidunt.</p>\r\n<p>Integer vitae diam id dolor pharetra dignissim in sed enim. Vivamus pulvinar tristique sem a iaculis. Aenean ultricies dui vel facilisis laoreet. Integer porta erat eu ultrices rhoncus. Sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum.</p>",
"language": null
},
{
"key": "dc.description.abstract",
"value": "Another collection for testing purposes",
"language": null
},
{
"key": "dc.description.tableofcontents",
"value": "<p>Some more news sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum. Donec sed ultricies erat, nec sollicitudin mauris. Duis varius nulla quis quam vulputate, at hendrerit turpis rutrum. Integer nec facilisis sapien. Fusce fringilla malesuada lectus id pulvinar. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae</p>",
"language": null
}
]
}
],
"bundles": [
@@ -239,13 +260,36 @@ export const ITEMS = {
"items": [
{ "href": "/items/8871" },
{ "href": "/items/9978" }
]
],
"logo": { "href": "/bitstreams/4688" }
},
"id": "5179",
"uuid": "9e32a2e2-6b91-4236-a361-995ccdc14c60",
"type": "collection",
"name": "A Test Collection",
"handle": "123456789/5179",
"metadata": [
{
"key": "dc.rights",
"value": "<p>© 2005-2016 JOHN DOE SOME RIGHTS RESERVED</p>",
"language": null
},
{
"key": "dc.description",
"value": "<p class='lead'>An introductory text dolor sit amet, consectetur adipiscing elit. Duis laoreet lorem erat, eget auctor est ultrices quis. Nullam ac tincidunt quam. In nec nisl odio. In egestas aliquam tincidunt.</p>\r\n<p>Integer vitae diam id dolor pharetra dignissim in sed enim. Vivamus pulvinar tristique sem a iaculis. Aenean ultricies dui vel facilisis laoreet. Integer porta erat eu ultrices rhoncus. Sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum.</p>",
"language": null
},
{
"key": "dc.description.abstract",
"value": "A collection for testing purposes",
"language": null
},
{
"key": "dc.description.tableofcontents",
"value": "<p>Some news sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum. Donec sed ultricies erat, nec sollicitudin mauris. Duis varius nulla quis quam vulputate, at hendrerit turpis rutrum. Integer nec facilisis sapien. Fusce fringilla malesuada lectus id pulvinar. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae</p>",
"language": null
}
]
},
{
"_links": {
@@ -260,6 +304,28 @@ export const ITEMS = {
"type": "collection",
"name": "Another Test Collection",
"handle": "123456789/6547",
"metadata": [
{
"key": "dc.rights",
"value": "<p>© 2005-2016 JOHN DOE SOME RIGHTS RESERVED</p>",
"language": null
},
{
"key": "dc.description",
"value": "<p class='lead'>Another introductory text dolor sit amet, consectetur adipiscing elit. Duis laoreet lorem erat, eget auctor est ultrices quis. Nullam ac tincidunt quam. In nec nisl odio. In egestas aliquam tincidunt.</p>\r\n<p>Integer vitae diam id dolor pharetra dignissim in sed enim. Vivamus pulvinar tristique sem a iaculis. Aenean ultricies dui vel facilisis laoreet. Integer porta erat eu ultrices rhoncus. Sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum.</p>",
"language": null
},
{
"key": "dc.description.abstract",
"value": "Another collection for testing purposes",
"language": null
},
{
"key": "dc.description.tableofcontents",
"value": "<p>Some more news sed condimentum malesuada ex sit amet ullamcorper. Morbi a ipsum dolor. Vivamus interdum eget lacus ut fermentum. Donec sed ultricies erat, nec sollicitudin mauris. Duis varius nulla quis quam vulputate, at hendrerit turpis rutrum. Integer nec facilisis sapien. Fusce fringilla malesuada lectus id pulvinar. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae</p>",
"language": null
}
]
}
]
}

View File

@@ -10,5 +10,5 @@
* ];
**/
export const routes: string[] = [
'home', 'items/:id' , 'collections/:id', '**'
'home', 'items/:id' , 'collections/:id', 'communities/:id', '**'
];

View File

@@ -5330,7 +5330,7 @@ rxjs@5.0.0-beta.12, rxjs@^5.0.0-beta.12:
dependencies:
symbol-observable "^1.0.1"
rxjs@^5.0.0-beta.12, rxjs@^5.0.1:
rxjs@^5.0.1:
version "5.4.0"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.4.0.tgz#a7db14ab157f9d7aac6a56e655e7a3860d39bf26"
dependencies: