mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
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:
@@ -7,7 +7,7 @@ dspace-angular
|
||||
|
||||
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)
|
||||
|
||||
|
@@ -12,8 +12,8 @@ describe('protractor App', function() {
|
||||
expect(page.getPageTitleText()).toEqual('DSpace');
|
||||
});
|
||||
|
||||
it('should display title "Hello, World!"', () => {
|
||||
it('should display header "Welcome to DSpace"', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getFirstPText()).toEqual('Hello, World!');
|
||||
expect(page.getFirstHeaderText()).toEqual('Welcome to DSpace');
|
||||
});
|
||||
});
|
||||
|
@@ -12,4 +12,8 @@ export class ProtractorPage {
|
||||
getFirstPText() {
|
||||
return element(by.xpath('//p[1]')).getText();
|
||||
}
|
||||
}
|
||||
|
||||
getFirstHeaderText() {
|
||||
return element(by.xpath('//h1[1]')).getText();
|
||||
}
|
||||
}
|
||||
|
10
package.json
10
package.json
@@ -78,19 +78,21 @@
|
||||
"@angular/upgrade": "2.2.3",
|
||||
"@angularclass/bootloader": "1.0.1",
|
||||
"@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/effects": "^2.0.0",
|
||||
"@ngrx/router-store": "^1.2.5",
|
||||
"@ngrx/store": "^2.2.1",
|
||||
"@ngrx/store-devtools": "^3.2.2",
|
||||
"@ngx-translate/core": "^6.0.1",
|
||||
"@ngx-translate/http-loader": "^0.0.3",
|
||||
"@types/jsonschema": "0.0.5",
|
||||
"angular2-express-engine": "2.1.0-rc.1",
|
||||
"angular2-platform-node": "2.1.0-rc.1",
|
||||
"angular2-universal": "2.1.0-rc.1",
|
||||
"angular2-universal-polyfills": "2.1.0-rc.1",
|
||||
"body-parser": "1.15.2",
|
||||
"bootstrap": "4.0.0-alpha.6",
|
||||
"bootstrap": "4.0.0-alpha.5",
|
||||
"cerialize": "^0.1.13",
|
||||
"compression": "1.6.2",
|
||||
"express": "4.14.0",
|
||||
@@ -100,9 +102,8 @@
|
||||
"jsonschema": "^1.1.1",
|
||||
"methods": "1.1.2",
|
||||
"morgan": "1.7.0",
|
||||
"ng2-pagination": "^2.0.0",
|
||||
"ng2-translate": "4.2.0",
|
||||
"preboot": "4.5.2",
|
||||
"reflect-metadata": "^0.1.10",
|
||||
"rxjs": "5.0.0-beta.12",
|
||||
"ts-md5": "^1.2.0",
|
||||
"webfontloader": "1.6.27",
|
||||
@@ -160,7 +161,6 @@
|
||||
"protractor": "~4.0.14",
|
||||
"protractor-istanbul-plugin": "~2.0.0",
|
||||
"raw-loader": "0.5.1",
|
||||
"reflect-metadata": "0.1.8",
|
||||
"rimraf": "2.5.4",
|
||||
"rollup": "0.37.0",
|
||||
"rollup-plugin-commonjs": "6.0.0",
|
||||
|
@@ -1,16 +1,21 @@
|
||||
{
|
||||
"example": {
|
||||
"with": {
|
||||
"data": "{{greeting}}, {{recipient}}!"
|
||||
}
|
||||
},
|
||||
|
||||
"footer": {
|
||||
"copyright": "copyright © 2002-{{ year }}",
|
||||
"link.dspace": "DSpace software",
|
||||
"link.duraspace": "DuraSpace"
|
||||
},
|
||||
|
||||
"item": {
|
||||
"page": {
|
||||
"author": "Author",
|
||||
"abstract": "Abstract",
|
||||
"date": "Date",
|
||||
"uri": "URI",
|
||||
"files": "Files",
|
||||
"collections": "Collections"
|
||||
}
|
||||
},
|
||||
|
||||
"nav": {
|
||||
"home": "Home"
|
||||
},
|
||||
@@ -31,5 +36,12 @@
|
||||
"link": {
|
||||
"home-page": "Take me to the home page"
|
||||
}
|
||||
},
|
||||
|
||||
"home": {
|
||||
"top-level-communities": {
|
||||
"head": "Communities in DSpace",
|
||||
"help": "Select a community to browse its collections."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,25 +4,6 @@
|
||||
|
||||
<main class="main-content">
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
|
@@ -15,11 +15,3 @@
|
||||
.main-content {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
h2.red {
|
||||
color: red;
|
||||
}
|
||||
|
||||
h2.green {
|
||||
color: green;
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ import {
|
||||
DebugElement
|
||||
} from "@angular/core";
|
||||
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";
|
||||
|
||||
// Load the implementations that should be tested
|
||||
@@ -34,8 +34,10 @@ describe('App component', () => {
|
||||
beforeEach(async(() => {
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [CommonModule, StoreModule.provideStore({}), TranslateModule.forRoot({
|
||||
provide: TranslateLoader,
|
||||
useClass: MockTranslateLoader
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: MockTranslateLoader
|
||||
}
|
||||
})],
|
||||
declarations: [AppComponent], // declare the test component
|
||||
providers: [
|
||||
@@ -52,8 +54,8 @@ describe('App component', () => {
|
||||
|
||||
comp = fixture.componentInstance; // component test instance
|
||||
|
||||
// query for the title <p> by CSS element selector
|
||||
de = fixture.debugElement.query(By.css('p'));
|
||||
// query for the <div class="outer-wrapper"> by CSS element selector
|
||||
de = fixture.debugElement.query(By.css('div.outer-wrapper'));
|
||||
el = de.nativeElement;
|
||||
});
|
||||
|
||||
|
@@ -3,17 +3,14 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Inject,
|
||||
ViewEncapsulation,
|
||||
OnDestroy,
|
||||
OnInit, HostListener
|
||||
} from "@angular/core";
|
||||
import { TranslateService } from "ng2-translate";
|
||||
import { TranslateService } from "@ngx-translate/core";
|
||||
import { HostWindowState } from "./shared/host-window.reducer";
|
||||
import { Store } from "@ngrx/store";
|
||||
import { HostWindowResizeAction } from "./shared/host-window.actions";
|
||||
|
||||
import { PaginationOptions } from './core/shared/pagination-options.model';
|
||||
|
||||
import { GLOBAL_CONFIG, GlobalConfig } from '../config';
|
||||
import { EnvConfig, GLOBAL_CONFIG, GlobalConfig } from '../config';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.Default,
|
||||
@@ -22,16 +19,7 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../config';
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css']
|
||||
})
|
||||
export class AppComponent implements OnDestroy, OnInit {
|
||||
private translateSubscription: any;
|
||||
|
||||
collection = [];
|
||||
example: string;
|
||||
options: PaginationOptions = new PaginationOptions();
|
||||
data: any = {
|
||||
greeting: 'Hello',
|
||||
recipient: 'World'
|
||||
};
|
||||
export class AppComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
@Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
|
||||
@@ -42,26 +30,12 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
translate.setDefaultLang('en');
|
||||
// the lang to use, if the lang isn't available, it will use the current loader to get them
|
||||
translate.use('en');
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
this.collection.push(`item ${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.translateSubscription = this.translate.get('example.with.data', { greeting: 'Hello', recipient: 'DSpace' }).subscribe((translation: string) => {
|
||||
this.example = translation;
|
||||
});
|
||||
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();
|
||||
}
|
||||
const env: string = EnvConfig.production ? "Production" : "Development";
|
||||
const color: string = EnvConfig.production ? "red" : "green";
|
||||
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
|
||||
}
|
||||
|
||||
@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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';
|
||||
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { HomeModule } from './home/home.module';
|
||||
import { ItemPageModule } from './item-page/item-page.module';
|
||||
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
|
||||
@@ -10,15 +11,17 @@ import { AppComponent } from './app.component';
|
||||
import { HeaderComponent } from './header/header.component';
|
||||
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
HeaderComponent,
|
||||
PageNotFoundComponent
|
||||
PageNotFoundComponent,
|
||||
],
|
||||
imports: [
|
||||
SharedModule,
|
||||
HomeModule,
|
||||
ItemPageModule,
|
||||
CoreModule.forRoot(),
|
||||
AppRoutingModule
|
||||
],
|
||||
|
41
src/app/core/cache/builders/build-decorators.ts
vendored
Normal file
41
src/app/core/cache/builders/build-decorators.ts
vendored
Normal 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);
|
||||
};
|
156
src/app/core/cache/builders/remote-data-build.service.ts
vendored
Normal file
156
src/app/core/cache/builders/remote-data-build.service.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
6
src/app/core/cache/cache.reducers.ts
vendored
6
src/app/core/cache/cache.reducers.ts
vendored
@@ -1,14 +1,14 @@
|
||||
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";
|
||||
|
||||
export interface CacheState {
|
||||
request: RequestCacheState,
|
||||
response: ResponseCacheState,
|
||||
object: ObjectCacheState
|
||||
}
|
||||
|
||||
export const reducers = {
|
||||
request: requestCacheReducer,
|
||||
response: responseCacheReducer,
|
||||
object: objectCacheReducer
|
||||
};
|
||||
|
||||
|
43
src/app/core/cache/models/normalized-bitstream.model.ts
vendored
Normal file
43
src/app/core/cache/models/normalized-bitstream.model.ts
vendored
Normal 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;
|
||||
}
|
30
src/app/core/cache/models/normalized-bundle.model.ts
vendored
Normal file
30
src/app/core/cache/models/normalized-bundle.model.ts
vendored
Normal 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>;
|
||||
}
|
36
src/app/core/cache/models/normalized-collection.model.ts
vendored
Normal file
36
src/app/core/cache/models/normalized-collection.model.ts
vendored
Normal 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>;
|
||||
|
||||
}
|
36
src/app/core/cache/models/normalized-community.model.ts
vendored
Normal file
36
src/app/core/cache/models/normalized-community.model.ts
vendored
Normal 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>;
|
||||
|
||||
}
|
33
src/app/core/cache/models/normalized-dspace-object-factory.ts
vendored
Normal file
33
src/app/core/cache/models/normalized-dspace-object-factory.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
7
src/app/core/cache/models/normalized-dspace-object-type.ts
vendored
Normal file
7
src/app/core/cache/models/normalized-dspace-object-type.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum NormalizedDSOType {
|
||||
NormalizedBitstream,
|
||||
NormalizedBundle,
|
||||
NormalizedItem,
|
||||
NormalizedCollection,
|
||||
NormalizedCommunity
|
||||
}
|
52
src/app/core/cache/models/normalized-dspace-object.model.ts
vendored
Normal file
52
src/app/core/cache/models/normalized-dspace-object.model.ts
vendored
Normal 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;
|
||||
}
|
47
src/app/core/cache/models/normalized-item.model.ts
vendored
Normal file
47
src/app/core/cache/models/normalized-item.model.ts
vendored
Normal 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>;
|
||||
}
|
10
src/app/core/cache/models/self-link.model.ts
vendored
Normal file
10
src/app/core/cache/models/self-link.model.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { autoserialize } from "cerialize";
|
||||
|
||||
export class SelfLink {
|
||||
|
||||
@autoserialize
|
||||
self: string;
|
||||
|
||||
@autoserialize
|
||||
uuid: string;
|
||||
}
|
1
src/app/core/cache/object-cache.reducer.ts
vendored
1
src/app/core/cache/object-cache.reducer.ts
vendored
@@ -12,6 +12,7 @@ import { CacheEntry } from "./cache-entry";
|
||||
*/
|
||||
export interface CacheableObject {
|
||||
uuid: string;
|
||||
self?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
24
src/app/core/cache/object-cache.service.ts
vendored
24
src/app/core/cache/object-cache.service.ts
vendored
@@ -60,6 +60,11 @@ export class ObjectCacheService {
|
||||
.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
|
||||
* with the specified UUIDs
|
||||
@@ -104,6 +109,25 @@ export class ObjectCacheService {
|
||||
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
|
||||
*
|
||||
|
205
src/app/core/cache/request-cache.actions.ts
vendored
205
src/app/core/cache/request-cache.actions.ts
vendored
@@ -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;
|
240
src/app/core/cache/request-cache.reducer.spec.ts
vendored
240
src/app/core/cache/request-cache.reducer.spec.ts
vendored
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
});
|
212
src/app/core/cache/request-cache.reducer.ts
vendored
212
src/app/core/cache/request-cache.reducer.ts
vendored
@@ -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;
|
||||
}
|
160
src/app/core/cache/request-cache.service.spec.ts
vendored
160
src/app/core/cache/request-cache.service.spec.ts
vendored
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
138
src/app/core/cache/request-cache.service.ts
vendored
138
src/app/core/cache/request-cache.service.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
69
src/app/core/cache/response-cache.actions.ts
vendored
Normal file
69
src/app/core/cache/response-cache.actions.ts
vendored
Normal 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;
|
16
src/app/core/cache/response-cache.models.ts
vendored
Normal file
16
src/app/core/cache/response-cache.models.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
|
225
src/app/core/cache/response-cache.reducer.spec.ts
vendored
Normal file
225
src/app/core/cache/response-cache.reducer.spec.ts
vendored
Normal 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);
|
||||
// });
|
||||
// });
|
||||
//
|
||||
// });
|
||||
//
|
||||
//
|
||||
// });
|
112
src/app/core/cache/response-cache.reducer.ts
vendored
Normal file
112
src/app/core/cache/response-cache.reducer.ts
vendored
Normal 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;
|
||||
}
|
146
src/app/core/cache/response-cache.service.spec.ts
vendored
Normal file
146
src/app/core/cache/response-cache.service.spec.ts
vendored
Normal 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);
|
||||
// });
|
||||
// });
|
||||
// });
|
89
src/app/core/cache/response-cache.service.ts
vendored
Normal file
89
src/app/core/cache/response-cache.service.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -1,12 +1,11 @@
|
||||
import { EffectsModule } from "@ngrx/effects";
|
||||
import { CollectionDataEffects } from "./data-services/collection-data.effects";
|
||||
import { ItemDataEffects } from "./data-services/item-data.effects";
|
||||
import { ObjectCacheEffects } from "./data-services/object-cache.effects";
|
||||
import { RequestCacheEffects } from "./data-services/request-cache.effects";
|
||||
import { ObjectCacheEffects } from "./data/object-cache.effects";
|
||||
import { RequestCacheEffects } from "./data/request-cache.effects";
|
||||
import { HrefIndexEffects } from "./index/href-index.effects";
|
||||
import { RequestEffects } from "./data/request.effects";
|
||||
|
||||
export const coreEffects = [
|
||||
EffectsModule.run(CollectionDataEffects),
|
||||
EffectsModule.run(ItemDataEffects),
|
||||
EffectsModule.run(RequestCacheEffects),
|
||||
EffectsModule.run(RequestEffects),
|
||||
EffectsModule.run(ObjectCacheEffects),
|
||||
EffectsModule.run(HrefIndexEffects),
|
||||
];
|
||||
|
@@ -1,15 +1,18 @@
|
||||
import { NgModule, Optional, SkipSelf, ModuleWithProviders } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { SharedModule } from "../shared/shared.module";
|
||||
|
||||
import { isNotEmpty } from "../shared/empty.util";
|
||||
import { FooterComponent } from "./footer/footer.component";
|
||||
import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service";
|
||||
import { ObjectCacheService } from "./cache/object-cache.service";
|
||||
import { RequestCacheService } from "./cache/request-cache.service";
|
||||
import { CollectionDataService } from "./data-services/collection-data.service";
|
||||
import { ItemDataService } from "./data-services/item-data.service";
|
||||
import { PaginationOptions } from "./shared/pagination-options.model";
|
||||
import { ResponseCacheService } from "./cache/response-cache.service";
|
||||
import { CollectionDataService } from "./data/collection-data.service";
|
||||
import { ItemDataService } from "./data/item-data.service";
|
||||
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 = [
|
||||
CommonModule,
|
||||
@@ -25,12 +28,14 @@ const EXPORTS = [
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
CommunityDataService,
|
||||
CollectionDataService,
|
||||
ItemDataService,
|
||||
DSpaceRESTv2Service,
|
||||
ObjectCacheService,
|
||||
PaginationOptions,
|
||||
RequestCacheService
|
||||
RequestService,
|
||||
RemoteDataBuildService
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@@ -1,12 +1,18 @@
|
||||
import { combineReducers } from "@ngrx/store";
|
||||
import { CacheState, cacheReducer } from "./cache/cache.reducers";
|
||||
import { IndexState, indexReducer } from "./index/index.reducers";
|
||||
import { DataState, dataReducer } from "./data/data.reducers";
|
||||
|
||||
export interface CoreState {
|
||||
cache: CacheState
|
||||
cache: CacheState,
|
||||
index: IndexState,
|
||||
data: DataState
|
||||
}
|
||||
|
||||
export const reducers = {
|
||||
cache: cacheReducer
|
||||
cache: cacheReducer,
|
||||
index: indexReducer,
|
||||
data: dataReducer
|
||||
};
|
||||
|
||||
export function coreReducer(state: any, action: any) {
|
||||
|
@@ -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;
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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)));
|
||||
});
|
||||
|
||||
}
|
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
26
src/app/core/data/collection-data.service.ts
Normal file
26
src/app/core/data/collection-data.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
26
src/app/core/data/community-data.service.ts
Normal file
26
src/app/core/data/community-data.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
14
src/app/core/data/data.reducers.ts
Normal file
14
src/app/core/data/data.reducers.ts
Normal 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);
|
||||
}
|
70
src/app/core/data/data.service.ts
Normal file
70
src/app/core/data/data.service.ts
Normal 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));
|
||||
}
|
||||
|
||||
}
|
25
src/app/core/data/item-data.service.ts
Normal file
25
src/app/core/data/item-data.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@@ -2,15 +2,12 @@ import { Injectable } from "@angular/core";
|
||||
import { Actions, Effect } from "@ngrx/effects";
|
||||
import { StoreActionTypes } from "../../store.actions";
|
||||
import { ResetObjectCacheTimestampsAction } from "../cache/object-cache.actions";
|
||||
import { Store } from "@ngrx/store";
|
||||
import { ObjectCacheState } from "../cache/object-cache.reducer";
|
||||
|
||||
@Injectable()
|
||||
export class ObjectCacheEffects {
|
||||
|
||||
constructor(
|
||||
private actions$: Actions,
|
||||
private store: Store<ObjectCacheState>
|
||||
private actions$: Actions
|
||||
) { }
|
||||
|
||||
/**
|
@@ -1,8 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { hasValue } from "../../shared/empty.util";
|
||||
|
||||
export enum RemoteDataState {
|
||||
//TODO RequestPending will never happen: implement it in the store & DataEffects.
|
||||
RequestPending,
|
||||
ResponsePending,
|
||||
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> {
|
||||
|
||||
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 payload: Observable<T>
|
||||
) {
|
||||
@@ -23,13 +23,17 @@ export class RemoteData<T> {
|
||||
|
||||
get state(): Observable<RemoteDataState> {
|
||||
return Observable.combineLatest(
|
||||
this.storeLoading,
|
||||
this.errorMessage.map(msg => hasValue(msg)),
|
||||
(storeLoading, hasMsg) => {
|
||||
if (storeLoading) {
|
||||
this.requestPending,
|
||||
this.responsePending,
|
||||
this.isSuccessFul,
|
||||
(requestPending, responsePending, isSuccessFul) => {
|
||||
if (requestPending) {
|
||||
return RemoteDataState.RequestPending
|
||||
}
|
||||
else if (responsePending) {
|
||||
return RemoteDataState.ResponsePending
|
||||
}
|
||||
else if (hasMsg) {
|
||||
else if (!isSuccessFul) {
|
||||
return RemoteDataState.Failed
|
||||
}
|
||||
else {
|
@@ -1,16 +1,15 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Injectable, Inject } from "@angular/core";
|
||||
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 { GlobalConfig, GLOBAL_CONFIG } from "../../../config";
|
||||
import { ResetResponseCacheTimestampsAction } from "../cache/response-cache.actions";
|
||||
|
||||
@Injectable()
|
||||
export class RequestCacheEffects {
|
||||
|
||||
constructor(
|
||||
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig,
|
||||
private actions$: Actions,
|
||||
private store: Store<RequestCacheState>
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -31,6 +30,5 @@ export class RequestCacheEffects {
|
||||
*/
|
||||
@Effect() fixTimestampsOnRehydrate = this.actions$
|
||||
.ofType(ObjectCacheActionTypes.RESET_TIMESTAMPS)
|
||||
.map(() => new ResetRequestCacheTimestampsAction(new Date().getTime()));
|
||||
|
||||
.map(() => new ResetResponseCacheTimestampsAction(new Date().getTime()));
|
||||
}
|
59
src/app/core/data/request.actions.ts
Normal file
59
src/app/core/data/request.actions.ts
Normal 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;
|
71
src/app/core/data/request.effects.ts
Normal file
71
src/app/core/data/request.effects.ts
Normal 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);
|
||||
}
|
||||
}
|
32
src/app/core/data/request.models.ts
Normal file
32
src/app/core/data/request.models.ts
Normal 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);
|
||||
}
|
||||
}
|
81
src/app/core/data/request.reducer.ts
Normal file
81
src/app/core/data/request.reducer.ts
Normal 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
|
||||
})
|
||||
});
|
||||
}
|
48
src/app/core/data/request.service.ts
Normal file
48
src/app/core/data/request.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
@@ -140,19 +140,20 @@ describe("DSpaceRESTv2Serializer", () => {
|
||||
|
||||
describe("deserializeArray", () => {
|
||||
|
||||
it("should turn a valid document describing a collection of objects in to an array of valid models", () => {
|
||||
const serializer = new DSpaceRESTv2Serializer(TestModel);
|
||||
const doc = {
|
||||
"_embedded": testResponses
|
||||
};
|
||||
|
||||
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[1].id).toBe(doc._embedded[1].id);
|
||||
expect(models[1].name).toBe(doc._embedded[1].name);
|
||||
});
|
||||
//TODO rewrite to incorporate normalisation.
|
||||
// it("should turn a valid document describing a collection of objects in to an array of valid models", () => {
|
||||
// const serializer = new DSpaceRESTv2Serializer(TestModel);
|
||||
// const doc = {
|
||||
// "_embedded": testResponses
|
||||
// };
|
||||
//
|
||||
// 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[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
|
||||
// will be handled in the rest api
|
||||
|
@@ -55,7 +55,8 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
|
||||
if (Array.isArray(response._embedded)) {
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ import {
|
||||
DebugElement
|
||||
} from "@angular/core";
|
||||
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";
|
||||
|
||||
// Load the implementations that should be tested
|
||||
@@ -30,8 +30,10 @@ describe('Footer component', () => {
|
||||
beforeEach(async(() => {
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [CommonModule, StoreModule.provideStore({}), TranslateModule.forRoot({
|
||||
provide: TranslateLoader,
|
||||
useClass: MockTranslateLoader
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: MockTranslateLoader
|
||||
}
|
||||
})],
|
||||
declarations: [FooterComponent], // declare the test component
|
||||
providers: [
|
||||
|
58
src/app/core/index/href-index.actions.ts
Normal file
58
src/app/core/index/href-index.actions.ts
Normal 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;
|
32
src/app/core/index/href-index.effects.ts
Normal file
32
src/app/core/index/href-index.effects.ts
Normal 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);
|
||||
});
|
||||
}
|
43
src/app/core/index/href-index.reducer.ts
Normal file
43
src/app/core/index/href-index.reducer.ts
Normal 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;
|
||||
}
|
14
src/app/core/index/index.reducers.ts
Normal file
14
src/app/core/index/index.reducers.ts
Normal 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);
|
||||
}
|
@@ -1,8 +1,7 @@
|
||||
import { inheritSerialization } from "cerialize";
|
||||
import { DSpaceObject } from "./dspace-object.model";
|
||||
import { Bundle } from "./bundle.model";
|
||||
import { RemoteData } from "../data/remote-data";
|
||||
|
||||
@inheritSerialization(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
|
||||
*/
|
||||
parents: Array<Bundle>;
|
||||
parents: Array<RemoteData<Bundle>>;
|
||||
|
||||
/**
|
||||
* The Bundle that owns this Bitstream
|
||||
*/
|
||||
owner: Bundle;
|
||||
|
||||
/**
|
||||
* The Bundle that owns this Bitstream
|
||||
*/
|
||||
retrieve: string;
|
||||
|
||||
}
|
||||
|
@@ -1,23 +1,24 @@
|
||||
import { inheritSerialization } from "cerialize";
|
||||
import { DSpaceObject } from "./dspace-object.model";
|
||||
import { Bitstream } from "./bitstream.model";
|
||||
import { Item } from "./item.model";
|
||||
import { RemoteData } from "../data/remote-data";
|
||||
|
||||
@inheritSerialization(DSpaceObject)
|
||||
export class Bundle extends DSpaceObject {
|
||||
/**
|
||||
* The primary bitstream of this Bundle
|
||||
*/
|
||||
primaryBitstream: Bitstream;
|
||||
primaryBitstream: RemoteData<Bitstream>;
|
||||
|
||||
/**
|
||||
* An array of Items that are direct parents of this Bundle
|
||||
*/
|
||||
parents: Array<Item>;
|
||||
parents: Array<RemoteData<Item>>;
|
||||
|
||||
/**
|
||||
* The Item that owns this Bundle
|
||||
*/
|
||||
owner: Item;
|
||||
|
||||
bitstreams: Array<RemoteData<Bitstream>>
|
||||
|
||||
}
|
||||
|
@@ -1,14 +1,13 @@
|
||||
import { autoserialize, inheritSerialization } from "cerialize";
|
||||
import { DSpaceObject } from "./dspace-object.model";
|
||||
import { Bitstream } from "./bitstream.model";
|
||||
import { Item } from "./item.model";
|
||||
import { RemoteData } from "../data/remote-data";
|
||||
|
||||
@inheritSerialization(DSpaceObject)
|
||||
export class Collection extends DSpaceObject {
|
||||
|
||||
/**
|
||||
* A string representing the unique handle of this Collection
|
||||
*/
|
||||
@autoserialize
|
||||
handle: string;
|
||||
|
||||
/**
|
||||
@@ -54,16 +53,18 @@ export class Collection extends DSpaceObject {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
parents: Array<Collection>;
|
||||
parents: Array<RemoteData<Collection>>;
|
||||
|
||||
/**
|
||||
* The Collection that owns this Collection
|
||||
*/
|
||||
owner: Collection;
|
||||
|
||||
items: Array<RemoteData<Item>>;
|
||||
|
||||
}
|
||||
|
62
src/app/core/shared/community.model.ts
Normal file
62
src/app/core/shared/community.model.ts
Normal 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>>;
|
||||
|
||||
}
|
@@ -2,73 +2,94 @@ import { autoserialize, autoserializeAs } from "cerialize";
|
||||
import { Metadatum } from "./metadatum.model"
|
||||
import { isEmpty, isNotEmpty } from "../../shared/empty.util";
|
||||
import { CacheableObject } from "../cache/object-cache.reducer";
|
||||
import { RemoteData } from "../data/remote-data";
|
||||
|
||||
/**
|
||||
* An abstract model class for a DSpaceObject.
|
||||
*/
|
||||
export abstract class DSpaceObject implements CacheableObject {
|
||||
|
||||
/**
|
||||
* The human-readable identifier of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
id: string;
|
||||
@autoserialize
|
||||
self: string;
|
||||
|
||||
/**
|
||||
* The universally unique identifier of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
uuid: string;
|
||||
/**
|
||||
* The human-readable identifier of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* A string representing the kind of DSpaceObject, e.g. community, item, …
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* The universally unique identifier of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
uuid: string;
|
||||
|
||||
/**
|
||||
* The name for this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
name: string;
|
||||
/**
|
||||
* A string representing the kind of DSpaceObject, e.g. community, item, …
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* An array containing all metadata of this DSpaceObject
|
||||
*/
|
||||
@autoserializeAs(Metadatum)
|
||||
metadata: Array<Metadatum>;
|
||||
/**
|
||||
* The name for this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* An array of DSpaceObjects that are direct parents of this DSpaceObject
|
||||
*/
|
||||
parents: Array<DSpaceObject>;
|
||||
/**
|
||||
* An array containing all metadata of this DSpaceObject
|
||||
*/
|
||||
@autoserializeAs(Metadatum)
|
||||
metadata: Array<Metadatum>;
|
||||
|
||||
/**
|
||||
* The DSpaceObject that owns this DSpaceObject
|
||||
*/
|
||||
owner: DSpaceObject;
|
||||
/**
|
||||
* An array of DSpaceObjects that are direct parents of this DSpaceObject
|
||||
*/
|
||||
parents: Array<RemoteData<DSpaceObject>>;
|
||||
|
||||
/**
|
||||
* Find a metadata field by key and language
|
||||
*
|
||||
* This method returns the value of the first element
|
||||
* in the metadata array that matches the provided
|
||||
* key and language
|
||||
*
|
||||
* @param key
|
||||
* @param language
|
||||
* @return string
|
||||
*/
|
||||
findMetadata(key: string, language?: string): string {
|
||||
const metadatum = this.metadata
|
||||
.find((metadatum: Metadatum) => {
|
||||
return metadatum.key === key &&
|
||||
(isEmpty(language) || metadatum.language === language)
|
||||
});
|
||||
if (isNotEmpty(metadatum)) {
|
||||
return metadatum.value;
|
||||
/**
|
||||
* The DSpaceObject that owns this DSpaceObject
|
||||
*/
|
||||
owner: DSpaceObject;
|
||||
|
||||
/**
|
||||
* Find a metadata field by key and language
|
||||
*
|
||||
* This method returns the value of the first element
|
||||
* in the metadata array that matches the provided
|
||||
* key and language
|
||||
*
|
||||
* @param key
|
||||
* @param language
|
||||
* @return string
|
||||
*/
|
||||
findMetadata(key: string, language?: string): string {
|
||||
const metadatum = this.metadata
|
||||
.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,39 +1,80 @@
|
||||
import { inheritSerialization, autoserialize } from "cerialize";
|
||||
import { DSpaceObject } from "./dspace-object.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 {
|
||||
|
||||
/**
|
||||
* A string representing the unique handle of this Item
|
||||
*/
|
||||
@autoserialize
|
||||
handle: string;
|
||||
/**
|
||||
* A string representing the unique handle of this Item
|
||||
*/
|
||||
handle: string;
|
||||
|
||||
/**
|
||||
* The Date of the last modification of this Item
|
||||
*/
|
||||
lastModified: Date;
|
||||
/**
|
||||
* 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 archived or not
|
||||
*/
|
||||
isArchived: boolean;
|
||||
|
||||
/**
|
||||
* A boolean representing if this Item is currently withdrawn or not
|
||||
*/
|
||||
isWithdrawn: 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
|
||||
*/
|
||||
parents: Array<Collection>;
|
||||
/**
|
||||
* An array of Collections that are direct parents of this Item
|
||||
*/
|
||||
parents: Array<RemoteData<Collection>>;
|
||||
|
||||
/**
|
||||
* The Collection that owns this Item
|
||||
*/
|
||||
owner: Collection;
|
||||
/**
|
||||
* The Collection that owns this Item
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -8,7 +8,7 @@ import { GlobalConfig } from "../../../config";
|
||||
* TODO write tests once GlobalConfig becomes injectable
|
||||
*/
|
||||
export class UIURLCombiner extends URLCombiner{
|
||||
constructor(...parts:Array<string>) {
|
||||
super(GlobalConfig.ui.baseURL, GlobalConfig.ui.nameSpace, ...parts);
|
||||
constructor(EnvConfig: GlobalConfig, ...parts: Array<string>) {
|
||||
super(EnvConfig.ui.baseUrl, EnvConfig.ui.nameSpace, ...parts);
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import { Store, StoreModule } from "@ngrx/store";
|
||||
import { HeaderState } from "./header.reducer";
|
||||
import Spy = jasmine.Spy;
|
||||
import { HeaderToggleAction } from "./header.actions";
|
||||
import { TranslateModule } from "ng2-translate";
|
||||
import { TranslateModule } from "@ngx-translate/core";
|
||||
import { NgbCollapseModule } from "@ng-bootstrap/ng-bootstrap";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
|
19
src/app/home/home-news/home-news.component.html
Normal file
19
src/app/home/home-news/home-news.component.html
Normal 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>
|
0
src/app/home/home-news/home-news.component.scss
Normal file
0
src/app/home/home-news/home-news.component.scss
Normal file
19
src/app/home/home-news/home-news.component.ts
Normal file
19
src/app/home/home-news/home-news.component.ts
Normal 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 {
|
||||
}
|
||||
}
|
@@ -1,3 +1,2 @@
|
||||
<div class="home">
|
||||
Home component
|
||||
</div>
|
||||
<ds-home-news></ds-home-news>
|
||||
<ds-top-level-community-list></ds-top-level-community-list>
|
||||
|
@@ -1,16 +1,11 @@
|
||||
import { Component, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.Default,
|
||||
encapsulation: ViewEncapsulation.Emulated,
|
||||
selector: 'ds-home',
|
||||
styleUrls: ['./home.component.css'],
|
||||
templateUrl: './home.component.html'
|
||||
})
|
||||
export class HomeComponent {
|
||||
|
||||
data: any = {};
|
||||
|
||||
export class HomeComponent implements OnInit {
|
||||
constructor() {
|
||||
this.universalInit();
|
||||
}
|
||||
@@ -19,4 +14,6 @@ export class HomeComponent {
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
}
|
||||
|
@@ -2,13 +2,23 @@ import { NgModule } from '@angular/core';
|
||||
|
||||
import { HomeComponent } from './home.component';
|
||||
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({
|
||||
imports: [
|
||||
HomeRoutingModule
|
||||
CommonModule,
|
||||
HomeRoutingModule,
|
||||
RouterModule,
|
||||
TranslateModule
|
||||
],
|
||||
declarations: [
|
||||
HomeComponent
|
||||
HomeComponent,
|
||||
TopLevelCommunityListComponent,
|
||||
HomeNewsComponent
|
||||
]
|
||||
})
|
||||
export class HomeModule { }
|
||||
|
@@ -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>
|
@@ -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();
|
||||
}
|
||||
}
|
7
src/app/item-page/collections/collections.component.html
Normal file
7
src/app/item-page/collections/collections.component.html
Normal 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>
|
34
src/app/item-page/collections/collections.component.ts
Normal file
34
src/app/item-page/collections/collections.component.ts
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
@@ -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>
|
33
src/app/item-page/file-section/file-section.component.ts
Normal file
33
src/app/item-page/file-section/file-section.component.ts
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
}
|
14
src/app/item-page/item-page-routing.module.ts
Normal file
14
src/app/item-page/item-page-routing.module.ts
Normal 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 {
|
||||
}
|
19
src/app/item-page/item-page.component.html
Normal file
19
src/app/item-page/item-page.component.html
Normal 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>
|
1
src/app/item-page/item-page.component.scss
Normal file
1
src/app/item-page/item-page.component.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import '../../styles/variables.scss';
|
41
src/app/item-page/item-page.component.ts
Normal file
41
src/app/item-page/item-page.component.ts
Normal 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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
40
src/app/item-page/item-page.module.ts
Normal file
40
src/app/item-page/item-page.module.ts
Normal 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 {
|
||||
}
|
@@ -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>
|
@@ -0,0 +1,6 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
:host {
|
||||
.simple-view-element {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
@@ -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() {
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -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>
|
@@ -0,0 +1 @@
|
||||
@import '../../../styles/variables.scss';
|
@@ -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
Reference in New Issue
Block a user