Merge remote-tracking branch 'upstream/master' into pagination

# Conflicts:
#	package.json
#	src/app/app.component.html
#	src/app/app.component.ts
#	src/app/core/cache/request-cache.reducer.spec.ts
#	src/app/core/cache/request-cache.service.spec.ts
#	src/app/core/core.module.ts
#	src/app/shared/shared.module.ts
This commit is contained in:
Giuseppe Digilio
2017-05-19 22:03:21 +02:00
134 changed files with 2947 additions and 1585 deletions

View File

@@ -7,7 +7,7 @@ dspace-angular
This project is currently in pre-alpha. This project is currently in pre-alpha.
You can find additional information on the [wiki](https://wiki.duraspace.org/display/DSPACE/DSpace+7+-+Angular+2+UI) or [the project board (waffle.io)](https://waffle.io/DSpace/dspace-angular). You can find additional information on the [wiki](https://wiki.duraspace.org/display/DSPACE/DSpace+7+-+Angular+UI) or [the project board (waffle.io)](https://waffle.io/DSpace/dspace-angular).
If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [here](https://github.com/DSpace-Labs/angular2-ui-prototype) If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [here](https://github.com/DSpace-Labs/angular2-ui-prototype)

View File

@@ -12,8 +12,8 @@ describe('protractor App', function() {
expect(page.getPageTitleText()).toEqual('DSpace'); expect(page.getPageTitleText()).toEqual('DSpace');
}); });
it('should display title "Hello, World!"', () => { it('should display header "Welcome to DSpace"', () => {
page.navigateTo(); page.navigateTo();
expect(page.getFirstPText()).toEqual('Hello, World!'); expect(page.getFirstHeaderText()).toEqual('Welcome to DSpace');
}); });
}); });

View File

@@ -12,4 +12,8 @@ export class ProtractorPage {
getFirstPText() { getFirstPText() {
return element(by.xpath('//p[1]')).getText(); return element(by.xpath('//p[1]')).getText();
} }
getFirstHeaderText() {
return element(by.xpath('//h1[1]')).getText();
}
} }

View File

@@ -78,19 +78,21 @@
"@angular/upgrade": "2.2.3", "@angular/upgrade": "2.2.3",
"@angularclass/bootloader": "1.0.1", "@angularclass/bootloader": "1.0.1",
"@angularclass/idle-preload": "1.0.4", "@angularclass/idle-preload": "1.0.4",
"@ng-bootstrap/ng-bootstrap": "1.0.0-alpha.24", "@ng-bootstrap/ng-bootstrap": "1.0.0-alpha.15",
"@ngrx/core": "^1.2.0", "@ngrx/core": "^1.2.0",
"@ngrx/effects": "^2.0.0", "@ngrx/effects": "^2.0.0",
"@ngrx/router-store": "^1.2.5", "@ngrx/router-store": "^1.2.5",
"@ngrx/store": "^2.2.1", "@ngrx/store": "^2.2.1",
"@ngrx/store-devtools": "^3.2.2", "@ngrx/store-devtools": "^3.2.2",
"@ngx-translate/core": "^6.0.1",
"@ngx-translate/http-loader": "^0.0.3",
"@types/jsonschema": "0.0.5", "@types/jsonschema": "0.0.5",
"angular2-express-engine": "2.1.0-rc.1", "angular2-express-engine": "2.1.0-rc.1",
"angular2-platform-node": "2.1.0-rc.1", "angular2-platform-node": "2.1.0-rc.1",
"angular2-universal": "2.1.0-rc.1", "angular2-universal": "2.1.0-rc.1",
"angular2-universal-polyfills": "2.1.0-rc.1", "angular2-universal-polyfills": "2.1.0-rc.1",
"body-parser": "1.15.2", "body-parser": "1.15.2",
"bootstrap": "4.0.0-alpha.6", "bootstrap": "4.0.0-alpha.5",
"cerialize": "^0.1.13", "cerialize": "^0.1.13",
"compression": "1.6.2", "compression": "1.6.2",
"express": "4.14.0", "express": "4.14.0",
@@ -100,9 +102,8 @@
"jsonschema": "^1.1.1", "jsonschema": "^1.1.1",
"methods": "1.1.2", "methods": "1.1.2",
"morgan": "1.7.0", "morgan": "1.7.0",
"ng2-pagination": "^2.0.0",
"ng2-translate": "4.2.0",
"preboot": "4.5.2", "preboot": "4.5.2",
"reflect-metadata": "^0.1.10",
"rxjs": "5.0.0-beta.12", "rxjs": "5.0.0-beta.12",
"ts-md5": "^1.2.0", "ts-md5": "^1.2.0",
"webfontloader": "1.6.27", "webfontloader": "1.6.27",
@@ -160,7 +161,6 @@
"protractor": "~4.0.14", "protractor": "~4.0.14",
"protractor-istanbul-plugin": "~2.0.0", "protractor-istanbul-plugin": "~2.0.0",
"raw-loader": "0.5.1", "raw-loader": "0.5.1",
"reflect-metadata": "0.1.8",
"rimraf": "2.5.4", "rimraf": "2.5.4",
"rollup": "0.37.0", "rollup": "0.37.0",
"rollup-plugin-commonjs": "6.0.0", "rollup-plugin-commonjs": "6.0.0",

View File

@@ -1,16 +1,21 @@
{ {
"example": {
"with": {
"data": "{{greeting}}, {{recipient}}!"
}
},
"footer": { "footer": {
"copyright": "copyright © 2002-{{ year }}", "copyright": "copyright © 2002-{{ year }}",
"link.dspace": "DSpace software", "link.dspace": "DSpace software",
"link.duraspace": "DuraSpace" "link.duraspace": "DuraSpace"
}, },
"item": {
"page": {
"author": "Author",
"abstract": "Abstract",
"date": "Date",
"uri": "URI",
"files": "Files",
"collections": "Collections"
}
},
"nav": { "nav": {
"home": "Home" "home": "Home"
}, },
@@ -31,5 +36,12 @@
"link": { "link": {
"home-page": "Take me to the home page" "home-page": "Take me to the home page"
} }
},
"home": {
"top-level-communities": {
"head": "Communities in DSpace",
"help": "Select a community to browse its collections."
}
} }
} }

View File

@@ -4,25 +4,6 @@
<main class="main-content"> <main class="main-content">
<div class="container-fluid"> <div class="container-fluid">
<p>{{ 'example.with.data' | translate:data }}</p>
<p>{{ example }}</p>
<h2 [ngClass]="{ 'red': EnvConfig.production, 'green': !EnvConfig.production }">
<span *ngIf="!EnvConfig.production">development</span>
<span *ngIf="EnvConfig.production">production</span>
</h2>
{{options.id}}
<!--ds-pagination [paginationOptions]="options"
[collectionSize]="100"
(pageChange)="options.currentPage = $event"
(pageSizeChange)="options.pageSize = $event">
<ul>
<li *ngFor="let item of collection | paginate: { itemsPerPage: options.pageSize, currentPage: options.currentPage, totalItems: 100 }"> {{item}} </li>
</ul>
</ds-pagination-->
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
</main> </main>

View File

@@ -15,11 +15,3 @@
.main-content { .main-content {
flex: 1 0 auto; flex: 1 0 auto;
} }
h2.red {
color: red;
}
h2.green {
color: green;
}

View File

@@ -10,7 +10,7 @@ import {
DebugElement DebugElement
} from "@angular/core"; } from "@angular/core";
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { TranslateModule, TranslateLoader } from "ng2-translate"; import { TranslateModule, TranslateLoader } from "@ngx-translate/core";
import { Store, StoreModule } from "@ngrx/store"; import { Store, StoreModule } from "@ngrx/store";
// Load the implementations that should be tested // Load the implementations that should be tested
@@ -34,8 +34,10 @@ describe('App component', () => {
beforeEach(async(() => { beforeEach(async(() => {
return TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, StoreModule.provideStore({}), TranslateModule.forRoot({ imports: [CommonModule, StoreModule.provideStore({}), TranslateModule.forRoot({
provide: TranslateLoader, loader: {
useClass: MockTranslateLoader provide: TranslateLoader,
useClass: MockTranslateLoader
}
})], })],
declarations: [AppComponent], // declare the test component declarations: [AppComponent], // declare the test component
providers: [ providers: [
@@ -52,8 +54,8 @@ describe('App component', () => {
comp = fixture.componentInstance; // component test instance comp = fixture.componentInstance; // component test instance
// query for the title <p> by CSS element selector // query for the <div class="outer-wrapper"> by CSS element selector
de = fixture.debugElement.query(By.css('p')); de = fixture.debugElement.query(By.css('div.outer-wrapper'));
el = de.nativeElement; el = de.nativeElement;
}); });

View File

@@ -3,17 +3,14 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Inject, Inject,
ViewEncapsulation, ViewEncapsulation,
OnDestroy,
OnInit, HostListener OnInit, HostListener
} from "@angular/core"; } from "@angular/core";
import { TranslateService } from "ng2-translate"; import { TranslateService } from "@ngx-translate/core";
import { HostWindowState } from "./shared/host-window.reducer"; import { HostWindowState } from "./shared/host-window.reducer";
import { Store } from "@ngrx/store"; import { Store } from "@ngrx/store";
import { HostWindowResizeAction } from "./shared/host-window.actions"; import { HostWindowResizeAction } from "./shared/host-window.actions";
import { PaginationOptions } from './core/shared/pagination-options.model'; import { EnvConfig, GLOBAL_CONFIG, GlobalConfig } from '../config';
import { GLOBAL_CONFIG, GlobalConfig } from '../config';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.Default, changeDetection: ChangeDetectionStrategy.Default,
@@ -22,16 +19,7 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../config';
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.css'] styleUrls: ['./app.component.css']
}) })
export class AppComponent implements OnDestroy, OnInit { export class AppComponent implements OnInit {
private translateSubscription: any;
collection = [];
example: string;
options: PaginationOptions = new PaginationOptions();
data: any = {
greeting: 'Hello',
recipient: 'World'
};
constructor( constructor(
@Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
@@ -42,26 +30,12 @@ export class AppComponent implements OnDestroy, OnInit {
translate.setDefaultLang('en'); translate.setDefaultLang('en');
// the lang to use, if the lang isn't available, it will use the current loader to get them // the lang to use, if the lang isn't available, it will use the current loader to get them
translate.use('en'); translate.use('en');
for (let i = 1; i <= 100; i++) {
this.collection.push(`item ${i}`);
}
} }
ngOnInit() { ngOnInit() {
this.translateSubscription = this.translate.get('example.with.data', { greeting: 'Hello', recipient: 'DSpace' }).subscribe((translation: string) => { const env: string = EnvConfig.production ? "Production" : "Development";
this.example = translation; const color: string = EnvConfig.production ? "red" : "green";
}); console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
this.onLoad();
this.options.id = 'app';
//this.options.currentPage = 1;
this.options.pageSize = 15;
this.options.size = 'sm';
}
ngOnDestroy() {
if (this.translateSubscription) {
this.translateSubscription.unsubscribe();
}
} }
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
@@ -71,9 +45,4 @@ export class AppComponent implements OnDestroy, OnInit {
); );
} }
private onLoad() {
this.store.dispatch(
new HostWindowResizeAction(window.innerWidth, window.innerHeight)
);
}
} }

View File

@@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
import { CoreModule } from './core/core.module'; import { CoreModule } from './core/core.module';
import { HomeModule } from './home/home.module'; import { HomeModule } from './home/home.module';
import { ItemPageModule } from './item-page/item-page.module';
import { SharedModule } from './shared/shared.module'; import { SharedModule } from './shared/shared.module';
@@ -10,15 +11,17 @@ import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component'; import { HeaderComponent } from './header/header.component';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent, AppComponent,
HeaderComponent, HeaderComponent,
PageNotFoundComponent PageNotFoundComponent,
], ],
imports: [ imports: [
SharedModule, SharedModule,
HomeModule, HomeModule,
ItemPageModule,
CoreModule.forRoot(), CoreModule.forRoot(),
AppRoutingModule AppRoutingModule
], ],

View File

@@ -0,0 +1,41 @@
import "reflect-metadata";
import { GenericConstructor } from "../../shared/generic-constructor";
import { CacheableObject } from "../object-cache.reducer";
import { NormalizedDSOType } from "../models/normalized-dspace-object-type";
const mapsToMetadataKey = Symbol("mapsTo");
const relationshipKey = Symbol("relationship");
const relationshipMap = new Map();
export const mapsTo = function(value: GenericConstructor<CacheableObject>) {
return Reflect.metadata(mapsToMetadataKey, value);
};
export const getMapsTo = function(target: any) {
return Reflect.getOwnMetadata(mapsToMetadataKey, target);
};
export const relationship = function(value: NormalizedDSOType): any {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
if (!target || !propertyKey) {
return;
}
let metaDataList : Array<string> = relationshipMap.get(target.constructor) || [];
if (metaDataList.indexOf(propertyKey) === -1) {
metaDataList.push(propertyKey);
}
relationshipMap.set(target.constructor, metaDataList);
return Reflect.metadata(relationshipKey, value).apply(this, arguments);
};
};
export const getResourceType = function(target: any, propertyKey: string) {
return Reflect.getMetadata(relationshipKey, target, propertyKey);
};
export const getRelationships = function(target: any) {
return relationshipMap.get(target);
};

View File

@@ -0,0 +1,156 @@
import { Injectable } from "@angular/core";
import { CacheableObject } from "../object-cache.reducer";
import { ObjectCacheService } from "../object-cache.service";
import { RequestService } from "../../data/request.service";
import { ResponseCacheService } from "../response-cache.service";
import { Store } from "@ngrx/store";
import { CoreState } from "../../core.reducers";
import { RequestEntry } from "../../data/request.reducer";
import { hasValue, isNotEmpty } from "../../../shared/empty.util";
import { ResponseCacheEntry } from "../response-cache.reducer";
import { ErrorResponse, SuccessResponse } from "../response-cache.models";
import { Observable } from "rxjs/Observable";
import { RemoteData } from "../../data/remote-data";
import { GenericConstructor } from "../../shared/generic-constructor";
import { getMapsTo, getResourceType, getRelationships } from "./build-decorators";
import { NormalizedDSOFactory } from "../models/normalized-dspace-object-factory";
@Injectable()
export class RemoteDataBuildService {
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected store: Store<CoreState>,
) {
}
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 requestPending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.requestPending).distinctUntilChanged();
const responsePending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.responsePending).distinctUntilChanged();
const isSuccessFul = responseCacheObs
.map((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful).distinctUntilChanged();
const errorMessage = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry) && !entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.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) => {
return this.build<TNormalized, TDomain>(normalized);
});
return new RemoteData(
href,
requestPending,
responsePending,
isSuccessFul,
errorMessage,
payload
);
}
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 requestPending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.requestPending).distinctUntilChanged();
const responsePending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.responsePending).distinctUntilChanged();
const isSuccessFul = responseCacheObs
.map((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful).distinctUntilChanged();
const errorMessage = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry) && !entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged();
const payload = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
return this.objectCache.getList(resourceUUIDs, normalizedType)
.map((normList: TNormalized[]) => {
return normList.map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized);
});
});
})
.distinctUntilChanged();
return new RemoteData(
href,
requestPending,
responsePending,
isSuccessFul,
errorMessage,
payload
);
}
build<TNormalized extends CacheableObject, TDomain>(normalized: TNormalized): TDomain {
let links: any = {};
const relationships = getRelationships(normalized.constructor) || [];
relationships.forEach((relationship: string) => {
if (hasValue(normalized[relationship])) {
const resourceType = getResourceType(normalized, relationship);
const resourceConstructor = NormalizedDSOFactory.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(href, resourceConstructor)
});
}, 0);
links[relationship] = normalized[relationship].map((href: string) => {
return this.buildSingle(href, resourceConstructor);
});
}
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(normalized[relationship], resourceConstructor);
},0);
links[relationship] = this.buildSingle(normalized[relationship], resourceConstructor);
}
}
});
const domainModel = getMapsTo(normalized.constructor);
return Object.assign(new domainModel(), normalized, links);
}
}

View File

@@ -1,14 +1,14 @@
import { combineReducers } from "@ngrx/store"; import { combineReducers } from "@ngrx/store";
import { RequestCacheState, requestCacheReducer } from "./request-cache.reducer"; import { ResponseCacheState, responseCacheReducer } from "./response-cache.reducer";
import { ObjectCacheState, objectCacheReducer } from "./object-cache.reducer"; import { ObjectCacheState, objectCacheReducer } from "./object-cache.reducer";
export interface CacheState { export interface CacheState {
request: RequestCacheState, response: ResponseCacheState,
object: ObjectCacheState object: ObjectCacheState
} }
export const reducers = { export const reducers = {
request: requestCacheReducer, response: responseCacheReducer,
object: objectCacheReducer object: objectCacheReducer
}; };

View File

@@ -0,0 +1,43 @@
import { inheritSerialization, autoserialize } from "cerialize";
import { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { Bitstream } from "../../shared/bitstream.model";
import { mapsTo } from "../builders/build-decorators";
@mapsTo(Bitstream)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedBitstream extends NormalizedDSpaceObject {
/**
* The size of this bitstream in bytes(?)
*/
@autoserialize
size: number;
/**
* The relative path to this Bitstream's file
*/
url: string;
/**
* The mime type of this Bitstream
*/
mimetype: string;
/**
* The description of this Bitstream
*/
description: string;
/**
* An array of Bundles that are direct parents of this Bitstream
*/
parents: Array<string>;
/**
* The Bundle that owns this Bitstream
*/
owner: string;
@autoserialize
retrieve: string;
}

View File

@@ -0,0 +1,30 @@
import { autoserialize, inheritSerialization } from "cerialize";
import { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { Bundle } from "../../shared/bundle.model";
import { mapsTo, relationship } from "../builders/build-decorators";
import { NormalizedDSOType } from "./normalized-dspace-object-type";
@mapsTo(Bundle)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedBundle extends NormalizedDSpaceObject {
/**
* The primary bitstream of this Bundle
*/
@autoserialize
@relationship(NormalizedDSOType.NormalizedBitstream)
primaryBitstream: string;
/**
* An array of Items that are direct parents of this Bundle
*/
parents: Array<string>;
/**
* The Item that owns this Bundle
*/
owner: string;
@autoserialize
@relationship(NormalizedDSOType.NormalizedBitstream)
bitstreams: Array<string>;
}

View File

@@ -0,0 +1,36 @@
import { autoserialize, inheritSerialization, autoserializeAs } from "cerialize";
import { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { Collection } from "../../shared/collection.model";
import { mapsTo, relationship } from "../builders/build-decorators";
import { NormalizedDSOType } from "./normalized-dspace-object-type";
@mapsTo(Collection)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedCollection extends NormalizedDSpaceObject {
/**
* A string representing the unique handle of this Collection
*/
@autoserialize
handle: string;
/**
* The Bitstream that represents the logo of this Collection
*/
logo: string;
/**
* An array of Collections that are direct parents of this Collection
*/
parents: Array<string>;
/**
* The Collection that owns this Collection
*/
owner: string;
@autoserialize
@relationship(NormalizedDSOType.NormalizedItem)
items: Array<string>;
}

View File

@@ -0,0 +1,36 @@
import { autoserialize, inheritSerialization, autoserializeAs } from "cerialize";
import { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { Community } from "../../shared/community.model";
import { mapsTo, relationship } from "../builders/build-decorators";
import { NormalizedDSOType } from "./normalized-dspace-object-type";
@mapsTo(Community)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedCommunity extends NormalizedDSpaceObject {
/**
* A string representing the unique handle of this Community
*/
@autoserialize
handle: string;
/**
* The Bitstream that represents the logo of this Community
*/
logo: string;
/**
* An array of Communities that are direct parents of this Community
*/
parents: Array<string>;
/**
* The Community that owns this Community
*/
owner: string;
@autoserialize
@relationship(NormalizedDSOType.NormalizedCollection)
collections: Array<string>;
}

View File

@@ -0,0 +1,33 @@
import { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { NormalizedBitstream } from "./normalized-bitstream.model";
import { NormalizedBundle } from "./normalized-bundle.model";
import { NormalizedItem } from "./normalized-item.model";
import { NormalizedCollection } from "./normalized-collection.model";
import { GenericConstructor } from "../../shared/generic-constructor";
import { NormalizedDSOType } from "./normalized-dspace-object-type";
import { NormalizedCommunity } from "./normalized-community.model";
export class NormalizedDSOFactory {
public static getConstructor(type: NormalizedDSOType): GenericConstructor<NormalizedDSpaceObject> {
switch (type) {
case NormalizedDSOType.NormalizedBitstream: {
return NormalizedBitstream
}
case NormalizedDSOType.NormalizedBundle: {
return NormalizedBundle
}
case NormalizedDSOType.NormalizedItem: {
return NormalizedItem
}
case NormalizedDSOType.NormalizedCollection: {
return NormalizedCollection
}
case NormalizedDSOType.NormalizedCommunity: {
return NormalizedCommunity
}
default: {
return undefined;
}
}
}
}

View File

@@ -0,0 +1,7 @@
export enum NormalizedDSOType {
NormalizedBitstream,
NormalizedBundle,
NormalizedItem,
NormalizedCollection,
NormalizedCommunity
}

View File

@@ -0,0 +1,52 @@
import { autoserialize, autoserializeAs } from "cerialize";
import { CacheableObject } from "../object-cache.reducer";
import { Metadatum } from "../../shared/metadatum.model";
/**
* An abstract model class for a DSpaceObject.
*/
export abstract class NormalizedDSpaceObject implements CacheableObject {
@autoserialize
self: string;
/**
* The human-readable identifier of this DSpaceObject
*/
@autoserialize
id: string;
/**
* The universally unique identifier of this DSpaceObject
*/
@autoserialize
uuid: string;
/**
* A string representing the kind of DSpaceObject, e.g. community, item, …
*/
type: string;
/**
* The name for this DSpaceObject
*/
@autoserialize
name: string;
/**
* An array containing all metadata of this DSpaceObject
*/
@autoserializeAs(Metadatum)
metadata: Array<Metadatum>;
/**
* An array of DSpaceObjects that are direct parents of this DSpaceObject
*/
@autoserialize
parents: Array<string>;
/**
* The DSpaceObject that owns this DSpaceObject
*/
owner: string;
}

View File

@@ -0,0 +1,47 @@
import { inheritSerialization, autoserialize } from "cerialize";
import { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { Item } from "../../shared/item.model";
import { mapsTo, relationship } from "../builders/build-decorators";
import { NormalizedDSOType } from "./normalized-dspace-object-type";
@mapsTo(Item)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedItem extends NormalizedDSpaceObject {
/**
* A string representing the unique handle of this Item
*/
@autoserialize
handle: string;
/**
* The Date of the last modification of this Item
*/
lastModified: Date;
/**
* A boolean representing if this Item is currently archived or not
*/
isArchived: boolean;
/**
* A boolean representing if this Item is currently withdrawn or not
*/
isWithdrawn: boolean;
/**
* An array of Collections that are direct parents of this Item
*/
@autoserialize
@relationship(NormalizedDSOType.NormalizedCollection)
parents: Array<string>;
/**
* The Collection that owns this Item
*/
owner: string;
@autoserialize
@relationship(NormalizedDSOType.NormalizedBundle)
bundles: Array<string>;
}

View File

@@ -0,0 +1,10 @@
import { autoserialize } from "cerialize";
export class SelfLink {
@autoserialize
self: string;
@autoserialize
uuid: string;
}

View File

@@ -12,6 +12,7 @@ import { CacheEntry } from "./cache-entry";
*/ */
export interface CacheableObject { export interface CacheableObject {
uuid: string; uuid: string;
self?: string;
} }
/** /**

View File

@@ -60,6 +60,11 @@ export class ObjectCacheService {
.map((entry: ObjectCacheEntry) => <T> Object.assign(new type(), entry.data)); .map((entry: ObjectCacheEntry) => <T> Object.assign(new type(), entry.data));
} }
getBySelfLink<T extends CacheableObject>(href: string, type: GenericConstructor<T>): Observable<T> {
return this.store.select<string>('core', 'index', 'href', href)
.flatMap((uuid: string) => this.get(uuid, type))
}
/** /**
* Get an observable for an array of objects of the same type * Get an observable for an array of objects of the same type
* with the specified UUIDs * with the specified UUIDs
@@ -104,6 +109,25 @@ export class ObjectCacheService {
return result; return result;
} }
/**
* Check whether the object with the specified self link is cached
*
* @param href
* The self link of the object to check
* @return boolean
* true if the object with the specified self link is cached,
* false otherwise
*/
hasBySelfLink(href: string): boolean {
let result: boolean = false;
this.store.select<string>('core', 'index', 'href', href)
.take(1)
.subscribe((uuid: string) => result = this.has(uuid));
return result;
}
/** /**
* Check whether an ObjectCacheEntry should still be cached * Check whether an ObjectCacheEntry should still be cached
* *

View File

@@ -1,205 +0,0 @@
import { OpaqueToken } from "@angular/core";
import { Action } from "@ngrx/store";
import { type } from "../../shared/ngrx/type";
import { PaginationOptions } from "../shared/pagination-options.model";
import { SortOptions } from "../shared/sort-options.model";
/**
* The list of RequestCacheAction type definitions
*/
export const RequestCacheActionTypes = {
FIND_BY_ID: type('dspace/core/cache/request/FIND_BY_ID'),
FIND_ALL: type('dspace/core/cache/request/FIND_ALL'),
SUCCESS: type('dspace/core/cache/request/SUCCESS'),
ERROR: type('dspace/core/cache/request/ERROR'),
REMOVE: type('dspace/core/cache/request/REMOVE'),
RESET_TIMESTAMPS: type('dspace/core/cache/request/RESET_TIMESTAMPS')
};
/**
* An ngrx action to find all objects of a certain type
*/
export class RequestCacheFindAllAction implements Action {
type = RequestCacheActionTypes.FIND_ALL;
payload: {
key: string,
service: OpaqueToken,
scopeID: string,
paginationOptions: PaginationOptions,
sortOptions: SortOptions
};
/**
* Create a new RequestCacheFindAllAction
*
* @param key
* the key under which to cache this request, should be unique
* @param service
* the name of the service that initiated the action
* @param scopeID
* the id of an optional scope object
* @param paginationOptions
* the pagination options
* @param sortOptions
* the sort options
*/
constructor(
key: string,
service: OpaqueToken,
scopeID?: string,
paginationOptions: PaginationOptions = new PaginationOptions(),
sortOptions: SortOptions = new SortOptions()
) {
this.payload = {
key,
service,
scopeID,
paginationOptions,
sortOptions
}
}
}
/**
* An ngrx action to find objects by id
*/
export class RequestCacheFindByIDAction implements Action {
type = RequestCacheActionTypes.FIND_BY_ID;
payload: {
key: string,
service: OpaqueToken,
resourceID: string
};
/**
* Create a new RequestCacheFindByIDAction
*
* @param key
* the key under which to cache this request, should be unique
* @param service
* the name of the service that initiated the action
* @param resourceID
* the ID of the resource to find
*/
constructor(
key: string,
service: OpaqueToken,
resourceID: string
) {
this.payload = {
key,
service,
resourceID
}
}
}
/**
* An ngrx action to indicate a request was returned successful
*/
export class RequestCacheSuccessAction implements Action {
type = RequestCacheActionTypes.SUCCESS;
payload: {
key: string,
resourceUUIDs: Array<string>,
timeAdded: number,
msToLive: number
};
/**
* Create a new RequestCacheSuccessAction
*
* @param key
* the key under which cache this request is cached,
* should be identical to the one used in the corresponding
* find action
* @param resourceUUIDs
* the UUIDs returned from the backend
* @param timeAdded
* the time it was returned
* @param msToLive
* the amount of milliseconds before it should expire
*/
constructor(key: string, resourceUUIDs: Array<string>, timeAdded, msToLive: number) {
this.payload = {
key,
resourceUUIDs,
timeAdded,
msToLive
};
}
}
/**
* An ngrx action to indicate a request failed
*/
export class RequestCacheErrorAction implements Action {
type = RequestCacheActionTypes.ERROR;
payload: {
key: string,
errorMessage: string
};
/**
* Create a new RequestCacheErrorAction
*
* @param key
* the key under which cache this request is cached,
* should be identical to the one used in the corresponding
* find action
* @param errorMessage
* A message describing the reason the request failed
*/
constructor(key: string, errorMessage: string) {
this.payload = {
key,
errorMessage
};
}
}
/**
* An ngrx action to remove a request from the cache
*/
export class RequestCacheRemoveAction implements Action {
type = RequestCacheActionTypes.REMOVE;
payload: string;
/**
* Create a new RequestCacheRemoveAction
* @param key
* The key of the request to remove
*/
constructor(key: string) {
this.payload = key;
}
}
/**
* An ngrx action to reset the timeAdded property of all cached objects
*/
export class ResetRequestCacheTimestampsAction implements Action {
type = RequestCacheActionTypes.RESET_TIMESTAMPS;
payload: number;
/**
* Create a new ResetObjectCacheTimestampsAction
*
* @param newTimestamp
* the new timeAdded all objects should get
*/
constructor(newTimestamp: number) {
this.payload = newTimestamp;
}
}
/**
* A type to encompass all RequestCacheActions
*/
export type RequestCacheAction
= RequestCacheFindAllAction
| RequestCacheFindByIDAction
| RequestCacheSuccessAction
| RequestCacheErrorAction
| RequestCacheRemoveAction
| ResetRequestCacheTimestampsAction;

View File

@@ -1,240 +0,0 @@
import { requestCacheReducer, RequestCacheState } from "./request-cache.reducer";
import {
RequestCacheRemoveAction, RequestCacheFindByIDAction,
RequestCacheFindAllAction, RequestCacheSuccessAction, RequestCacheErrorAction,
ResetRequestCacheTimestampsAction
} from "./request-cache.actions";
import deepFreeze = require("deep-freeze");
import { OpaqueToken } from "@angular/core";
import { PaginationOptions } from "../shared/pagination-options.model";
class NullAction extends RequestCacheRemoveAction {
type = null;
payload = null;
constructor() {
super(null);
}
}
describe("requestCacheReducer", () => {
const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"];
const services = [new OpaqueToken('service1'), new OpaqueToken('service2')];
const msToLive = 900000;
const uuids = [
"9e32a2e2-6b91-4236-a361-995ccdc14c60",
"598ce822-c357-46f3-ab70-63724d02d6ad",
"be8325f7-243b-49f4-8a4b-df2b793ff3b5"
];
const resourceID = "9978";
const paginationOptions: PaginationOptions = {
"id": "test",
"currentPage": 1,
"pageSizeOptions": [5, 10, 20, 40, 60, 80, 100],
"disabled": false,
"boundaryLinks": false,
"directionLinks": true,
"ellipses": true,
"maxSize": 0,
"pageSize": 10,
"rotate": false,
"size": 'sm'
};
const sortOptions = { "field": "id", "direction": 0 };
const testState = {
[keys[0]]: {
"key": keys[0],
"service": services[0],
"resourceUUIDs": [uuids[0], uuids[1]],
"isLoading": false,
"paginationOptions": paginationOptions,
"sortOptions": sortOptions,
"timeAdded": new Date().getTime(),
"msToLive": msToLive
},
[keys[1]]: {
"key": keys[1],
"service": services[1],
"resourceID": resourceID,
"resourceUUIDs": [uuids[2]],
"isLoading": false,
"timeAdded": new Date().getTime(),
"msToLive": msToLive
}
};
deepFreeze(testState);
const errorState: {} = {
[keys[0]]: {
errorMessage: 'error',
resourceUUIDs: uuids
}
};
deepFreeze(errorState);
it("should return the current state when no valid actions have been made", () => {
const action = new NullAction();
const newState = requestCacheReducer(testState, action);
expect(newState).toEqual(testState);
});
it("should start with an empty cache", () => {
const action = new NullAction();
const initialState = requestCacheReducer(undefined, action);
expect(initialState).toEqual(Object.create(null));
});
describe("FIND_BY_ID", () => {
const action = new RequestCacheFindByIDAction(keys[0], services[0], resourceID);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should add the request to the cache", () => {
const state = Object.create(null);
const newState = requestCacheReducer(state, action);
expect(newState[keys[0]].key).toBe(keys[0]);
expect(newState[keys[0]].service).toEqual(services[0]);
expect(newState[keys[0]].resourceID).toBe(resourceID);
});
it("should set isLoading to true", () => {
const state = Object.create(null);
const newState = requestCacheReducer(state, action);
expect(newState[keys[0]].isLoading).toBe(true);
});
it("should remove any previous error message or resourceUUID for the request", () => {
const newState = requestCacheReducer(errorState, action);
expect(newState[keys[0]].resourceUUIDs.length).toBe(0);
expect(newState[keys[0]].errorMessage).toBeUndefined();
});
});
describe("FIND_ALL", () => {
const action = new RequestCacheFindAllAction(keys[0], services[0], resourceID, paginationOptions, sortOptions);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should add the request to the cache", () => {
const state = Object.create(null);
const newState = requestCacheReducer(state, action);
expect(newState[keys[0]].key).toBe(keys[0]);
expect(newState[keys[0]].service).toEqual(services[0]);
expect(newState[keys[0]].scopeID).toBe(resourceID);
expect(newState[keys[0]].paginationOptions).toEqual(paginationOptions);
expect(newState[keys[0]].sortOptions).toEqual(sortOptions);
});
it("should set isLoading to true", () => {
const state = Object.create(null);
const newState = requestCacheReducer(state, action);
expect(newState[keys[0]].isLoading).toBe(true);
});
it("should remove any previous error message or resourceUUIDs for the request", () => {
const newState = requestCacheReducer(errorState, action);
expect(newState[keys[0]].resourceUUIDs.length).toBe(0);
expect(newState[keys[0]].errorMessage).toBeUndefined();
});
});
describe("SUCCESS", () => {
const successUUIDs = [uuids[0], uuids[2]];
const successTimeAdded = new Date().getTime();
const successMsToLive = 5;
const action = new RequestCacheSuccessAction(keys[0], successUUIDs, successTimeAdded, successMsToLive);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should add the response to the cached request", () => {
const newState = requestCacheReducer(testState, action);
expect(newState[keys[0]].resourceUUIDs).toBe(successUUIDs);
expect(newState[keys[0]].timeAdded).toBe(successTimeAdded);
expect(newState[keys[0]].msToLive).toBe(successMsToLive);
});
it("should set isLoading to false", () => {
const newState = requestCacheReducer(testState, action);
expect(newState[keys[0]].isLoading).toBe(false);
});
it("should remove any previous error message for the request", () => {
const newState = requestCacheReducer(errorState, action);
expect(newState[keys[0]].errorMessage).toBeUndefined();
});
});
describe("ERROR", () => {
const errorMsg = 'errorMsg';
const action = new RequestCacheErrorAction(keys[0], errorMsg);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should set an error message for the request", () => {
const newState = requestCacheReducer(errorState, action);
expect(newState[keys[0]].errorMessage).toBe(errorMsg);
});
it("should set isLoading to false", () => {
const newState = requestCacheReducer(testState, action);
expect(newState[keys[0]].isLoading).toBe(false);
});
});
describe("REMOVE", () => {
it("should perform the action without affecting the previous state", () => {
const action = new RequestCacheRemoveAction(keys[0]);
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should remove the specified request from the cache", () => {
const action = new RequestCacheRemoveAction(keys[0]);
const newState = requestCacheReducer(testState, action);
expect(testState[keys[0]]).not.toBeUndefined();
expect(newState[keys[0]]).toBeUndefined();
});
it("shouldn't do anything when the specified key isn't cached", () => {
const wrongKey = "this isn't cached";
const action = new RequestCacheRemoveAction(wrongKey);
const newState = requestCacheReducer(testState, action);
expect(testState[wrongKey]).toBeUndefined();
expect(newState).toEqual(testState);
});
});
describe("RESET_TIMESTAMPS", () => {
const newTimeStamp = new Date().getTime();
const action = new ResetRequestCacheTimestampsAction(newTimeStamp);
it("should perform the action without affecting the previous state", () => {
//testState has already been frozen above
requestCacheReducer(testState, action);
});
it("should set the timestamp of all requests in the cache", () => {
const newState = requestCacheReducer(testState, action);
Object.keys(newState).forEach((key) => {
expect(newState[key].timeAdded).toEqual(newTimeStamp);
});
});
});
});

View File

@@ -1,212 +0,0 @@
import { PaginationOptions } from "../shared/pagination-options.model";
import { SortOptions } from "../shared/sort-options.model";
import {
RequestCacheAction, RequestCacheActionTypes, RequestCacheFindAllAction,
RequestCacheSuccessAction, RequestCacheErrorAction, RequestCacheFindByIDAction,
RequestCacheRemoveAction, ResetRequestCacheTimestampsAction
} from "./request-cache.actions";
import { OpaqueToken } from "@angular/core";
import { CacheEntry } from "./cache-entry";
import { hasValue } from "../../shared/empty.util";
/**
* An entry in the RequestCache
*/
export class RequestCacheEntry implements CacheEntry {
service: OpaqueToken;
key: string;
scopeID: string;
resourceID: string;
resourceUUIDs: Array<String>;
resourceType: String;
isLoading: boolean;
errorMessage: string;
paginationOptions: PaginationOptions;
sortOptions: SortOptions;
timeAdded: number;
msToLive: number;
}
/**
* The RequestCache State
*/
export interface RequestCacheState {
[key: string]: RequestCacheEntry
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
/**
* The RequestCache Reducer
*
* @param state
* the current state
* @param action
* the action to perform on the state
* @return RequestCacheState
* the new state
*/
export const requestCacheReducer = (state = initialState, action: RequestCacheAction): RequestCacheState => {
switch (action.type) {
case RequestCacheActionTypes.FIND_ALL: {
return findAllRequest(state, <RequestCacheFindAllAction> action);
}
case RequestCacheActionTypes.FIND_BY_ID: {
return findByIDRequest(state, <RequestCacheFindByIDAction> action);
}
case RequestCacheActionTypes.SUCCESS: {
return success(state, <RequestCacheSuccessAction> action);
}
case RequestCacheActionTypes.ERROR: {
return error(state, <RequestCacheErrorAction> action);
}
case RequestCacheActionTypes.REMOVE: {
return removeFromCache(state, <RequestCacheRemoveAction> action);
}
case RequestCacheActionTypes.RESET_TIMESTAMPS: {
return resetRequestCacheTimestamps(state, <ResetRequestCacheTimestampsAction>action)
}
default: {
return state;
}
}
};
/**
* Add a FindAll request to the cache
*
* @param state
* the current state
* @param action
* a RequestCacheFindAllAction
* @return RequestCacheState
* the new state, with the request added, or overwritten
*/
function findAllRequest(state: RequestCacheState, action: RequestCacheFindAllAction): RequestCacheState {
return Object.assign({}, state, {
[action.payload.key]: {
key: action.payload.key,
service: action.payload.service,
scopeID: action.payload.scopeID,
resourceUUIDs: [],
isLoading: true,
errorMessage: undefined,
paginationOptions: action.payload.paginationOptions,
sortOptions: action.payload.sortOptions
}
});
}
/**
* Add a FindByID request to the cache
*
* @param state
* the current state
* @param action
* a RequestCacheFindByIDAction
* @return RequestCacheState
* the new state, with the request added, or overwritten
*/
function findByIDRequest(state: RequestCacheState, action: RequestCacheFindByIDAction): RequestCacheState {
return Object.assign({}, state, {
[action.payload.key]: {
key: action.payload.key,
service: action.payload.service,
resourceID: action.payload.resourceID,
resourceUUIDs: [],
isLoading: true,
errorMessage: undefined,
}
});
}
/**
* Update a cached request with a successful response
*
* @param state
* the current state
* @param action
* a RequestCacheSuccessAction
* @return RequestCacheState
* the new state, with the response added to the request
*/
function success(state: RequestCacheState, action: RequestCacheSuccessAction): RequestCacheState {
return Object.assign({}, state, {
[action.payload.key]: Object.assign({}, state[action.payload.key], {
isLoading: false,
resourceUUIDs: action.payload.resourceUUIDs,
errorMessage: undefined,
timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive
})
});
}
/**
* Update a cached request with an error
*
* @param state
* the current state
* @param action
* a RequestCacheSuccessAction
* @return RequestCacheState
* the new state, with the error added to the request
*/
function error(state: RequestCacheState, action: RequestCacheErrorAction): RequestCacheState {
return Object.assign({}, state, {
[action.payload.key]: Object.assign({}, state[action.payload.key], {
isLoading: false,
errorMessage: action.payload.errorMessage
})
});
}
/**
* Remove a request from the cache
*
* @param state
* the current state
* @param action
* an RequestCacheRemoveAction
* @return RequestCacheState
* the new state, with the request removed if it existed.
*/
function removeFromCache(state: RequestCacheState, action: RequestCacheRemoveAction): RequestCacheState {
if (hasValue(state[action.payload])) {
let newCache = Object.assign({}, state);
delete newCache[action.payload];
return newCache;
}
else {
return state;
}
}
/**
* Set the timeAdded timestamp of every cached request to the specified value
*
* @param state
* the current state
* @param action
* a ResetRequestCacheTimestampsAction
* @return RequestCacheState
* the new state, with all timeAdded timestamps set to the specified value
*/
function resetRequestCacheTimestamps(state: RequestCacheState, action: ResetRequestCacheTimestampsAction): RequestCacheState {
let newState = Object.create(null);
Object.keys(state).forEach(key => {
newState[key] = Object.assign({}, state[key], {
timeAdded: action.payload
});
});
return newState;
}

View File

@@ -1,160 +0,0 @@
import { RequestCacheService } from "./request-cache.service";
import { Store } from "@ngrx/store";
import { RequestCacheState, RequestCacheEntry } from "./request-cache.reducer";
import { OpaqueToken } from "@angular/core";
import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "./request-cache.actions";
import { Observable } from "rxjs";
import { PaginationOptions } from "../shared/pagination-options.model";
describe("RequestCacheService", () => {
let service: RequestCacheService;
let store: Store<RequestCacheState>;
const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"];
const serviceTokens = [new OpaqueToken('service1'), new OpaqueToken('service2')];
const resourceID = "9978";
const paginationOptions: PaginationOptions = {
"id": "test",
"currentPage": 1,
"pageSizeOptions": [5, 10, 20, 40, 60, 80, 100],
"disabled": false,
"boundaryLinks": false,
"directionLinks": true,
"ellipses": true,
"maxSize": 0,
"pageSize": 10,
"rotate": false,
"size": 'sm'
};
const sortOptions = { "field": "id", "direction": 0 };
const timestamp = new Date().getTime();
const validCacheEntry = (key) => {
return {
key: key,
timeAdded: timestamp,
msToLive: 24 * 60 * 60 * 1000 // a day
}
};
const invalidCacheEntry = (key) => {
return {
key: key,
timeAdded: 0,
msToLive: 0
}
};
beforeEach(() => {
store = new Store<RequestCacheState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = new RequestCacheService(store);
spyOn(window, 'Date').and.returnValue({ getTime: () => timestamp });
});
describe("findAll", () => {
beforeEach(() => {
spyOn(service, "get").and.callFake((key) => Observable.of({key: key}));
});
describe("if the key isn't cached", () => {
beforeEach(() => {
spyOn(service, "has").and.returnValue(false);
});
it("should dispatch a FIND_ALL action with the key, service, scopeID, paginationOptions and sortOptions", () => {
service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions);
expect(store.dispatch).toHaveBeenCalledWith(new RequestCacheFindAllAction(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions))
});
it("should return an observable of the newly cached request with the specified key", () => {
let result: RequestCacheEntry;
service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry);
expect(result.key).toEqual(keys[0]);
});
});
describe("if the key is already cached", () => {
beforeEach(() => {
spyOn(service, "has").and.returnValue(true);
});
it("shouldn't dispatch anything", () => {
service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions);
expect(store.dispatch).not.toHaveBeenCalled();
});
it("should return an observable of the existing cached request with the specified key", () => {
let result: RequestCacheEntry;
service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry);
expect(result.key).toEqual(keys[0]);
});
});
});
describe("findById", () => {
beforeEach(() => {
spyOn(service, "get").and.callFake((key) => Observable.of({key: key}));
});
describe("if the key isn't cached", () => {
beforeEach(() => {
spyOn(service, "has").and.returnValue(false);
});
it("should dispatch a FIND_BY_ID action with the key, service, and resourceID", () => {
service.findById(keys[0], serviceTokens[0], resourceID);
expect(store.dispatch).toHaveBeenCalledWith(new RequestCacheFindByIDAction(keys[0], serviceTokens[0], resourceID))
});
it("should return an observable of the newly cached request with the specified key", () => {
let result: RequestCacheEntry;
service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry);
expect(result.key).toEqual(keys[0]);
});
});
describe("if the key is already cached", () => {
beforeEach(() => {
spyOn(service, "has").and.returnValue(true);
});
it("shouldn't dispatch anything", () => {
service.findById(keys[0], serviceTokens[0], resourceID);
expect(store.dispatch).not.toHaveBeenCalled();
});
it("should return an observable of the existing cached request with the specified key", () => {
let result: RequestCacheEntry;
service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry);
expect(result.key).toEqual(keys[0]);
});
});
});
describe("get", () => {
it("should return an observable of the cached request with the specified key", () => {
spyOn(store, "select").and.callFake((...args:Array<any>) => {
return Observable.of(validCacheEntry(args[args.length - 1]));
});
let testObj: RequestCacheEntry;
service.get(keys[1]).take(1).subscribe(entry => testObj = entry);
expect(testObj.key).toEqual(keys[1]);
});
it("should not return a cached request that has exceeded its time to live", () => {
spyOn(store, "select").and.callFake((...args:Array<any>) => {
return Observable.of(invalidCacheEntry(args[args.length - 1]));
});
let getObsHasFired = false;
const subscription = service.get(keys[1]).subscribe(entry => getObsHasFired = true);
expect(getObsHasFired).toBe(false);
subscription.unsubscribe();
});
});
describe("has", () => {
it("should return true if the request with the supplied key is cached and still valid", () => {
spyOn(store, 'select').and.returnValue(Observable.of(validCacheEntry(keys[1])));
expect(service.has(keys[1])).toBe(true);
});
it("should return false if the request with the supplied key isn't cached", () => {
spyOn(store, 'select').and.returnValue(Observable.of(undefined));
expect(service.has(keys[1])).toBe(false);
});
it("should return false if the request with the supplied key is cached but has exceeded its time to live", () => {
spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry(keys[1])));
expect(service.has(keys[1])).toBe(false);
});
});
});

View File

@@ -1,138 +0,0 @@
import { Injectable, OpaqueToken } from "@angular/core";
import { Store } from "@ngrx/store";
import { RequestCacheState, RequestCacheEntry } from "./request-cache.reducer";
import { Observable } from "rxjs";
import { hasNoValue } from "../../shared/empty.util";
import {
RequestCacheRemoveAction, RequestCacheFindAllAction,
RequestCacheFindByIDAction
} from "./request-cache.actions";
import { SortOptions } from "../shared/sort-options.model";
import { PaginationOptions } from "../shared/pagination-options.model";
/**
* A service to interact with the request cache
*/
@Injectable()
export class RequestCacheService {
constructor(
private store: Store<RequestCacheState>
) {}
/**
* Start a new findAll request
*
* This will send a new findAll request to the backend,
* and store the request parameters and the fact that
* the request is pending
*
* @param key
* the key should be a unique identifier for the request and its parameters
* @param service
* the service that initiated the request
* @param scopeID
* the id of an optional scope object
* @param paginationOptions
* the pagination options (optional)
* @param sortOptions
* the sort options (optional)
* @return Observable<RequestCacheEntry>
* an observable of the RequestCacheEntry for this request
*/
findAll(
key: string,
service: OpaqueToken,
scopeID?: string,
paginationOptions?: PaginationOptions,
sortOptions?: SortOptions
): Observable<RequestCacheEntry> {
if (!this.has(key)) {
this.store.dispatch(new RequestCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions));
}
return this.get(key);
}
/**
* Start a new findById request
*
* This will send a new findById request to the backend,
* and store the request parameters and the fact that
* the request is pending
*
* @param key
* the key should be a unique identifier for the request and its parameters
* @param service
* the service that initiated the request
* @param resourceID
* the ID of the resource to find
* @return Observable<RequestCacheEntry>
* an observable of the RequestCacheEntry for this request
*/
findById(
key: string,
service: OpaqueToken,
resourceID: string
): Observable<RequestCacheEntry> {
if (!this.has(key)) {
this.store.dispatch(new RequestCacheFindByIDAction(key, service, resourceID));
}
return this.get(key);
}
/**
* Get an observable of the request with the specified key
*
* @param key
* the key of the request to get
* @return Observable<RequestCacheEntry>
* an observable of the RequestCacheEntry with the specified key
*/
get(key: string): Observable<RequestCacheEntry> {
return this.store.select<RequestCacheEntry>('core', 'cache', 'request', key)
.filter(entry => this.isValid(entry))
.distinctUntilChanged()
}
/**
* Check whether the request with the specified key is cached
*
* @param key
* the key of the request to check
* @return boolean
* true if the request with the specified key is cached,
* false otherwise
*/
has(key: string): boolean {
let result: boolean;
this.store.select<RequestCacheEntry>('core', 'cache', 'request', key)
.take(1)
.subscribe(entry => result = this.isValid(entry));
return result;
}
/**
* Check whether a RequestCacheEntry should still be cached
*
* @param entry
* the entry to check
* @return boolean
* false if the entry is null, undefined, or its time to
* live has been exceeded, true otherwise
*/
private isValid(entry: RequestCacheEntry): boolean {
if (hasNoValue(entry)) {
return false;
}
else {
const timeOutdated = entry.timeAdded + entry.msToLive;
const isOutDated = new Date().getTime() > timeOutdated;
if (isOutDated) {
this.store.dispatch(new RequestCacheRemoveAction(entry.key));
}
return !isOutDated;
}
}
}

View File

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

View File

@@ -0,0 +1,16 @@
export class Response {
constructor(public isSuccessful: boolean) {}
}
export class SuccessResponse extends Response {
constructor(public resourceUUIDs: Array<String>) {
super(true);
}
}
export class ErrorResponse extends Response {
constructor(public errorMessage: string) {
super(false);
}
}

View File

@@ -0,0 +1,225 @@
import { responseCacheReducer, ResponseCacheState } from "./response-cache.reducer";
import {
ResponseCacheRemoveAction,
ResetResponseCacheTimestampsAction
} from "./response-cache.actions";
import deepFreeze = require("deep-freeze");
class NullAction extends ResponseCacheRemoveAction {
type = null;
payload = null;
constructor() {
super(null);
}
}
// describe("responseCacheReducer", () => {
// const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"];
// const services = [new OpaqueToken('service1'), new OpaqueToken('service2')];
// const msToLive = 900000;
// const uuids = [
// "9e32a2e2-6b91-4236-a361-995ccdc14c60",
// "598ce822-c357-46f3-ab70-63724d02d6ad",
// "be8325f7-243b-49f4-8a4b-df2b793ff3b5"
// ];
// const resourceID = "9978";
// const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 };
// const sortOptions = { "field": "id", "direction": 0 };
// const testState = {
// [keys[0]]: {
// "key": keys[0],
// "service": services[0],
// "resourceUUIDs": [uuids[0], uuids[1]],
// "isLoading": false,
// "paginationOptions": paginationOptions,
// "sortOptions": sortOptions,
// "timeAdded": new Date().getTime(),
// "msToLive": msToLive
// },
// [keys[1]]: {
// "key": keys[1],
// "service": services[1],
// "resourceID": resourceID,
// "resourceUUIDs": [uuids[2]],
// "isLoading": false,
// "timeAdded": new Date().getTime(),
// "msToLive": msToLive
// }
// };
// deepFreeze(testState);
// const errorState: {} = {
// [keys[0]]: {
// errorMessage: 'error',
// resourceUUIDs: uuids
// }
// };
// deepFreeze(errorState);
//
//
// it("should return the current state when no valid actions have been made", () => {
// const action = new NullAction();
// const newState = responseCacheReducer(testState, action);
//
// expect(newState).toEqual(testState);
// });
//
// it("should start with an empty cache", () => {
// const action = new NullAction();
// const initialState = responseCacheReducer(undefined, action);
//
// expect(initialState).toEqual(Object.create(null));
// });
//
// describe("FIND_BY_ID", () => {
// const action = new ResponseCacheFindByIDAction(keys[0], services[0], resourceID);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should add the request to the cache", () => {
// const state = Object.create(null);
// const newState = responseCacheReducer(state, action);
// expect(newState[keys[0]].key).toBe(keys[0]);
// expect(newState[keys[0]].service).toEqual(services[0]);
// expect(newState[keys[0]].resourceID).toBe(resourceID);
// });
//
// it("should set responsePending to true", () => {
// const state = Object.create(null);
// const newState = responseCacheReducer(state, action);
// expect(newState[keys[0]].responsePending).toBe(true);
// });
//
// it("should remove any previous error message or resourceUUID for the request", () => {
// const newState = responseCacheReducer(errorState, action);
// expect(newState[keys[0]].resourceUUIDs.length).toBe(0);
// expect(newState[keys[0]].errorMessage).toBeUndefined();
// });
// });
//
// describe("FIND_ALL", () => {
// const action = new ResponseCacheFindAllAction(keys[0], services[0], resourceID, paginationOptions, sortOptions);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should add the request to the cache", () => {
// const state = Object.create(null);
// const newState = responseCacheReducer(state, action);
// expect(newState[keys[0]].key).toBe(keys[0]);
// expect(newState[keys[0]].service).toEqual(services[0]);
// expect(newState[keys[0]].scopeID).toBe(resourceID);
// expect(newState[keys[0]].paginationOptions).toEqual(paginationOptions);
// expect(newState[keys[0]].sortOptions).toEqual(sortOptions);
// });
//
// it("should set responsePending to true", () => {
// const state = Object.create(null);
// const newState = responseCacheReducer(state, action);
// expect(newState[keys[0]].responsePending).toBe(true);
// });
//
// it("should remove any previous error message or resourceUUIDs for the request", () => {
// const newState = responseCacheReducer(errorState, action);
// expect(newState[keys[0]].resourceUUIDs.length).toBe(0);
// expect(newState[keys[0]].errorMessage).toBeUndefined();
// });
// });
//
// describe("SUCCESS", () => {
// const successUUIDs = [uuids[0], uuids[2]];
// const successTimeAdded = new Date().getTime();
// const successMsToLive = 5;
// const action = new ResponseCacheSuccessAction(keys[0], successUUIDs, successTimeAdded, successMsToLive);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should add the response to the cached request", () => {
// const newState = responseCacheReducer(testState, action);
// expect(newState[keys[0]].resourceUUIDs).toBe(successUUIDs);
// expect(newState[keys[0]].timeAdded).toBe(successTimeAdded);
// expect(newState[keys[0]].msToLive).toBe(successMsToLive);
// });
//
// it("should set responsePending to false", () => {
// const newState = responseCacheReducer(testState, action);
// expect(newState[keys[0]].responsePending).toBe(false);
// });
//
// it("should remove any previous error message for the request", () => {
// const newState = responseCacheReducer(errorState, action);
// expect(newState[keys[0]].errorMessage).toBeUndefined();
// });
// });
//
// describe("ERROR", () => {
// const errorMsg = 'errorMsg';
// const action = new ResponseCacheErrorAction(keys[0], errorMsg);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should set an error message for the request", () => {
// const newState = responseCacheReducer(errorState, action);
// expect(newState[keys[0]].errorMessage).toBe(errorMsg);
// });
//
// it("should set responsePending to false", () => {
// const newState = responseCacheReducer(testState, action);
// expect(newState[keys[0]].responsePending).toBe(false);
// });
// });
//
// describe("REMOVE", () => {
// it("should perform the action without affecting the previous state", () => {
// const action = new ResponseCacheRemoveAction(keys[0]);
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should remove the specified request from the cache", () => {
// const action = new ResponseCacheRemoveAction(keys[0]);
// const newState = responseCacheReducer(testState, action);
// expect(testState[keys[0]]).not.toBeUndefined();
// expect(newState[keys[0]]).toBeUndefined();
// });
//
// it("shouldn't do anything when the specified key isn't cached", () => {
// const wrongKey = "this isn't cached";
// const action = new ResponseCacheRemoveAction(wrongKey);
// const newState = responseCacheReducer(testState, action);
// expect(testState[wrongKey]).toBeUndefined();
// expect(newState).toEqual(testState);
// });
// });
//
// describe("RESET_TIMESTAMPS", () => {
// const newTimeStamp = new Date().getTime();
// const action = new ResetResponseCacheTimestampsAction(newTimeStamp);
//
// it("should perform the action without affecting the previous state", () => {
// //testState has already been frozen above
// responseCacheReducer(testState, action);
// });
//
// it("should set the timestamp of all requests in the cache", () => {
// const newState = responseCacheReducer(testState, action);
// Object.keys(newState).forEach((key) => {
// expect(newState[key].timeAdded).toEqual(newTimeStamp);
// });
// });
//
// });
//
//
// });

View File

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

View File

@@ -0,0 +1,146 @@
import { ResponseCacheService } from "./response-cache.service";
import { Store } from "@ngrx/store";
import { ResponseCacheState, ResponseCacheEntry } from "./response-cache.reducer";
import { OpaqueToken } from "@angular/core";
import { Observable } from "rxjs";
// describe("ResponseCacheService", () => {
// let service: ResponseCacheService;
// let store: Store<ResponseCacheState>;
//
// const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"];
// const serviceTokens = [new OpaqueToken('service1'), new OpaqueToken('service2')];
// const resourceID = "9978";
// const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 };
// const sortOptions = { "field": "id", "direction": 0 };
// const timestamp = new Date().getTime();
// const validCacheEntry = (key) => {
// return {
// key: key,
// timeAdded: timestamp,
// msToLive: 24 * 60 * 60 * 1000 // a day
// }
// };
// const invalidCacheEntry = (key) => {
// return {
// key: key,
// timeAdded: 0,
// msToLive: 0
// }
// };
//
// beforeEach(() => {
// store = new Store<ResponseCacheState>(undefined, undefined, undefined);
// spyOn(store, 'dispatch');
// service = new ResponseCacheService(store);
// spyOn(window, 'Date').and.returnValue({ getTime: () => timestamp });
// });
//
// describe("findAll", () => {
// beforeEach(() => {
// spyOn(service, "get").and.callFake((key) => Observable.of({key: key}));
// });
// describe("if the key isn't cached", () => {
// beforeEach(() => {
// spyOn(service, "has").and.returnValue(false);
// });
// it("should dispatch a FIND_ALL action with the key, service, scopeID, paginationOptions and sortOptions", () => {
// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions);
// expect(store.dispatch).toHaveBeenCalledWith(new ResponseCacheFindAllAction(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions))
// });
// it("should return an observable of the newly cached request with the specified key", () => {
// let result: ResponseCacheEntry;
// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry);
// expect(result.key).toEqual(keys[0]);
// });
// });
// describe("if the key is already cached", () => {
// beforeEach(() => {
// spyOn(service, "has").and.returnValue(true);
// });
// it("shouldn't dispatch anything", () => {
// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions);
// expect(store.dispatch).not.toHaveBeenCalled();
// });
// it("should return an observable of the existing cached request with the specified key", () => {
// let result: ResponseCacheEntry;
// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry);
// expect(result.key).toEqual(keys[0]);
// });
// });
// });
//
// describe("findById", () => {
// beforeEach(() => {
// spyOn(service, "get").and.callFake((key) => Observable.of({key: key}));
// });
// describe("if the key isn't cached", () => {
// beforeEach(() => {
// spyOn(service, "has").and.returnValue(false);
// });
// it("should dispatch a FIND_BY_ID action with the key, service, and resourceID", () => {
// service.findById(keys[0], serviceTokens[0], resourceID);
// expect(store.dispatch).toHaveBeenCalledWith(new ResponseCacheFindByIDAction(keys[0], serviceTokens[0], resourceID))
// });
// it("should return an observable of the newly cached request with the specified key", () => {
// let result: ResponseCacheEntry;
// service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry);
// expect(result.key).toEqual(keys[0]);
// });
// });
// describe("if the key is already cached", () => {
// beforeEach(() => {
// spyOn(service, "has").and.returnValue(true);
// });
// it("shouldn't dispatch anything", () => {
// service.findById(keys[0], serviceTokens[0], resourceID);
// expect(store.dispatch).not.toHaveBeenCalled();
// });
// it("should return an observable of the existing cached request with the specified key", () => {
// let result: ResponseCacheEntry;
// service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry);
// expect(result.key).toEqual(keys[0]);
// });
// });
// });
//
// describe("get", () => {
// it("should return an observable of the cached request with the specified key", () => {
// spyOn(store, "select").and.callFake((...args:Array<any>) => {
// return Observable.of(validCacheEntry(args[args.length - 1]));
// });
//
// let testObj: ResponseCacheEntry;
// service.get(keys[1]).take(1).subscribe(entry => testObj = entry);
// expect(testObj.key).toEqual(keys[1]);
// });
//
// it("should not return a cached request that has exceeded its time to live", () => {
// spyOn(store, "select").and.callFake((...args:Array<any>) => {
// return Observable.of(invalidCacheEntry(args[args.length - 1]));
// });
//
// let getObsHasFired = false;
// const subscription = service.get(keys[1]).subscribe(entry => getObsHasFired = true);
// expect(getObsHasFired).toBe(false);
// subscription.unsubscribe();
// });
// });
//
// describe("has", () => {
// it("should return true if the request with the supplied key is cached and still valid", () => {
// spyOn(store, 'select').and.returnValue(Observable.of(validCacheEntry(keys[1])));
// expect(service.has(keys[1])).toBe(true);
// });
//
// it("should return false if the request with the supplied key isn't cached", () => {
// spyOn(store, 'select').and.returnValue(Observable.of(undefined));
// expect(service.has(keys[1])).toBe(false);
// });
//
// it("should return false if the request with the supplied key is cached but has exceeded its time to live", () => {
// spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry(keys[1])));
// expect(service.has(keys[1])).toBe(false);
// });
// });
// });

View File

@@ -0,0 +1,89 @@
import { Injectable } from "@angular/core";
import { Store } from "@ngrx/store";
import {
ResponseCacheState, ResponseCacheEntry
} from "./response-cache.reducer";
import { Observable } from "rxjs";
import { hasNoValue } from "../../shared/empty.util";
import {
ResponseCacheRemoveAction,
ResponseCacheAddAction
} from "./response-cache.actions";
import { Response } from "./response-cache.models";
/**
* A service to interact with the response cache
*/
@Injectable()
export class ResponseCacheService {
constructor(
private store: Store<ResponseCacheState>
) {}
add(key: string, response: Response, msToLive: number): Observable<ResponseCacheEntry> {
if (!this.has(key)) {
// this.store.dispatch(new ResponseCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions));
this.store.dispatch(new ResponseCacheAddAction(key, response, new Date().getTime(), msToLive));
}
return this.get(key);
}
/**
* Get an observable of the response with the specified key
*
* @param key
* the key of the response to get
* @return Observable<ResponseCacheEntry>
* an observable of the ResponseCacheEntry with the specified key
*/
get(key: string): Observable<ResponseCacheEntry> {
return this.store.select<ResponseCacheEntry>('core', 'cache', 'response', key)
.filter(entry => this.isValid(entry))
.distinctUntilChanged()
}
/**
* Check whether the response with the specified key is cached
*
* @param key
* the key of the response to check
* @return boolean
* true if the response with the specified key is cached,
* false otherwise
*/
has(key: string): boolean {
let result: boolean;
this.store.select<ResponseCacheEntry>('core', 'cache', 'response', key)
.take(1)
.subscribe(entry => {
result = this.isValid(entry);
});
return result;
}
/**
* Check whether a ResponseCacheEntry should still be cached
*
* @param entry
* the entry to check
* @return boolean
* false if the entry is null, undefined, or its time to
* live has been exceeded, true otherwise
*/
private isValid(entry: ResponseCacheEntry): boolean {
if (hasNoValue(entry)) {
return false;
}
else {
const timeOutdated = entry.timeAdded + entry.msToLive;
const isOutDated = new Date().getTime() > timeOutdated;
if (isOutDated) {
this.store.dispatch(new ResponseCacheRemoveAction(entry.key));
}
return !isOutDated;
}
}
}

View File

@@ -1,12 +1,11 @@
import { EffectsModule } from "@ngrx/effects"; import { EffectsModule } from "@ngrx/effects";
import { CollectionDataEffects } from "./data-services/collection-data.effects"; import { ObjectCacheEffects } from "./data/object-cache.effects";
import { ItemDataEffects } from "./data-services/item-data.effects"; import { RequestCacheEffects } from "./data/request-cache.effects";
import { ObjectCacheEffects } from "./data-services/object-cache.effects"; import { HrefIndexEffects } from "./index/href-index.effects";
import { RequestCacheEffects } from "./data-services/request-cache.effects"; import { RequestEffects } from "./data/request.effects";
export const coreEffects = [ export const coreEffects = [
EffectsModule.run(CollectionDataEffects), EffectsModule.run(RequestEffects),
EffectsModule.run(ItemDataEffects),
EffectsModule.run(RequestCacheEffects),
EffectsModule.run(ObjectCacheEffects), EffectsModule.run(ObjectCacheEffects),
EffectsModule.run(HrefIndexEffects),
]; ];

View File

@@ -1,15 +1,18 @@
import { NgModule, Optional, SkipSelf, ModuleWithProviders } from '@angular/core'; import { NgModule, Optional, SkipSelf, ModuleWithProviders } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SharedModule } from "../shared/shared.module"; import { SharedModule } from "../shared/shared.module";
import { isNotEmpty } from "../shared/empty.util"; import { isNotEmpty } from "../shared/empty.util";
import { FooterComponent } from "./footer/footer.component"; import { FooterComponent } from "./footer/footer.component";
import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service"; import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service";
import { ObjectCacheService } from "./cache/object-cache.service"; import { ObjectCacheService } from "./cache/object-cache.service";
import { RequestCacheService } from "./cache/request-cache.service"; import { ResponseCacheService } from "./cache/response-cache.service";
import { CollectionDataService } from "./data-services/collection-data.service"; import { CollectionDataService } from "./data/collection-data.service";
import { ItemDataService } from "./data-services/item-data.service"; import { ItemDataService } from "./data/item-data.service";
import { PaginationOptions } from "./shared/pagination-options.model"; import { RequestService } from "./data/request.service";
import { RemoteDataBuildService } from "./cache/builders/remote-data-build.service";
import { CommunityDataService } from "./data/community-data.service";
import { PaginationOptions } from "./cache/models/pagination-options.model";
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -25,12 +28,14 @@ const EXPORTS = [
]; ];
const PROVIDERS = [ const PROVIDERS = [
CommunityDataService,
CollectionDataService, CollectionDataService,
ItemDataService, ItemDataService,
DSpaceRESTv2Service, DSpaceRESTv2Service,
ObjectCacheService, ObjectCacheService,
PaginationOptions, PaginationOptions,
RequestCacheService RequestService,
RemoteDataBuildService
]; ];
@NgModule({ @NgModule({

View File

@@ -1,12 +1,18 @@
import { combineReducers } from "@ngrx/store"; import { combineReducers } from "@ngrx/store";
import { CacheState, cacheReducer } from "./cache/cache.reducers"; import { CacheState, cacheReducer } from "./cache/cache.reducers";
import { IndexState, indexReducer } from "./index/index.reducers";
import { DataState, dataReducer } from "./data/data.reducers";
export interface CoreState { export interface CoreState {
cache: CacheState cache: CacheState,
index: IndexState,
data: DataState
} }
export const reducers = { export const reducers = {
cache: cacheReducer cache: cacheReducer,
index: indexReducer,
data: dataReducer
}; };
export function coreReducer(state: any, action: any) { export function coreReducer(state: any, action: any) {

View File

@@ -1,41 +0,0 @@
import { Inject, Injectable } from "@angular/core";
import { DataEffects } from "./data.effects";
import { Serializer } from "../serializer";
import { Collection } from "../shared/collection.model";
import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer";
import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { Actions, Effect } from "@ngrx/effects";
import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions";
import { CollectionDataService } from "./collection-data.service";
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
@Injectable()
export class CollectionDataEffects extends DataEffects<Collection> {
constructor(
@Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig,
actions$: Actions,
restApi: DSpaceRESTv2Service,
cache: ObjectCacheService,
dataService: CollectionDataService
) {
super(EnvConfig, actions$, restApi, cache, dataService);
}
protected getFindAllEndpoint(action: RequestCacheFindAllAction): string {
return '/collections';
}
protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string {
return `/collections/${action.payload.resourceID}`;
}
protected getSerializer(): Serializer<Collection> {
return new DSpaceRESTv2Serializer(Collection);
}
@Effect() findAll$ = this.findAll;
@Effect() findById$ = this.findById;
}

View File

@@ -1,18 +0,0 @@
import { Injectable, OpaqueToken } from "@angular/core";
import { DataService } from "./data.service";
import { Collection } from "../shared/collection.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheService } from "../cache/request-cache.service";
@Injectable()
export class CollectionDataService extends DataService<Collection> {
serviceName = new OpaqueToken('CollectionDataService');
constructor(
protected objectCache: ObjectCacheService,
protected requestCache: RequestCacheService,
) {
super(Collection);
}
}

View File

@@ -1,68 +0,0 @@
import { Inject } from "@angular/core";
import { Actions } from "@ngrx/effects";
import { Observable } from "rxjs";
import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { ObjectCacheService } from "../cache/object-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer";
import { Serializer } from "../serializer";
import {
RequestCacheActionTypes, RequestCacheFindAllAction, RequestCacheSuccessAction,
RequestCacheErrorAction, RequestCacheFindByIDAction
} from "../cache/request-cache.actions";
import { DataService } from "./data.service";
import { hasNoValue } from "../../shared/empty.util";
import { GlobalConfig } from '../../../config';
export abstract class DataEffects<T extends CacheableObject> {
protected abstract getFindAllEndpoint(action: RequestCacheFindAllAction): string;
protected abstract getFindByIdEndpoint(action: RequestCacheFindByIDAction): string;
protected abstract getSerializer(): Serializer<T>;
constructor(
private EnvConfig: GlobalConfig,
private actions$: Actions,
private restApi: DSpaceRESTv2Service,
private objectCache: ObjectCacheService,
private dataService: DataService<T>
) { }
// TODO, results of a findall aren't retrieved from cache yet
protected findAll = this.actions$
.ofType(RequestCacheActionTypes.FIND_ALL)
.filter((action: RequestCacheFindAllAction) => action.payload.service === this.dataService.serviceName)
.flatMap((action: RequestCacheFindAllAction) => {
//TODO scope, pagination, sorting -> when we know how that works in rest
return this.restApi.get(this.getFindAllEndpoint(action))
.map((data: DSpaceRESTV2Response) => this.getSerializer().deserializeArray(data))
.do((ts: T[]) => {
ts.forEach((t) => {
if (hasNoValue(t) || hasNoValue(t.uuid)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(t, this.EnvConfig.cache.msToLive);
});
})
.map((ts: Array<T>) => ts.map(t => t.uuid))
.map((ids: Array<string>) => new RequestCacheSuccessAction(action.payload.key, ids, new Date().getTime(), this.EnvConfig.cache.msToLive))
.catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message)));
});
protected findById = this.actions$
.ofType(RequestCacheActionTypes.FIND_BY_ID)
.filter((action: RequestCacheFindAllAction) => action.payload.service === this.dataService.serviceName)
.flatMap((action: RequestCacheFindByIDAction) => {
return this.restApi.get(this.getFindByIdEndpoint(action))
.map((data: DSpaceRESTV2Response) => this.getSerializer().deserialize(data))
.do((t: T) => {
if (hasNoValue(t) || hasNoValue(t.uuid)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(t, this.EnvConfig.cache.msToLive);
})
.map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], new Date().getTime(), this.EnvConfig.cache.msToLive))
.catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message)));
});
}

View File

@@ -1,54 +0,0 @@
import { OpaqueToken } from "@angular/core";
import { Observable } from "rxjs";
import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheService } from "../cache/request-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer";
import { ParamHash } from "../shared/param-hash";
import { isNotEmpty } from "../../shared/empty.util";
import { GenericConstructor } from "../shared/generic-constructor";
import { RemoteData } from "./remote-data";
export abstract class DataService<T extends CacheableObject> {
abstract serviceName: OpaqueToken;
protected abstract objectCache: ObjectCacheService;
protected abstract requestCache: RequestCacheService;
constructor(private modelType: GenericConstructor<T>) {
}
findAll(scopeID?: string): RemoteData<Array<T>> {
const key = new ParamHash(this.serviceName, 'findAll', scopeID).toString();
const requestCacheObs = this.requestCache.findAll(key, this.serviceName, scopeID);
return new RemoteData(
requestCacheObs.map(entry => entry.isLoading).distinctUntilChanged(),
requestCacheObs.map(entry => entry.errorMessage).distinctUntilChanged(),
requestCacheObs
.map(entry => entry.resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
// use those IDs to fetch the actual objects from the ObjectCache
return this.objectCache.getList<T>(resourceUUIDs, this.modelType);
}).distinctUntilChanged()
);
}
findById(id: string): RemoteData<T> {
const key = new ParamHash(this.serviceName, 'findById', id).toString();
const requestCacheObs = this.requestCache.findById(key, this.serviceName, id);
return new RemoteData(
requestCacheObs.map(entry => entry.isLoading).distinctUntilChanged(),
requestCacheObs.map(entry => entry.errorMessage).distinctUntilChanged(),
requestCacheObs
.map(entry => entry.resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => {
if (isNotEmpty(resourceUUIDs)) {
return this.objectCache.get<T>(resourceUUIDs[0], this.modelType);
}
else {
return Observable.of(undefined);
}
}).distinctUntilChanged()
);
}
}

View File

@@ -1,41 +0,0 @@
import { Inject, Injectable } from "@angular/core";
import { DataEffects } from "./data.effects";
import { Serializer } from "../serializer";
import { Item } from "../shared/item.model";
import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer";
import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { Actions, Effect } from "@ngrx/effects";
import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions";
import { ItemDataService } from "./item-data.service";
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
@Injectable()
export class ItemDataEffects extends DataEffects<Item> {
constructor(
@Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig,
actions$: Actions,
restApi: DSpaceRESTv2Service,
cache: ObjectCacheService,
dataService: ItemDataService
) {
super(EnvConfig, actions$, restApi, cache, dataService);
}
protected getFindAllEndpoint(action: RequestCacheFindAllAction): string {
return '/items';
}
protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string {
return `/items/${action.payload.resourceID}`;
}
protected getSerializer(): Serializer<Item> {
return new DSpaceRESTv2Serializer(Item);
}
@Effect() findAll$ = this.findAll;
@Effect() findById$ = this.findById;
}

View File

@@ -1,18 +0,0 @@
import { Injectable, OpaqueToken } from "@angular/core";
import { DataService } from "./data.service";
import { Item } from "../shared/item.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheService } from "../cache/request-cache.service";
@Injectable()
export class ItemDataService extends DataService<Item> {
serviceName = new OpaqueToken('ItemDataService');
constructor(
protected objectCache: ObjectCacheService,
protected requestCache: RequestCacheService,
) {
super(Item);
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable } from "@angular/core";
import { DataService } from "./data.service";
import { Collection } from "../shared/collection.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { ResponseCacheService } from "../cache/response-cache.service";
import { Store } from "@ngrx/store";
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";
@Injectable()
export class CollectionDataService extends DataService<NormalizedCollection, Collection> {
protected endpoint = '/collections';
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>
) {
super(NormalizedCollection);
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable } from "@angular/core";
import { DataService } from "./data.service";
import { Community } from "../shared/community.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { ResponseCacheService } from "../cache/response-cache.service";
import { Store } from "@ngrx/store";
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";
@Injectable()
export class CommunityDataService extends DataService<NormalizedCommunity, Community> {
protected endpoint = '/communities';
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>
) {
super(NormalizedCommunity);
}
}

View File

@@ -0,0 +1,14 @@
import { combineReducers } from "@ngrx/store";
import { RequestState, requestReducer } from "./request.reducer";
export interface DataState {
request: RequestState
}
export const reducers = {
request: requestReducer
};
export function dataReducer(state: any, action: any) {
return combineReducers(reducers)(state, action);
}

View File

@@ -0,0 +1,70 @@
import { ObjectCacheService } from "../cache/object-cache.service";
import { ResponseCacheService } from "../cache/response-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer";
import { hasValue } from "../../shared/empty.util";
import { RemoteData } from "./remote-data";
import { FindAllRequest, FindByIDRequest, Request } from "./request.models";
import { Store } from "@ngrx/store";
import { RequestConfigureAction, RequestExecuteAction } from "./request.actions";
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";
export abstract class DataService<TNormalized extends CacheableObject, TDomain> {
protected abstract objectCache: ObjectCacheService;
protected abstract responseCache: ResponseCacheService;
protected abstract requestService: RequestService;
protected abstract rdbService: RemoteDataBuildService;
protected abstract store: Store<CoreState>;
protected abstract endpoint: string;
constructor(private normalizedResourceType: GenericConstructor<TNormalized>) {
}
protected getFindAllHref(scopeID?): string {
let result = this.endpoint;
if (hasValue(scopeID)) {
result += `?scope=${scopeID}`
}
return result;
}
findAll(scopeID?: string): RemoteData<Array<TDomain>> {
const href = this.getFindAllHref(scopeID);
if (!this.responseCache.has(href) && !this.requestService.isPending(href)) {
const request = new FindAllRequest(href, this.normalizedResourceType, scopeID);
this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(href));
}
return this.rdbService.buildList<TNormalized, TDomain>(href, this.normalizedResourceType);
// return this.rdbService.buildList(href);
}
protected getFindByIDHref(resourceID): string {
return `${this.endpoint}/${resourceID}`;
}
findById(id: string): RemoteData<TDomain> {
const href = this.getFindByIDHref(id);
if (!this.objectCache.hasBySelfLink(href) && !this.requestService.isPending(href)) {
const request = new FindByIDRequest(href, this.normalizedResourceType, id);
this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(href));
}
return this.rdbService.buildSingle<TNormalized, TDomain>(href, this.normalizedResourceType);
// return this.rdbService.buildSingle(href);
}
findByHref(href: string): RemoteData<TDomain> {
if (!this.objectCache.hasBySelfLink(href) && !this.requestService.isPending(href)) {
const request = new Request(href, this.normalizedResourceType);
this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(href));
}
return this.rdbService.buildSingle<TNormalized, TDomain>(href, this.normalizedResourceType);
// return this.rdbService.buildSingle(href));
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from "@angular/core";
import { DataService } from "./data.service";
import { Item } from "../shared/item.model";
import { ObjectCacheService } from "../cache/object-cache.service";
import { ResponseCacheService } from "../cache/response-cache.service";
import { Store } from "@ngrx/store";
import { CoreState } from "../core.reducers";
import { NormalizedItem } from "../cache/models/normalized-item.model";
import { RequestService } from "./request.service";
import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service";
@Injectable()
export class ItemDataService extends DataService<NormalizedItem, Item> {
protected endpoint = '/items';
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>
) {
super(NormalizedItem);
}
}

View File

@@ -2,15 +2,12 @@ import { Injectable } from "@angular/core";
import { Actions, Effect } from "@ngrx/effects"; import { Actions, Effect } from "@ngrx/effects";
import { StoreActionTypes } from "../../store.actions"; import { StoreActionTypes } from "../../store.actions";
import { ResetObjectCacheTimestampsAction } from "../cache/object-cache.actions"; import { ResetObjectCacheTimestampsAction } from "../cache/object-cache.actions";
import { Store } from "@ngrx/store";
import { ObjectCacheState } from "../cache/object-cache.reducer";
@Injectable() @Injectable()
export class ObjectCacheEffects { export class ObjectCacheEffects {
constructor( constructor(
private actions$: Actions, private actions$: Actions
private store: Store<ObjectCacheState>
) { } ) { }
/** /**

View File

@@ -1,8 +1,6 @@
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { hasValue } from "../../shared/empty.util";
export enum RemoteDataState { export enum RemoteDataState {
//TODO RequestPending will never happen: implement it in the store & DataEffects.
RequestPending, RequestPending,
ResponsePending, ResponsePending,
Failed, Failed,
@@ -10,12 +8,14 @@ export enum RemoteDataState {
} }
/** /**
* A class to represent the state of * A class to represent the state of a remote resource
*/ */
export class RemoteData<T> { export class RemoteData<T> {
constructor( constructor(
private storeLoading: Observable<boolean>, public self: string,
private requestPending: Observable<boolean>,
private responsePending: Observable<boolean>,
private isSuccessFul: Observable<boolean>,
public errorMessage: Observable<string>, public errorMessage: Observable<string>,
public payload: Observable<T> public payload: Observable<T>
) { ) {
@@ -23,13 +23,17 @@ export class RemoteData<T> {
get state(): Observable<RemoteDataState> { get state(): Observable<RemoteDataState> {
return Observable.combineLatest( return Observable.combineLatest(
this.storeLoading, this.requestPending,
this.errorMessage.map(msg => hasValue(msg)), this.responsePending,
(storeLoading, hasMsg) => { this.isSuccessFul,
if (storeLoading) { (requestPending, responsePending, isSuccessFul) => {
if (requestPending) {
return RemoteDataState.RequestPending
}
else if (responsePending) {
return RemoteDataState.ResponsePending return RemoteDataState.ResponsePending
} }
else if (hasMsg) { else if (!isSuccessFul) {
return RemoteDataState.Failed return RemoteDataState.Failed
} }
else { else {

View File

@@ -1,16 +1,15 @@
import { Injectable } from "@angular/core"; import { Injectable, Inject } from "@angular/core";
import { Actions, Effect } from "@ngrx/effects"; import { Actions, Effect } from "@ngrx/effects";
import { ResetRequestCacheTimestampsAction } from "../cache/request-cache.actions";
import { Store } from "@ngrx/store";
import { RequestCacheState } from "../cache/request-cache.reducer";
import { ObjectCacheActionTypes } from "../cache/object-cache.actions"; import { ObjectCacheActionTypes } from "../cache/object-cache.actions";
import { GlobalConfig, GLOBAL_CONFIG } from "../../../config";
import { ResetResponseCacheTimestampsAction } from "../cache/response-cache.actions";
@Injectable() @Injectable()
export class RequestCacheEffects { export class RequestCacheEffects {
constructor( constructor(
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig,
private actions$: Actions, private actions$: Actions,
private store: Store<RequestCacheState>
) { } ) { }
/** /**
@@ -31,6 +30,5 @@ export class RequestCacheEffects {
*/ */
@Effect() fixTimestampsOnRehydrate = this.actions$ @Effect() fixTimestampsOnRehydrate = this.actions$
.ofType(ObjectCacheActionTypes.RESET_TIMESTAMPS) .ofType(ObjectCacheActionTypes.RESET_TIMESTAMPS)
.map(() => new ResetRequestCacheTimestampsAction(new Date().getTime())); .map(() => new ResetResponseCacheTimestampsAction(new Date().getTime()));
} }

View File

@@ -0,0 +1,59 @@
import { Action } from "@ngrx/store";
import { type } from "../../shared/ngrx/type";
import { CacheableObject } from "../cache/object-cache.reducer";
import { Request } from "./request.models";
/**
* The list of RequestAction type definitions
*/
export const RequestActionTypes = {
CONFIGURE: type('dspace/core/data/request/CONFIGURE'),
EXECUTE: type('dspace/core/data/request/EXECUTE'),
COMPLETE: type('dspace/core/data/request/COMPLETE')
};
export class RequestConfigureAction implements Action {
type = RequestActionTypes.CONFIGURE;
payload: Request<CacheableObject>;
constructor(
request: Request<CacheableObject>
) {
this.payload = request;
}
}
export class RequestExecuteAction implements Action {
type = RequestActionTypes.EXECUTE;
payload: string;
constructor(key: string) {
this.payload = key
}
}
/**
* An ngrx action to indicate a response was returned
*/
export class RequestCompleteAction implements Action {
type = RequestActionTypes.COMPLETE;
payload: string;
/**
* Create a new RequestCompleteAction
*
* @param key
* the key under which this request is stored,
*/
constructor(key: string) {
this.payload = key;
}
}
/**
* A type to encompass all RequestActions
*/
export type RequestAction
= RequestConfigureAction
| RequestExecuteAction
| RequestCompleteAction;

View File

@@ -0,0 +1,71 @@
import { Injectable, Inject } from "@angular/core";
import { Actions, Effect } from "@ngrx/effects";
import { Store } from "@ngrx/store";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model";
import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer";
import { CacheableObject } from "../cache/object-cache.reducer";
import { Observable } from "rxjs";
import { Response, SuccessResponse, ErrorResponse } from "../cache/response-cache.models";
import { hasNoValue } from "../../shared/empty.util";
import { GlobalConfig, GLOBAL_CONFIG } from "../../../config";
import { RequestState, RequestEntry } from "./request.reducer";
import {
RequestActionTypes, RequestExecuteAction,
RequestCompleteAction
} from "./request.actions";
import { ResponseCacheService } from "../cache/response-cache.service";
import { RequestService } from "./request.service";
@Injectable()
export class RequestEffects {
constructor(
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig,
private actions$: Actions,
private restApi: DSpaceRESTv2Service,
private objectCache: ObjectCacheService,
private responseCache: ResponseCacheService,
protected requestService: RequestService,
private store: Store<RequestState>
) { }
@Effect() execute = this.actions$
.ofType(RequestActionTypes.EXECUTE)
.flatMap((action: RequestExecuteAction) => {
return this.requestService.get(action.payload)
.take(1);
})
.flatMap((entry: RequestEntry) => {
const [ifArray, ifNotArray] = this.restApi.get(entry.request.href)
.share() // share ensures restApi.get() doesn't get called twice when the partitions are used below
.partition((data: DSpaceRESTV2Response) => Array.isArray(data._embedded));
return Observable.merge(
ifArray.map((data: DSpaceRESTV2Response) => {
return new DSpaceRESTv2Serializer(entry.request.resourceType).deserializeArray(data);
}).do((cos: CacheableObject[]) => cos.forEach((t) => this.addToObjectCache(t)))
.map((cos: Array<CacheableObject>): Array<string> => cos.map(t => t.uuid)),
ifNotArray.map((data: DSpaceRESTV2Response) => {
return new DSpaceRESTv2Serializer(entry.request.resourceType).deserialize(data);
}).do((co: CacheableObject) => this.addToObjectCache(co))
.map((co: CacheableObject): Array<string> => [co.uuid])
).map((ids: Array<string>) => new SuccessResponse(ids))
.do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((response: Response) => new RequestCompleteAction(entry.request.href))
.catch((error: Error) => Observable.of(new ErrorResponse(error.message))
.do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((response: Response) => new RequestCompleteAction(entry.request.href)));
});
protected addToObjectCache(co: CacheableObject): void {
if (hasNoValue(co) || hasNoValue(co.uuid)) {
throw new Error('The server returned an invalid object');
}
this.objectCache.add(co, this.EnvConfig.cache.msToLive);
}
}

View File

@@ -0,0 +1,32 @@
import { SortOptions } from "../cache/models/sort-options.model";
import { PaginationOptions } from "../cache/models/pagination-options.model";
import { GenericConstructor } from "../shared/generic-constructor";
export class Request<T> {
constructor(
public href: string,
public resourceType: GenericConstructor<T>
) {}
}
export class FindByIDRequest<T> extends Request<T> {
constructor(
href: string,
resourceType: GenericConstructor<T>,
public resourceID: string
) {
super(href, resourceType);
}
}
export class FindAllRequest<T> extends Request<T> {
constructor(
href: string,
resourceType: GenericConstructor<T>,
public scopeID?: string,
public paginationOptions?: PaginationOptions,
public sortOptions?: SortOptions
) {
super(href, resourceType);
}
}

View File

@@ -0,0 +1,81 @@
import { CacheableObject } from "../cache/object-cache.reducer";
import {
RequestActionTypes, RequestAction, RequestConfigureAction,
RequestExecuteAction, RequestCompleteAction
} from "./request.actions";
import { Request } from "./request.models";
export class RequestEntry {
request: Request<CacheableObject>;
requestPending: boolean;
responsePending: boolean;
completed: boolean;
}
export interface RequestState {
[key: string]: RequestEntry
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
export const requestReducer = (state = initialState, action: RequestAction): RequestState => {
switch (action.type) {
case RequestActionTypes.CONFIGURE: {
return configureRequest(state, <RequestConfigureAction> action);
}
case RequestActionTypes.EXECUTE: {
return executeRequest(state, <RequestExecuteAction> action);
}
case RequestActionTypes.COMPLETE: {
return completeRequest(state, <RequestCompleteAction> action);
}
default: {
return state;
}
}
};
function configureRequest(state: RequestState, action: RequestConfigureAction): RequestState {
return Object.assign({}, state, {
[action.payload.href]: {
request: action.payload,
requestPending: true,
responsePending: false,
completed: false
}
});
}
function executeRequest(state: RequestState, action: RequestExecuteAction): RequestState {
return Object.assign({}, state, {
[action.payload]: Object.assign({}, state[action.payload], {
requestPending: false,
responsePending: true
})
});
}
/**
* Update a request with the response
*
* @param state
* the current state
* @param action
* a RequestCompleteAction
* @return RequestState
* the new state, with the response added to the request
*/
function completeRequest(state: RequestState, action: RequestCompleteAction): RequestState {
return Object.assign({}, state, {
[action.payload]: Object.assign({}, state[action.payload], {
responsePending: false,
completed: true
})
});
}

View File

@@ -0,0 +1,48 @@
import { Injectable } from "@angular/core";
import { RequestEntry, RequestState } from "./request.reducer";
import { Store } from "@ngrx/store";
import { Request } from "./request.models";
import { hasValue } from "../../shared/empty.util";
import { Observable } from "rxjs/Observable";
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 { GenericConstructor } from "../shared/generic-constructor";
@Injectable()
export class RequestService {
constructor(
private objectCache: ObjectCacheService,
private responseCache: ResponseCacheService,
private store: Store<RequestState>
) {
}
isPending(href: string): boolean {
let isPending = false;
this.store.select<RequestEntry>('core', 'data', 'request', href)
.take(1)
.subscribe((re: RequestEntry) => {
isPending = (hasValue(re) && !re.completed)
});
return isPending;
}
get(href: string): Observable<RequestEntry> {
return this.store.select<RequestEntry>('core', 'data', 'request', href);
}
configure<T extends CacheableObject>(href: string, normalizedType: GenericConstructor<T>): void {
const isCached = this.objectCache.hasBySelfLink(href);
const isPending = this.isPending(href);
if (!(isCached || isPending)) {
const request = new Request(href, normalizedType);
this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(href));
}
}
}

View File

@@ -140,19 +140,20 @@ describe("DSpaceRESTv2Serializer", () => {
describe("deserializeArray", () => { describe("deserializeArray", () => {
it("should turn a valid document describing a collection of objects in to an array of valid models", () => { //TODO rewrite to incorporate normalisation.
const serializer = new DSpaceRESTv2Serializer(TestModel); // it("should turn a valid document describing a collection of objects in to an array of valid models", () => {
const doc = { // const serializer = new DSpaceRESTv2Serializer(TestModel);
"_embedded": testResponses // const doc = {
}; // "_embedded": testResponses
// };
const models = serializer.deserializeArray(doc); //
// const models = serializer.deserializeArray(doc);
expect(models[0].id).toBe(doc._embedded[0].id); //
expect(models[0].name).toBe(doc._embedded[0].name); // expect(models[0].id).toBe(doc._embedded[0].id);
expect(models[1].id).toBe(doc._embedded[1].id); // expect(models[0].name).toBe(doc._embedded[0].name);
expect(models[1].name).toBe(doc._embedded[1].name); // expect(models[1].id).toBe(doc._embedded[1].id);
}); // expect(models[1].name).toBe(doc._embedded[1].name);
// });
//TODO cant implement/test this yet - depends on how relationships //TODO cant implement/test this yet - depends on how relationships
// will be handled in the rest api // will be handled in the rest api

View File

@@ -55,7 +55,8 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
if (Array.isArray(response._embedded)) { if (Array.isArray(response._embedded)) {
throw new Error('Expected a single model, use deserializeArray() instead'); throw new Error('Expected a single model, use deserializeArray() instead');
} }
return <T> Deserialize(response._embedded, this.modelType); let normalized = Object.assign({}, response._embedded, this.normalizeLinks(response._embedded._links));
return <T> Deserialize(normalized, this.modelType);
} }
/** /**
@@ -70,7 +71,26 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
if (!Array.isArray(response._embedded)) { if (!Array.isArray(response._embedded)) {
throw new Error('Expected an Array, use deserialize() instead'); throw new Error('Expected an Array, use deserialize() instead');
} }
return <Array<T>> Deserialize(response._embedded, this.modelType); let normalized = response._embedded.map((resource) => {
return Object.assign({}, resource, this.normalizeLinks(resource._links));
});
return <Array<T>> Deserialize(normalized, this.modelType);
}
private normalizeLinks(links:any): any {
let normalizedLinks = links;
for (let link in normalizedLinks) {
if (Array.isArray(normalizedLinks[link])) {
normalizedLinks[link] = normalizedLinks[link].map(linkedResource => {
return linkedResource.href;
});
}
else {
normalizedLinks[link] = normalizedLinks[link].href;
}
}
return normalizedLinks;
} }
} }

View File

@@ -10,7 +10,7 @@ import {
DebugElement DebugElement
} from "@angular/core"; } from "@angular/core";
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { TranslateModule, TranslateLoader } from "ng2-translate"; import { TranslateModule, TranslateLoader } from "@ngx-translate/core";
import { Store, StoreModule } from "@ngrx/store"; import { Store, StoreModule } from "@ngrx/store";
// Load the implementations that should be tested // Load the implementations that should be tested
@@ -30,8 +30,10 @@ describe('Footer component', () => {
beforeEach(async(() => { beforeEach(async(() => {
return TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [CommonModule, StoreModule.provideStore({}), TranslateModule.forRoot({ imports: [CommonModule, StoreModule.provideStore({}), TranslateModule.forRoot({
provide: TranslateLoader, loader: {
useClass: MockTranslateLoader provide: TranslateLoader,
useClass: MockTranslateLoader
}
})], })],
declarations: [FooterComponent], // declare the test component declarations: [FooterComponent], // declare the test component
providers: [ providers: [

View File

@@ -0,0 +1,58 @@
import { Action } from "@ngrx/store";
import { type } from "../../shared/ngrx/type";
/**
* The list of HrefIndexAction type definitions
*/
export const HrefIndexActionTypes = {
ADD: type('dspace/core/index/href/ADD'),
REMOVE_UUID: type('dspace/core/index/href/REMOVE_UUID')
};
/**
* An ngrx action to add an href to the index
*/
export class AddToHrefIndexAction implements Action {
type = HrefIndexActionTypes.ADD;
payload: {
href: string;
uuid: string;
};
/**
* Create a new AddToHrefIndexAction
*
* @param href
* the href to add
* @param uuid
* the uuid of the resource the href links to
*/
constructor(href: string, uuid: string) {
this.payload = { href, uuid };
}
}
/**
* An ngrx action to remove an href from the index
*/
export class RemoveUUIDFromHrefIndexAction implements Action {
type = HrefIndexActionTypes.REMOVE_UUID;
payload: string;
/**
* Create a new RemoveUUIDFromHrefIndexAction
*
* @param uuid
* the uuid to remove all hrefs for
*/
constructor(uuid: string) {
this.payload = uuid;
}
}
/**
* A type to encompass all HrefIndexActions
*/
export type HrefIndexAction
= AddToHrefIndexAction
| RemoveUUIDFromHrefIndexAction;

View File

@@ -0,0 +1,32 @@
import { Injectable } from "@angular/core";
import { Effect, Actions } from "@ngrx/effects";
import {
ObjectCacheActionTypes, AddToObjectCacheAction,
RemoveFromObjectCacheAction
} from "../cache/object-cache.actions";
import { AddToHrefIndexAction, RemoveUUIDFromHrefIndexAction } from "./href-index.actions";
import { hasValue } from "../../shared/empty.util";
@Injectable()
export class HrefIndexEffects {
constructor(
private actions$: Actions
) { }
@Effect() add$ = this.actions$
.ofType(ObjectCacheActionTypes.ADD)
.filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache.self))
.map((action: AddToObjectCacheAction) => {
return new AddToHrefIndexAction(
action.payload.objectToCache.self,
action.payload.objectToCache.uuid
);
});
@Effect() remove$ = this.actions$
.ofType(ObjectCacheActionTypes.REMOVE)
.map((action: RemoveFromObjectCacheAction) => {
return new RemoveUUIDFromHrefIndexAction(action.payload);
});
}

View File

@@ -0,0 +1,43 @@
import {
HrefIndexAction, HrefIndexActionTypes, AddToHrefIndexAction,
RemoveUUIDFromHrefIndexAction
} from "./href-index.actions";
export interface HrefIndexState {
[href: string]: string
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState: HrefIndexState = Object.create(null);
export const hrefIndexReducer = (state = initialState, action: HrefIndexAction): HrefIndexState => {
switch (action.type) {
case HrefIndexActionTypes.ADD: {
return addToHrefIndex(state, <AddToHrefIndexAction>action);
}
case HrefIndexActionTypes.REMOVE_UUID: {
return removeUUIDFromHrefIndex(state, <RemoveUUIDFromHrefIndexAction>action)
}
default: {
return state;
}
}
};
function addToHrefIndex(state: HrefIndexState, action: AddToHrefIndexAction): HrefIndexState {
return Object.assign({}, state, {
[action.payload.href]: action.payload.uuid
});
}
function removeUUIDFromHrefIndex(state: HrefIndexState, action: RemoveUUIDFromHrefIndexAction): HrefIndexState {
let newState = Object.create(null);
for (let href in state) {
if (state[href] !== action.payload) {
newState[href] = state[href];
}
}
return newState;
}

View File

@@ -0,0 +1,14 @@
import { combineReducers } from "@ngrx/store";
import { HrefIndexState, hrefIndexReducer } from "./href-index.reducer";
export interface IndexState {
href: HrefIndexState
}
export const reducers = {
href: hrefIndexReducer
};
export function indexReducer(state: any, action: any) {
return combineReducers(reducers)(state, action);
}

View File

@@ -1,8 +1,7 @@
import { inheritSerialization } from "cerialize";
import { DSpaceObject } from "./dspace-object.model"; import { DSpaceObject } from "./dspace-object.model";
import { Bundle } from "./bundle.model"; import { Bundle } from "./bundle.model";
import { RemoteData } from "../data/remote-data";
@inheritSerialization(DSpaceObject)
export class Bitstream extends DSpaceObject { export class Bitstream extends DSpaceObject {
/** /**
@@ -28,10 +27,16 @@ export class Bitstream extends DSpaceObject {
/** /**
* An array of Bundles that are direct parents of this Bitstream * An array of Bundles that are direct parents of this Bitstream
*/ */
parents: Array<Bundle>; parents: Array<RemoteData<Bundle>>;
/** /**
* The Bundle that owns this Bitstream * The Bundle that owns this Bitstream
*/ */
owner: Bundle; owner: Bundle;
/**
* The Bundle that owns this Bitstream
*/
retrieve: string;
} }

View File

@@ -1,23 +1,24 @@
import { inheritSerialization } from "cerialize";
import { DSpaceObject } from "./dspace-object.model"; import { DSpaceObject } from "./dspace-object.model";
import { Bitstream } from "./bitstream.model"; import { Bitstream } from "./bitstream.model";
import { Item } from "./item.model"; import { Item } from "./item.model";
import { RemoteData } from "../data/remote-data";
@inheritSerialization(DSpaceObject)
export class Bundle extends DSpaceObject { export class Bundle extends DSpaceObject {
/** /**
* The primary bitstream of this Bundle * The primary bitstream of this Bundle
*/ */
primaryBitstream: Bitstream; primaryBitstream: RemoteData<Bitstream>;
/** /**
* An array of Items that are direct parents of this Bundle * An array of Items that are direct parents of this Bundle
*/ */
parents: Array<Item>; parents: Array<RemoteData<Item>>;
/** /**
* The Item that owns this Bundle * The Item that owns this Bundle
*/ */
owner: Item; owner: Item;
bitstreams: Array<RemoteData<Bitstream>>
} }

View File

@@ -1,14 +1,13 @@
import { autoserialize, inheritSerialization } from "cerialize";
import { DSpaceObject } from "./dspace-object.model"; import { DSpaceObject } from "./dspace-object.model";
import { Bitstream } from "./bitstream.model"; import { Bitstream } from "./bitstream.model";
import { Item } from "./item.model";
import { RemoteData } from "../data/remote-data";
@inheritSerialization(DSpaceObject)
export class Collection extends DSpaceObject { export class Collection extends DSpaceObject {
/** /**
* A string representing the unique handle of this Collection * A string representing the unique handle of this Collection
*/ */
@autoserialize
handle: string; handle: string;
/** /**
@@ -54,16 +53,18 @@ export class Collection extends DSpaceObject {
/** /**
* The Bitstream that represents the logo of this Collection * The Bitstream that represents the logo of this Collection
*/ */
logo: Bitstream; logo: RemoteData<Bitstream>;
/** /**
* An array of Collections that are direct parents of this Collection * An array of Collections that are direct parents of this Collection
*/ */
parents: Array<Collection>; parents: Array<RemoteData<Collection>>;
/** /**
* The Collection that owns this Collection * The Collection that owns this Collection
*/ */
owner: Collection; owner: Collection;
items: Array<RemoteData<Item>>;
} }

View File

@@ -0,0 +1,62 @@
import { DSpaceObject } from "./dspace-object.model";
import { Bitstream } from "./bitstream.model";
import { Collection } from "./collection.model";
import { RemoteData } from "../data/remote-data";
export class Community extends DSpaceObject {
/**
* A string representing the unique handle of this Community
*/
handle: string;
/**
* The introductory text of this Community
* Corresponds to the metadata field dc.description
*/
get introductoryText(): string {
return this.findMetadata("dc.description");
}
/**
* The short description: HTML
* Corresponds to the metadata field dc.description.abstract
*/
get shortDescription(): string {
return this.findMetadata("dc.description.abstract");
}
/**
* The copyright text of this Community
* Corresponds to the metadata field dc.rights
*/
get copyrightText(): string {
return this.findMetadata("dc.rights");
}
/**
* The sidebar text of this Community
* Corresponds to the metadata field dc.description.tableofcontents
*/
get sidebarText(): string {
return this.findMetadata("dc.description.tableofcontents");
}
/**
* The Bitstream that represents the logo of this Community
*/
logo: RemoteData<Bitstream>;
/**
* An array of Communities that are direct parents of this Community
*/
parents: Array<RemoteData<DSpaceObject>>;
/**
* The Community that owns this Community
*/
owner: Community;
collections: Array<RemoteData<Collection>>;
}

View File

@@ -2,73 +2,94 @@ import { autoserialize, autoserializeAs } from "cerialize";
import { Metadatum } from "./metadatum.model" import { Metadatum } from "./metadatum.model"
import { isEmpty, isNotEmpty } from "../../shared/empty.util"; import { isEmpty, isNotEmpty } from "../../shared/empty.util";
import { CacheableObject } from "../cache/object-cache.reducer"; import { CacheableObject } from "../cache/object-cache.reducer";
import { RemoteData } from "../data/remote-data";
/** /**
* An abstract model class for a DSpaceObject. * An abstract model class for a DSpaceObject.
*/ */
export abstract class DSpaceObject implements CacheableObject { export abstract class DSpaceObject implements CacheableObject {
/** @autoserialize
* The human-readable identifier of this DSpaceObject self: string;
*/
@autoserialize
id: string;
/** /**
* The universally unique identifier of this DSpaceObject * The human-readable identifier of this DSpaceObject
*/ */
@autoserialize @autoserialize
uuid: string; id: string;
/** /**
* A string representing the kind of DSpaceObject, e.g. community, item, … * The universally unique identifier of this DSpaceObject
*/ */
type: string; @autoserialize
uuid: string;
/** /**
* The name for this DSpaceObject * A string representing the kind of DSpaceObject, e.g. community, item, …
*/ */
@autoserialize type: string;
name: string;
/** /**
* An array containing all metadata of this DSpaceObject * The name for this DSpaceObject
*/ */
@autoserializeAs(Metadatum) @autoserialize
metadata: Array<Metadatum>; name: string;
/** /**
* An array of DSpaceObjects that are direct parents of this DSpaceObject * An array containing all metadata of this DSpaceObject
*/ */
parents: Array<DSpaceObject>; @autoserializeAs(Metadatum)
metadata: Array<Metadatum>;
/** /**
* The DSpaceObject that owns this DSpaceObject * An array of DSpaceObjects that are direct parents of this DSpaceObject
*/ */
owner: DSpaceObject; parents: Array<RemoteData<DSpaceObject>>;
/** /**
* Find a metadata field by key and language * The DSpaceObject that owns this DSpaceObject
* */
* This method returns the value of the first element owner: DSpaceObject;
* in the metadata array that matches the provided
* key and language /**
* * Find a metadata field by key and language
* @param key *
* @param language * This method returns the value of the first element
* @return string * in the metadata array that matches the provided
*/ * key and language
findMetadata(key: string, language?: string): string { *
const metadatum = this.metadata * @param key
.find((metadatum: Metadatum) => { * @param language
return metadatum.key === key && * @return string
(isEmpty(language) || metadatum.language === language) */
}); findMetadata(key: string, language?: string): string {
if (isNotEmpty(metadatum)) { const metadatum = this.metadata
return metadatum.value; .find((metadatum: Metadatum) => {
return metadatum.key === key &&
(isEmpty(language) || metadatum.language === language)
});
if (isNotEmpty(metadatum)) {
return metadatum.value;
}
else {
return undefined;
}
} }
else {
return undefined; /**
* Find metadata by an array of keys
*
* This method returns the values of the element
* in the metadata array that match the provided
* key(s)
*
* @param key(s)
* @return Array<Metadatum>
*/
filterMetadata(keys: string[]): Array<Metadatum> {
return this.metadata
.filter((metadatum: Metadatum) => {
return keys.some(key => key === metadatum.key);
});
} }
}
} }

View File

@@ -1,39 +1,80 @@
import { inheritSerialization, autoserialize } from "cerialize";
import { DSpaceObject } from "./dspace-object.model"; import { DSpaceObject } from "./dspace-object.model";
import { Collection } from "./collection.model"; import { Collection } from "./collection.model";
import { RemoteData } from "../data/remote-data";
import { Bundle } from "./bundle.model";
import { Bitstream } from "./bitstream.model";
import { Observable } from "rxjs";
@inheritSerialization(DSpaceObject)
export class Item extends DSpaceObject { export class Item extends DSpaceObject {
/** /**
* A string representing the unique handle of this Item * A string representing the unique handle of this Item
*/ */
@autoserialize handle: string;
handle: string;
/** /**
* The Date of the last modification of this Item * The Date of the last modification of this Item
*/ */
lastModified: Date; lastModified: Date;
/** /**
* A boolean representing if this Item is currently archived or not * A boolean representing if this Item is currently archived or not
*/ */
isArchived: boolean; isArchived: boolean;
/** /**
* A boolean representing if this Item is currently withdrawn or not * A boolean representing if this Item is currently withdrawn or not
*/ */
isWithdrawn: boolean; isWithdrawn: boolean;
/** /**
* An array of Collections that are direct parents of this Item * An array of Collections that are direct parents of this Item
*/ */
parents: Array<Collection>; parents: Array<RemoteData<Collection>>;
/** /**
* The Collection that owns this Item * The Collection that owns this Item
*/ */
owner: Collection; owner: Collection;
bundles: Array<RemoteData<Bundle>>;
getThumbnail(): Observable<Bitstream> {
const bundle: Observable<Bundle> = this.getBundle("THUMBNAIL");
return bundle.flatMap(
bundle => {
if (bundle != null) {
return bundle.primaryBitstream.payload;
}
else {
return Observable.of(undefined);
}
}
);
}
getFiles(): Observable<Array<Observable<Bitstream>>> {
const bundle: Observable <Bundle> = this.getBundle("ORIGINAL");
return bundle.map(bundle => {
if (bundle != null) {
return bundle.bitstreams.map(bitstream => bitstream.payload)
}
});
}
getBundle(name: String): Observable<Bundle> {
return Observable.combineLatest(
...this.bundles.map(b => b.payload),
(...bundles: Array<Bundle>) => bundles)
.map(bundles => {
return bundles.find((bundle: Bundle) => {
return bundle.name === name
});
});
}
getCollections(): Array<Observable<Collection>> {
return this.parents.map(collection => collection.payload.map(parent => parent));
}
} }

View File

@@ -1,58 +0,0 @@
import { ParamHash } from "./param-hash";
describe("ParamHash", () => {
it("should return a hash for a set of parameters", () => {
const hash = new ParamHash('azerty', true, 23).toString();
expect(hash).not.toBeNull();
expect(hash).not.toBe('');
});
it("should work with both simple and complex objects as parameters", () => {
const hash = new ParamHash('azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }).toString();
expect(hash).not.toBeNull();
expect(hash).not.toBe('');
});
it("should work with null or undefined as parameters", () => {
const hash1 = new ParamHash(undefined).toString();
const hash2 = new ParamHash(null).toString();
const hash3 = new ParamHash(undefined, null).toString();
expect(hash1).not.toBeNull();
expect(hash1).not.toBe('');
expect(hash2).not.toBeNull();
expect(hash2).not.toBe('');
expect(hash3).not.toBeNull();
expect(hash3).not.toBe('');
expect(hash1).not.toEqual(hash2);
expect(hash1).not.toEqual(hash3);
expect(hash2).not.toEqual(hash3);
});
it("should work if created without parameters", () => {
const hash1 = new ParamHash().toString();
const hash2 = new ParamHash().toString();
expect(hash1).not.toBeNull();
expect(hash1).not.toBe('');
expect(hash1).toEqual(hash2);
});
it("should create the same hash if created with the same set of parameters in the same order", () => {
const params = ['azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }];
const hash1 = new ParamHash(...params).toString();
const hash2 = new ParamHash(...params).toString();
expect(hash1).toEqual(hash2);
});
it("should create a different hash if created with the same set of parameters in a different order", () => {
const params = ['azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }];
const hash1 = new ParamHash(...params).toString();
const hash2 = new ParamHash(...params.reverse()).toString();
expect(hash1).not.toEqual(hash2);
});
});

View File

@@ -1,35 +0,0 @@
import { Md5 } from "ts-md5/dist/md5";
/**
* Creates a hash of a set of parameters
*/
export class ParamHash {
private params: Array<any>;
constructor(...params) {
this.params = params;
}
/**
* Returns an md5 hash based on the
* params passed to the constructor
*
* If you hash the same set of params in the
* same order the hashes will be identical
*
* @return {string}
* an md5 hash
*/
toString(): string {
let hash = new Md5();
this.params.forEach((param) => {
if (param === Object(param)) {
hash.appendStr(JSON.stringify(param));
}
else {
hash.appendStr('' + param);
}
});
return hash.end().toString();
}
}

View File

@@ -8,7 +8,7 @@ import { GlobalConfig } from "../../../config";
* TODO write tests once GlobalConfig becomes injectable * TODO write tests once GlobalConfig becomes injectable
*/ */
export class UIURLCombiner extends URLCombiner{ export class UIURLCombiner extends URLCombiner{
constructor(...parts:Array<string>) { constructor(EnvConfig: GlobalConfig, ...parts: Array<string>) {
super(GlobalConfig.ui.baseURL, GlobalConfig.ui.nameSpace, ...parts); super(EnvConfig.ui.baseUrl, EnvConfig.ui.nameSpace, ...parts);
} }
} }

View File

@@ -5,7 +5,7 @@ import { Store, StoreModule } from "@ngrx/store";
import { HeaderState } from "./header.reducer"; import { HeaderState } from "./header.reducer";
import Spy = jasmine.Spy; import Spy = jasmine.Spy;
import { HeaderToggleAction } from "./header.actions"; import { HeaderToggleAction } from "./header.actions";
import { TranslateModule } from "ng2-translate"; import { TranslateModule } from "@ngx-translate/core";
import { NgbCollapseModule } from "@ng-bootstrap/ng-bootstrap"; import { NgbCollapseModule } from "@ng-bootstrap/ng-bootstrap";
import { Observable } from "rxjs"; import { Observable } from "rxjs";

View File

@@ -0,0 +1,19 @@
<!--.row to offset the app component's .container-fluid padding-->
<div class="row">
<div class="jumbotron jumbotron-fluid">
<div class="container-fluid">
<h1 class="display-3">Welcome to DSpace</h1>
<p class="lead">DSpace is an open source software platform that enables organisations to:</p>
<ul>
<li>capture and describe digital material using a submission workflow module, or a variety
of
programmatic ingest options
</li>
<li>distribute an organisation's digital assets over the web through a search and retrieval
system
</li>
<li>preserve digital assets over the long term</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,19 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'ds-home-news',
styleUrls: ['./home-news.component.css'],
templateUrl: './home-news.component.html'
})
export class HomeNewsComponent implements OnInit {
constructor() {
this.universalInit();
}
universalInit() {
}
ngOnInit(): void {
}
}

View File

@@ -1,3 +1,2 @@
<div class="home"> <ds-home-news></ds-home-news>
Home component <ds-top-level-community-list></ds-top-level-community-list>
</div>

View File

@@ -1,16 +1,11 @@
import { Component, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; import { Component, OnInit } from '@angular/core';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.Emulated,
selector: 'ds-home', selector: 'ds-home',
styleUrls: ['./home.component.css'], styleUrls: ['./home.component.css'],
templateUrl: './home.component.html' templateUrl: './home.component.html'
}) })
export class HomeComponent { export class HomeComponent implements OnInit {
data: any = {};
constructor() { constructor() {
this.universalInit(); this.universalInit();
} }
@@ -19,4 +14,6 @@ export class HomeComponent {
} }
ngOnInit(): void {
}
} }

View File

@@ -2,13 +2,23 @@ import { NgModule } from '@angular/core';
import { HomeComponent } from './home.component'; import { HomeComponent } from './home.component';
import { HomeRoutingModule } from './home-routing.module'; import { HomeRoutingModule } from './home-routing.module';
import { CommonModule } from "@angular/common";
import { TopLevelCommunityListComponent } from "./top-level-community-list/top-level-community-list.component";
import { HomeNewsComponent } from "./home-news/home-news.component";
import { RouterModule } from "@angular/router";
import { TranslateModule } from "@ngx-translate/core";
@NgModule({ @NgModule({
imports: [ imports: [
HomeRoutingModule CommonModule,
HomeRoutingModule,
RouterModule,
TranslateModule
], ],
declarations: [ declarations: [
HomeComponent HomeComponent,
TopLevelCommunityListComponent,
HomeNewsComponent
] ]
}) })
export class HomeModule { } export class HomeModule { }

View File

@@ -0,0 +1,12 @@
<div *ngIf="topLevelCommunities.hasSucceeded | async">
<h2>{{'home.top-level-communities.head' | translate}}</h2>
<p class="lead">{{'home.top-level-communities.help' | translate}}</p>
<ul>
<li *ngFor="let community of (topLevelCommunities.payload | async)">
<p>
<span class="lead"><a [routerLink]="['/communities', community.id]">{{community.name}}</a></span><br>
<span class="text-muted">{{community.shortDescription}}</span>
</p>
</li>
</ul>
</div>

View File

@@ -0,0 +1,27 @@
import { Component, OnInit } from '@angular/core';
import { CommunityDataService } from "../../core/data/community-data.service";
import { RemoteData } from "../../core/data/remote-data";
import { Community } from "../../core/shared/community.model";
@Component({
selector: 'ds-top-level-community-list',
styleUrls: ['./top-level-community-list.component.css'],
templateUrl: './top-level-community-list.component.html'
})
export class TopLevelCommunityListComponent implements OnInit {
topLevelCommunities: RemoteData<Community[]>;
constructor(
private cds: CommunityDataService
) {
this.universalInit();
}
universalInit() {
}
ngOnInit(): void {
this.topLevelCommunities = this.cds.findAll();
}
}

View File

@@ -0,0 +1,7 @@
<ds-metadata-field-wrapper [label]="label | translate">
<div class="collections">
<a *ngFor="let collection of collections; let last=last;" [href]="(collection | async)?.self">
<span>{{(collection | async)?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
</a>
</div>
</ds-metadata-field-wrapper>

View File

@@ -0,0 +1,34 @@
import { Component, Input, OnInit } from '@angular/core';
import { Collection } from "../../core/shared/collection.model";
import { Observable } from "rxjs";
import { Item } from "../../core/shared/item.model";
@Component({
selector: 'ds-item-page-collections',
templateUrl: './collections.component.html'
})
export class CollectionsComponent implements OnInit {
@Input() item: Item;
label : string = "item.page.collections";
separator: string = "<br/>"
collections: Array<Observable<Collection>>;
constructor() {
this.universalInit();
}
universalInit() {
}
ngOnInit(): void {
this.collections = this.item.getCollections();
}
}

View File

@@ -0,0 +1,9 @@
<ds-metadata-field-wrapper [label]="label | translate">
<div class="file-section">
<a *ngFor="let file of (files | async); let last=last;" [href]="(file | async)?.retrieve">
<span>{{(file | async)?.name}}</span>
<span>({{((file | async)?.size) | dsFileSize }})</span>
<span *ngIf="!last" innerHTML="{{separator}}"></span>
</a>
</div>
</ds-metadata-field-wrapper>

View File

@@ -0,0 +1,33 @@
import { Component, Input, OnInit } from '@angular/core';
import { Bitstream } from "../../core/shared/bitstream.model";
import { Item } from "../../core/shared/item.model";
import { Observable } from "rxjs";
@Component({
selector: 'ds-item-page-file-section',
templateUrl: './file-section.component.html'
})
export class FileSectionComponent implements OnInit {
@Input() item: Item;
label : string = "item.page.files";
separator: string = "<br/>"
files: Observable<Array<Observable<Bitstream>>>;
constructor() {
this.universalInit();
}
universalInit() {
}
ngOnInit(): void {
this.files = this.item.getFiles();
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ItemPageComponent } from './item-page.component';
@NgModule({
imports: [
RouterModule.forChild([
{ path: 'items/:id', pathMatch: 'full', component: ItemPageComponent },
])
]
})
export class ItemPageRoutingModule {
}

View File

@@ -0,0 +1,19 @@
<div class="item-page" *ngIf="item.hasSucceeded | async">
<ds-item-page-title-field [item]="item.payload | async"></ds-item-page-title-field>
<div class="row">
<div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper>
<ds-item-page-file-section [item]="item.payload | async"></ds-item-page-file-section>
<ds-item-page-date-field [item]="item.payload | async"></ds-item-page-date-field>
<ds-item-page-author-field [item]="item.payload | async"></ds-item-page-author-field>
</div>
<div class="col-xs-12 col-md-6">
<ds-item-page-abstract-field
[item]="item.payload | async"></ds-item-page-abstract-field>
<ds-item-page-uri-field [item]="item.payload | async"></ds-item-page-uri-field>
<ds-item-page-collections [item]="item.payload | async"></ds-item-page-collections>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,41 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Item } from "../core/shared/item.model";
import { ItemDataService } from "../core/data/item-data.service";
import { RemoteData } from "../core/data/remote-data";
import { Observable } from "rxjs";
import { Bitstream } from "../core/shared/bitstream.model";
@Component({
selector: 'ds-item-page',
styleUrls: ['./item-page.component.css'],
templateUrl: './item-page.component.html',
})
export class ItemPageComponent implements OnInit {
id: number;
private sub: any;
item: RemoteData<Item>;
thumbnail: Observable<Bitstream>;
constructor(private route: ActivatedRoute, private items: ItemDataService) {
this.universalInit();
}
universalInit() {
}
ngOnInit(): void {
this.sub = this.route.params.subscribe(params => {
this.id = +params['id'];
this.item = this.items.findById(params['id']);
this.thumbnail = this.item.payload.flatMap(i => i.getThumbnail());
});
}
}

View File

@@ -0,0 +1,40 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ItemPageComponent } from './item-page.component';
import { ItemPageRoutingModule } from './item-page-routing.module';
import { MetadataValuesComponent } from './metadata-values/metadata-values.component';
import { MetadataUriValuesComponent } from './metadata-uri-values/metadata-uri-values.component';
import { MetadataFieldWrapperComponent } from './metadata-field-wrapper/metadata-field-wrapper.component';
import { ItemPageAuthorFieldComponent } from './specific-field/author/item-page-author-field.component';
import { ItemPageDateFieldComponent } from './specific-field/date/item-page-date-field.component';
import { ItemPageAbstractFieldComponent } from './specific-field/abstract/item-page-abstract-field.component';
import { ItemPageUriFieldComponent } from './specific-field/uri/item-page-uri-field.component';
import { ItemPageTitleFieldComponent } from './specific-field/title/item-page-title-field.component';
import { ItemPageSpecificFieldComponent } from './specific-field/item-page-specific-field.component';
import { SharedModule } from './../shared/shared.module';
import { FileSectionComponent } from "./file-section/file-section.component";
import { CollectionsComponent } from "./collections/collections.component";
@NgModule({
declarations: [
ItemPageComponent,
MetadataValuesComponent,
MetadataUriValuesComponent,
MetadataFieldWrapperComponent,
ItemPageAuthorFieldComponent,
ItemPageDateFieldComponent,
ItemPageAbstractFieldComponent,
ItemPageUriFieldComponent,
ItemPageTitleFieldComponent,
ItemPageSpecificFieldComponent,
FileSectionComponent,
CollectionsComponent
],
imports: [
ItemPageRoutingModule,
CommonModule,
SharedModule
]
})
export class ItemPageModule {
}

View File

@@ -0,0 +1,6 @@
<div class="simple-view-element">
<h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5>
<div class="simple-view-element-body">
<ng-content></ng-content>
</div>
</div>

View File

@@ -0,0 +1,6 @@
@import '../../../styles/variables.scss';
:host {
.simple-view-element {
margin-bottom: 15px;
}
}

View File

@@ -0,0 +1,21 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-metadata-field-wrapper',
styleUrls: ['./metadata-field-wrapper.component.css'],
templateUrl: './metadata-field-wrapper.component.html'
})
export class MetadataFieldWrapperComponent {
@Input() label: string;
constructor() {
this.universalInit();
}
universalInit() {
}
}

View File

@@ -0,0 +1,5 @@
<ds-metadata-field-wrapper [label]="label | translate">
<a *ngFor="let metadatum of values; let last=last;" [href]="metadatum.value">
{{ linktext || metadatum.value }}<span *ngIf="!last" [innerHTML]="separator"></span>
</a>
</ds-metadata-field-wrapper>

View File

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

View File

@@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
import { MetadataValuesComponent } from "../metadata-values/metadata-values.component";
@Component({
selector: 'ds-metadata-uri-values',
styleUrls: ['./metadata-uri-values.component.css'],
templateUrl: './metadata-uri-values.component.html'
})
export class MetadataUriValuesComponent extends MetadataValuesComponent {
@Input() linktext: any;
@Input() values: any;
@Input() separator: string;
@Input() label: string;
}

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