1
0

Merge pull request #117 from artlowel/dso-list-component

Live REST API & Dso list component
This commit is contained in:
Art Lowel
2017-06-23 17:04:21 +02:00
committed by GitHub
75 changed files with 1433 additions and 580 deletions

View File

@@ -2,10 +2,10 @@ module.exports = {
// The REST API server settings.
"rest": {
"ssl": false,
"address": "localhost",
"port": 3000,
"address": "dspace7.4science.it",
"port": 80,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
"nameSpace": "/api"
"nameSpace": "/dspace-spring-rest/api"
},
// Angular2 UI server settings.
"ui": {

View File

@@ -7,7 +7,12 @@
"collection": {
"page": {
"news": "News",
"license": "License"
"license": "License",
"browse": {
"recent": {
"head": "Recent Submissions"
}
}
}
},
"community": {
@@ -45,6 +50,7 @@
},
"pagination": {
"results-per-page": "Results Per Page",
"sort-direction": "Sort Options",
"showing": {
"label": "Now showing items ",
"detail": "{{ range }} of {{ total }}"

View File

@@ -1,32 +1,44 @@
<div class="collection-page" *ngIf="collectionData.hasSucceeded | async">
<!-- Collection Name -->
<ds-comcol-page-header
[name]="(collectionData.payload | async)?.name">
</ds-comcol-page-header>
<!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logoData"
[logo]="logoData.payload | async"
[alternateText]="'Collection Logo'">
</ds-comcol-page-logo>
<!-- Introductionary text -->
<ds-comcol-page-content
<div class="collection-page">
<div *ngIf="collectionData.hasSucceeded | async">
<!-- Collection Name -->
<ds-comcol-page-header
[name]="(collectionData.payload | async)?.name">
</ds-comcol-page-header>
<!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logoData"
[logo]="logoData.payload | async"
[alternateText]="'Collection Logo'">
</ds-comcol-page-logo>
<!-- Introductionary text -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.introductoryText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- News -->
<ds-comcol-page-content
</ds-comcol-page-content>
<!-- News -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.sidebarText"
[hasInnerHtml]="true"
[title]="'community.page.news'">
</ds-comcol-page-content>
<!-- Copyright -->
<ds-comcol-page-content
</ds-comcol-page-content>
<!-- Copyright -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.copyrightText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- Licence -->
<ds-comcol-page-content
</ds-comcol-page-content>
<!-- Licence -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.license"
[title]="'collection.page.license'">
</ds-comcol-page-content>
</ds-comcol-page-content>
</div>
<br>
<div *ngIf="itemData.hasSucceeded | async">
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
<ds-object-list [config]="config" [sortConfig]="sortConfig"
[objects]="itemData" [hideGear]="true"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
(sortDirectionChange)="onSortDirectionChange($event)"
(sortFieldChange)="onSortDirectionChange($event)"></ds-object-list>
</div>
</div>

View File

@@ -1,4 +1,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy,
OnInit
} from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Collection } from "../core/shared/collection.model";
@@ -6,36 +9,93 @@ import { Bitstream } from "../core/shared/bitstream.model";
import { RemoteData } from "../core/data/remote-data";
import { CollectionDataService } from "../core/data/collection-data.service";
import { Subscription } from "rxjs/Subscription";
import { ItemDataService } from "../core/data/item-data.service";
import { Item } from "../core/shared/item.model";
import { SortOptions, SortDirection } from "../core/cache/models/sort-options.model";
import { PaginationComponentOptions } from "../shared/pagination/pagination-component-options.model";
import { Observable } from "rxjs/Observable";
import { hasValue } from "../shared/empty.util";
@Component({
selector: 'ds-collection-page',
styleUrls: ['./collection-page.component.css'],
templateUrl: './collection-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CollectionPageComponent implements OnInit, OnDestroy {
collectionData: RemoteData<Collection>;
itemData: RemoteData<Item[]>;
logoData: RemoteData<Bitstream>;
config : PaginationComponentOptions;
sortConfig : SortOptions;
private subs: Subscription[] = [];
private collectionId: string;
constructor(
private collectionDataService: CollectionDataService,
private itemDataService: ItemDataService,
private ref: ChangeDetectorRef,
private route: ActivatedRoute
) {
this.universalInit();
}
ngOnInit(): void {
this.route.params.subscribe((params: Params) => {
this.collectionData = this.collectionDataService.findById(params['id']);
this.subs.push(this.collectionData.payload
.subscribe(collection => this.logoData = collection.logo));
});
this.subs.push(this.route.params.map((params: Params) => params['id'] )
.subscribe((id: string) => {
this.collectionId = id;
this.collectionData = this.collectionDataService.findById(this.collectionId);
this.subs.push(this.collectionData.payload
.subscribe(collection => this.logoData = collection.logo));
this.config = new PaginationComponentOptions();
this.config.id = "collection-browse";
this.config.pageSizeOptions = [ 4 ];
this.config.pageSize = 4;
this.sortConfig = new SortOptions();
this.updateResults();
}));
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
this.subs
.filter(sub => hasValue(sub))
.forEach(sub => sub.unsubscribe());
}
universalInit() {
}
onPageChange(currentPage: number): void {
this.config.currentPage = currentPage;
this.updateResults();
}
onPageSizeChange(elementsPerPage: number): void {
this.config.pageSize = elementsPerPage;
this.updateResults();
}
onSortDirectionChange(sortDirection: SortDirection): void {
this.sortConfig = new SortOptions(this.sortConfig.field, sortDirection);
this.updateResults();
}
onSortFieldChange(field: string): void {
this.sortConfig = new SortOptions(field, this.sortConfig.direction);
this.updateResults();
}
updateResults() {
this.itemData = undefined;
this.itemData = this.itemDataService.findAll({
scopeID: this.collectionId,
currentPage: this.config.currentPage,
elementsPerPage: this.config.pageSize,
sort: this.sortConfig
});
// this.ref.detectChanges();
}
}

View File

@@ -1,9 +1,9 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from "@ngx-translate/core";
import { SharedModule } from '../shared/shared.module';
import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageRoutingModule } from './collection-page-routing.module';
@@ -11,8 +11,8 @@ import { CollectionPageRoutingModule } from './collection-page-routing.module';
imports: [
CollectionPageRoutingModule,
CommonModule,
TranslateModule,
SharedModule,
TranslateModule,
],
declarations: [
CollectionPageComponent,

View File

@@ -6,6 +6,7 @@ import { Bitstream } from "../core/shared/bitstream.model";
import { RemoteData } from "../core/data/remote-data";
import { CommunityDataService } from "../core/data/community-data.service";
import { Subscription } from "rxjs/Subscription";
import { hasValue } from "../shared/empty.util";
@Component({
selector: 'ds-community-page',
@@ -33,9 +34,11 @@ export class CommunityPageComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
this.subs
.filter(sub => hasValue(sub))
.forEach(sub => sub.unsubscribe());
}
universalInit() {
}
}
}

View File

@@ -16,7 +16,7 @@ export const getMapsTo = function(target: any) {
return Reflect.getOwnMetadata(mapsToMetadataKey, target);
};
export const relationship = function(value: ResourceType): any {
export const relationship = function(value: ResourceType, isList: boolean = false): any {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
if (!target || !propertyKey) {
return;
@@ -28,11 +28,11 @@ export const relationship = function(value: ResourceType): any {
}
relationshipMap.set(target.constructor, metaDataList);
return Reflect.metadata(relationshipKey, value).apply(this, arguments);
return Reflect.metadata(relationshipKey, { resourceType: value, isList }).apply(this, arguments);
};
};
export const getResourceType = function(target: any, propertyKey: string) {
export const getRelationMetadata = function(target: any, propertyKey: string) {
return Reflect.getMetadata(relationshipKey, target, propertyKey);
};

View File

@@ -12,7 +12,7 @@ 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 { getMapsTo, getRelationMetadata, getRelationships } from "./build-decorators";
import { NormalizedObjectFactory } from "../models/normalized-object-factory";
import { Request } from "../../data/request.models";
@@ -55,17 +55,54 @@ export class RemoteDataBuildService {
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged();
const payload = this.objectCache.getBySelfLink<TNormalized>(href, normalizedType)
.map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized);
const statusCode = responseCacheObs
.map((entry: ResponseCacheEntry) => entry.response.statusCode)
.distinctUntilChanged();
const pageInfo = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry.response) && hasValue(entry.response['pageInfo']))
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).pageInfo)
.distinctUntilChanged();
//always use self link if that is cached, only if it isn't, get it via the response.
const payload =
Observable.combineLatest(
this.objectCache.getBySelfLink<TNormalized>(href, normalizedType).startWith(undefined),
responseCacheObs
.filter((entry: ResponseCacheEntry) => 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()
.startWith(undefined),
(fromSelfLink, fromResponse) => {
if (hasValue(fromSelfLink)) {
return fromSelfLink;
}
else {
return fromResponse;
}
}
).filter(normalized => hasValue(normalized))
.map((normalized: TNormalized) => {
return this.build<TNormalized, TDomain>(normalized);
});
return new RemoteData(
href,
requestPending,
responsePending,
isSuccessFul,
errorMessage,
statusCode,
pageInfo,
payload
);
}
@@ -90,6 +127,15 @@ export class RemoteDataBuildService {
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged();
const statusCode = responseCacheObs
.map((entry: ResponseCacheEntry) => entry.response.statusCode)
.distinctUntilChanged();
const pageInfo = responseCacheObs
.filter((entry: ResponseCacheEntry) => hasValue(entry.response) && hasValue(entry.response['pageInfo']))
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).pageInfo)
.distinctUntilChanged();
const payload = responseCacheObs
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs)
@@ -109,6 +155,8 @@ export class RemoteDataBuildService {
responsePending,
isSuccessFul,
errorMessage,
statusCode,
pageInfo,
payload
);
}
@@ -121,7 +169,7 @@ export class RemoteDataBuildService {
relationships.forEach((relationship: string) => {
if (hasValue(normalized[relationship])) {
const resourceType = getResourceType(normalized, relationship);
const { resourceType, isList } = getRelationMetadata(normalized, relationship);
const resourceConstructor = NormalizedObjectFactory.getConstructor(resourceType);
if (Array.isArray(normalized[relationship])) {
// without the setTimeout, the actions inside requestService.configure
@@ -137,7 +185,12 @@ export class RemoteDataBuildService {
rdArr.push(this.buildSingle(href, resourceConstructor));
});
links[relationship] = this.aggregate(rdArr);
if (rdArr.length === 1) {
links[relationship] = rdArr[0];
}
else {
links[relationship] = this.aggregate(rdArr);
}
}
else {
// without the setTimeout, the actions inside requestService.configure
@@ -146,7 +199,14 @@ export class RemoteDataBuildService {
this.requestService.configure(new Request(normalized[relationship]));
},0);
links[relationship] = this.buildSingle(normalized[relationship], resourceConstructor);
// The rest API can return a single URL to represent a list of resources (e.g. /items/:id/bitstreams)
// in that case only 1 href will be stored in the normalized obj (so the isArray above fails),
// but it should still be built as a list
if (isList) {
links[relationship] = this.buildList(normalized[relationship], resourceConstructor);
} else {
links[relationship] = this.buildSingle(normalized[relationship], resourceConstructor);
}
}
}
});
@@ -183,6 +243,20 @@ export class RemoteDataBuildService {
.join(", ")
);
const statusCode = Observable.combineLatest(
...input.map(rd => rd.statusCode),
).map((...statusCodes) => statusCodes
.map((code, idx) => {
if (hasValue(code)) {
return `[${idx}]: ${code}`;
}
})
.filter(c => hasValue(c))
.join(", ")
);
const pageInfo = Observable.of(undefined);
const payload = <Observable<T[]>> Observable.combineLatest(
...input.map(rd => rd.payload)
);
@@ -196,6 +270,8 @@ export class RemoteDataBuildService {
responsePending,
isSuccessFul,
errorMessage,
statusCode,
pageInfo,
payload
);
}

View File

@@ -0,0 +1,11 @@
import { NormalizedObject } from "./normalized-object.model";
import { inheritSerialization } from "cerialize";
@inheritSerialization(NormalizedObject)
export class NormalizedBitstreamFormat extends NormalizedObject {
//TODO this class was created as a placeholder when we connected to the live rest api
get uuid(): string {
return this.self;
}
}

View File

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

View File

@@ -11,7 +11,7 @@ export class NormalizedBundle extends NormalizedDSpaceObject {
* The primary bitstream of this Bundle
*/
@autoserialize
@relationship(ResourceType.Bitstream)
@relationship(ResourceType.Bitstream, false)
primaryBitstream: string;
/**
@@ -25,6 +25,6 @@ export class NormalizedBundle extends NormalizedDSpaceObject {
owner: string;
@autoserialize
@relationship(ResourceType.Bitstream)
@relationship(ResourceType.Bitstream, true)
bitstreams: Array<string>;
}

View File

@@ -18,21 +18,25 @@ export class NormalizedCollection extends NormalizedDSpaceObject {
* The Bitstream that represents the logo of this Collection
*/
@autoserialize
@relationship(ResourceType.Bitstream)
@relationship(ResourceType.Bitstream, false)
logo: string;
/**
* An array of Collections that are direct parents of this Collection
* An array of Communities that are direct parents of this Collection
*/
@autoserialize
@relationship(ResourceType.Community, true)
parents: Array<string>;
/**
* The Collection that owns this Collection
* The Community that owns this Collection
*/
@autoserialize
@relationship(ResourceType.Community, false)
owner: string;
@autoserialize
@relationship(ResourceType.Item)
@relationship(ResourceType.Item, true)
items: Array<string>;
}

View File

@@ -18,21 +18,25 @@ export class NormalizedCommunity extends NormalizedDSpaceObject {
* The Bitstream that represents the logo of this Community
*/
@autoserialize
@relationship(ResourceType.Bitstream)
@relationship(ResourceType.Bitstream, false)
logo: string;
/**
* An array of Communities that are direct parents of this Community
*/
@autoserialize
@relationship(ResourceType.Community, true)
parents: Array<string>;
/**
* The Community that owns this Community
*/
@autoserialize
@relationship(ResourceType.Community, false)
owner: string;
@autoserialize
@relationship(ResourceType.Collection)
@relationship(ResourceType.Collection, true)
collections: Array<string>;
}

View File

@@ -1,24 +1,36 @@
import { autoserialize, autoserializeAs } from "cerialize";
import { CacheableObject } from "../object-cache.reducer";
import { autoserialize, autoserializeAs, inheritSerialization } from "cerialize";
import { Metadatum } from "../../shared/metadatum.model";
import { ResourceType } from "../../shared/resource-type";
import { NormalizedObject } from "./normalized-object.model";
/**
* An abstract model class for a DSpaceObject.
*/
export abstract class NormalizedDSpaceObject implements CacheableObject {
export abstract class NormalizedDSpaceObject extends NormalizedObject{
/**
* The link to the rest endpoint where this object can be found
*
* Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level
*/
@autoserialize
self: string;
/**
* The human-readable identifier of this DSpaceObject
*
* Currently mapped to uuid but left in to leave room
* for a shorter, more user friendly type of id
*/
@autoserialize
@autoserializeAs(String, 'uuid')
id: string;
/**
* The universally unique identifier of this DSpaceObject
*
* Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level
*/
@autoserialize
uuid: string;

View File

@@ -1,4 +1,4 @@
import { inheritSerialization, autoserialize } from "cerialize";
import { inheritSerialization, autoserialize, autoserializeAs } from "cerialize";
import { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { Item } from "../../shared/item.model";
import { mapsTo, relationship } from "../builders/build-decorators";
@@ -17,31 +17,41 @@ export class NormalizedItem extends NormalizedDSpaceObject {
/**
* The Date of the last modification of this Item
*/
@autoserialize
lastModified: Date;
/**
* A boolean representing if this Item is currently archived or not
*/
@autoserializeAs(Boolean, 'inArchive')
isArchived: boolean;
/**
* A boolean representing if this Item is currently discoverable or not
*/
@autoserializeAs(Boolean, 'discoverable')
isDiscoverable: boolean;
/**
* A boolean representing if this Item is currently withdrawn or not
*/
@autoserializeAs(Boolean, 'withdrawn')
isWithdrawn: boolean;
/**
* An array of Collections that are direct parents of this Item
*/
@autoserialize
@relationship(ResourceType.Collection)
@relationship(ResourceType.Collection, true)
parents: Array<string>;
/**
* The Collection that owns this Item
*/
owner: string;
@relationship(ResourceType.Collection, false)
owningCollection: string;
@autoserialize
@relationship(ResourceType.Bundle)
bundles: Array<string>;
@relationship(ResourceType.Bitstream, true)
bitstreams: Array<string>;
}

View File

@@ -6,13 +6,20 @@ import { NormalizedCollection } from "./normalized-collection.model";
import { GenericConstructor } from "../../shared/generic-constructor";
import { NormalizedCommunity } from "./normalized-community.model";
import { ResourceType } from "../../shared/resource-type";
import { NormalizedObject } from "./normalized-object.model";
import { NormalizedBitstreamFormat } from "./normalized-bitstream-format.model";
export class NormalizedObjectFactory {
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedDSpaceObject> {
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject> {
switch (type) {
case ResourceType.Bitstream: {
return NormalizedBitstream
}
// commented out for now, bitstreamformats aren't used in the UI yet
// and slow things down noticeably
// case ResourceType.BitstreamFormat: {
// return NormalizedBitstreamFormat
// }
case ResourceType.Bundle: {
return NormalizedBundle
}

View File

@@ -0,0 +1,20 @@
import { CacheableObject } from "../object-cache.reducer";
import { autoserialize } from "cerialize";
/**
* An abstract model class for a NormalizedObject.
*/
export abstract class NormalizedObject implements CacheableObject {
/**
* The link to the rest endpoint where this object can be found
*/
@autoserialize
self: string;
/**
* The universally unique identifier of this Object
*/
@autoserialize
uuid: string;
}

View File

@@ -4,6 +4,8 @@ export enum SortDirection {
}
export class SortOptions {
field: string = "id";
direction: SortDirection = SortDirection.Ascending
constructor (public field: string = "name", public direction : SortDirection = SortDirection.Ascending) {
}
}

View File

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

View File

@@ -1,18 +1,28 @@
import { RequestError } from "../data/request.models";
import { PageInfo } from "../shared/page-info.model";
export class Response {
constructor(public isSuccessful: boolean) {}
constructor(
public isSuccessful: boolean,
public statusCode: string
) {}
}
export class SuccessResponse extends Response {
constructor(public resourceUUIDs: Array<String>) {
super(true);
constructor(
public resourceUUIDs: Array<String>,
public statusCode: string,
public pageInfo?: PageInfo
) {
super(true, statusCode);
}
}
export class ErrorResponse extends Response {
errorMessage: string;
constructor(error: Error) {
super(false);
constructor(error: RequestError) {
super(false, error.statusText);
console.error(error);
this.errorMessage = error.message;
}

View File

@@ -12,7 +12,7 @@ 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";
import { PaginationComponentOptions } from "../shared/pagination/pagination-component-options.model";
const IMPORTS = [
CommonModule,
@@ -33,7 +33,7 @@ const PROVIDERS = [
ItemDataService,
DSpaceRESTv2Service,
ObjectCacheService,
PaginationOptions,
PaginationComponentOptions,
ResponseCacheService,
RequestService,
RemoteDataBuildService

View File

@@ -1,4 +1,4 @@
import { Injectable } from "@angular/core";
import { Inject, Injectable } from "@angular/core";
import { DataService } from "./data.service";
import { Collection } from "../shared/collection.model";
import { ObjectCacheService } from "../cache/object-cache.service";
@@ -8,19 +8,22 @@ import { NormalizedCollection } from "../cache/models/normalized-collection.mode
import { CoreState } from "../core.reducers";
import { RequestService } from "./request.service";
import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service";
import { GLOBAL_CONFIG, GlobalConfig } from "../../../config";
@Injectable()
export class CollectionDataService extends DataService<NormalizedCollection, Collection> {
protected endpoint = '/collections';
protected resourceEndpoint = '/core/collections';
protected browseEndpoint = '/discover/browses/dateissued/collections';
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>
protected store: Store<CoreState>,
@Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig
) {
super(NormalizedCollection);
super(NormalizedCollection, EnvConfig);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable } from "@angular/core";
import { Inject, Injectable } from "@angular/core";
import { DataService } from "./data.service";
import { Community } from "../shared/community.model";
import { ObjectCacheService } from "../cache/object-cache.service";
@@ -8,19 +8,22 @@ import { NormalizedCommunity } from "../cache/models/normalized-community.model"
import { CoreState } from "../core.reducers";
import { RequestService } from "./request.service";
import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service";
import { GLOBAL_CONFIG, GlobalConfig } from "../../../config";
@Injectable()
export class CommunityDataService extends DataService<NormalizedCommunity, Community> {
protected endpoint = '/communities';
protected resourceEndpoint = '/core/communities';
protected browseEndpoint = '/discover/browses/dateissued/communities';
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>
protected store: Store<CoreState>,
@Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig
) {
super(NormalizedCommunity);
super(NormalizedCommunity, EnvConfig);
}
}

View File

@@ -1,15 +1,18 @@
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 { hasValue, isNotEmpty } from "../../shared/empty.util";
import { RemoteData } from "./remote-data";
import { FindAllRequest, FindByIDRequest, Request } from "./request.models";
import { FindAllOptions, 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";
import { Inject } from "@angular/core";
import { GLOBAL_CONFIG, GlobalConfig } from "../../../config";
import { RESTURLCombiner } from "../url-combiner/rest-url-combiner";
export abstract class DataService<TNormalized extends CacheableObject, TDomain> {
protected abstract objectCache: ObjectCacheService;
@@ -17,30 +20,61 @@ export abstract class DataService<TNormalized extends CacheableObject, TDomain>
protected abstract requestService: RequestService;
protected abstract rdbService: RemoteDataBuildService;
protected abstract store: Store<CoreState>;
protected abstract endpoint: string;
protected abstract resourceEndpoint: string;
protected abstract browseEndpoint: string;
constructor(private normalizedResourceType: GenericConstructor<TNormalized>) {
constructor(
private normalizedResourceType: GenericConstructor<TNormalized>,
protected EnvConfig: GlobalConfig
) {
}
protected getFindAllHref(scopeID?): string {
let result = this.endpoint;
if (hasValue(scopeID)) {
result += `?scope=${scopeID}`
protected getFindAllHref(options: FindAllOptions = {}): string {
let result;
let args = [];
if (hasValue(options.scopeID)) {
result = this.browseEndpoint;
args.push(`scope=${options.scopeID}`);
}
return result;
else {
result = this.resourceEndpoint;
}
if (hasValue(options.currentPage) && typeof options.currentPage === "number") {
/* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */
args.push(`page=${options.currentPage - 1}`);
}
if (hasValue(options.elementsPerPage)) {
args.push(`size=${options.elementsPerPage}`);
}
if (hasValue(options.sort)) {
let direction = 'asc';
if (options.sort.direction === 1) {
direction = 'desc';
}
args.push(`sort=${options.sort.field},${direction}`);
}
if (isNotEmpty(args)) {
result = `${result}?${args.join('&')}`;
}
return new RESTURLCombiner(this.EnvConfig, result).toString();
}
findAll(scopeID?: string): RemoteData<Array<TDomain>> {
const href = this.getFindAllHref(scopeID);
const request = new FindAllRequest(href, scopeID);
findAll(options: FindAllOptions = {}): RemoteData<Array<TDomain>> {
const href = this.getFindAllHref(options);
const request = new FindAllRequest(href, options);
this.requestService.configure(request);
return this.rdbService.buildList<TNormalized, TDomain>(href, this.normalizedResourceType);
// return this.rdbService.buildList(href);
}
protected getFindByIDHref(resourceID): string {
return `${this.endpoint}/${resourceID}`;
return new RESTURLCombiner(this.EnvConfig, `${this.resourceEndpoint}/${resourceID}`).toString();
}
findById(id: string): RemoteData<TDomain> {

View File

@@ -1,4 +1,4 @@
import { Injectable } from "@angular/core";
import { Inject, Injectable } from "@angular/core";
import { DataService } from "./data.service";
import { Item } from "../shared/item.model";
import { ObjectCacheService } from "../cache/object-cache.service";
@@ -8,18 +8,21 @@ 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";
import { GLOBAL_CONFIG, GlobalConfig } from "../../../config";
@Injectable()
export class ItemDataService extends DataService<NormalizedItem, Item> {
protected endpoint = '/items';
protected resourceEndpoint = '/core/items';
protected browseEndpoint = '/discover/browses/dateissued/items';
constructor(
protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>
protected store: Store<CoreState>,
@Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig
) {
super(NormalizedItem);
super(NormalizedItem, EnvConfig);
}
}

View File

@@ -1,10 +1,11 @@
import { Observable } from "rxjs";
import { PageInfo } from "../shared/page-info.model";
export enum RemoteDataState {
RequestPending,
ResponsePending,
Failed,
Success
RequestPending = <any> "RequestPending",
ResponsePending = <any> "ResponsePending",
Failed = <any> "Failed",
Success = <any> "Success"
}
/**
@@ -17,6 +18,8 @@ export class RemoteData<T> {
private responsePending: Observable<boolean>,
private isSuccessFul: Observable<boolean>,
public errorMessage: Observable<string>,
public statusCode: Observable<string>,
public pageInfo: Observable<PageInfo>,
public payload: Observable<T>
) {
}

View File

@@ -1,6 +1,5 @@
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";
@@ -10,7 +9,7 @@ import { Observable } from "rxjs";
import { Response, SuccessResponse, ErrorResponse } from "../cache/response-cache.models";
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from "../../shared/empty.util";
import { GlobalConfig, GLOBAL_CONFIG } from "../../../config";
import { RequestState, RequestEntry } from "./request.reducer";
import { RequestEntry } from "./request.reducer";
import {
RequestActionTypes, RequestExecuteAction,
RequestCompleteAction
@@ -19,11 +18,31 @@ import { ResponseCacheService } from "../cache/response-cache.service";
import { RequestService } from "./request.service";
import { NormalizedObjectFactory } from "../cache/models/normalized-object-factory";
import { ResourceType } from "../shared/resource-type";
import { RequestError } from "./request.models";
import { PageInfo } from "../shared/page-info.model";
import { NormalizedObject } from "../cache/models/normalized-object.model";
function isObjectLevel(halObj: any) {
return isNotEmpty(halObj._links) && hasValue(halObj._links.self);
}
function isPaginatedResponse(halObj: any) {
return isNotEmpty(halObj.page) && hasValue(halObj._embedded);
}
function flattenSingleKeyObject(obj: any): any {
const keys = Object.keys(obj);
if (keys.length !== 1) {
throw new Error(`Expected an object with a single key, got: ${JSON.stringify(obj)}`);
}
return obj[keys[0]];
}
class ProcessRequestDTO {
[key: string]: NormalizedObject[]
}
@Injectable()
export class RequestEffects {
@@ -33,8 +52,7 @@ export class RequestEffects {
private restApi: DSpaceRESTv2Service,
private objectCache: ObjectCacheService,
private responseCache: ResponseCacheService,
protected requestService: RequestService,
private store: Store<RequestState>
protected requestService: RequestService
) { }
@Effect() execute = this.actions$
@@ -45,83 +63,102 @@ export class RequestEffects {
})
.flatMap((entry: RequestEntry) => {
return this.restApi.get(entry.request.href)
.map((data: DSpaceRESTV2Response) => this.processEmbedded(data._embedded, entry.request.href))
.map((ids: Array<string>) => new SuccessResponse(ids))
.do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((data: DSpaceRESTV2Response) => {
const processRequestDTO = this.process(data.payload, entry.request.href);
const uuids = flattenSingleKeyObject(processRequestDTO).map(no => no.uuid);
return new SuccessResponse(uuids, data.statusCode, this.processPageInfo(data.payload.page))
}).do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((response: Response) => new RequestCompleteAction(entry.request.href))
.catch((error: Error) => Observable.of(new ErrorResponse(error))
.catch((error: RequestError) => Observable.of(new ErrorResponse(error))
.do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((response: Response) => new RequestCompleteAction(entry.request.href)));
});
protected processEmbedded(_embedded: any, requestHref): Array<string> {
protected process(data: any, requestHref: string): ProcessRequestDTO {
if (isNotEmpty(_embedded)) {
if (isObjectLevel(_embedded)) {
return this.deserializeAndCache(_embedded, requestHref);
if (isNotEmpty(data)) {
if (isPaginatedResponse(data)) {
return this.process(data._embedded, requestHref);
}
else if (isObjectLevel(data)) {
return { "topLevel": this.deserializeAndCache(data, requestHref) };
}
else {
let uuids = [];
Object.keys(_embedded)
.filter(property => _embedded.hasOwnProperty(property))
.forEach(property => {
uuids = [...uuids, ...this.deserializeAndCache(_embedded[property], requestHref)];
let result = new ProcessRequestDTO();
if (Array.isArray(data)) {
result['topLevel'] = [];
data.forEach(datum => {
if (isPaginatedResponse(datum)) {
const obj = this.process(datum, requestHref);
result['topLevel'] = [...result['topLevel'], ...flattenSingleKeyObject(obj)];
}
else {
result['topLevel'] = [...result['topLevel'], ...this.deserializeAndCache(datum, requestHref)];
}
});
return uuids;
}
else {
Object.keys(data)
.filter(property => data.hasOwnProperty(property))
.filter(property => hasValue(data[property]))
.forEach(property => {
if (isPaginatedResponse(data[property])) {
const obj = this.process(data[property], requestHref);
result[property] = flattenSingleKeyObject(obj);
}
else {
result[property] = this.deserializeAndCache(data[property], requestHref);
}
});
}
return result;
}
}
}
protected deserializeAndCache(obj, requestHref): Array<string> {
let type: ResourceType;
const isArray = Array.isArray(obj);
if (isArray && isEmpty(obj)) {
return [];
}
if (isArray) {
type = obj[0]["type"];
}
else {
type = obj["type"];
protected deserializeAndCache(obj, requestHref: string): NormalizedObject[] {
if(Array.isArray(obj)) {
let result = [];
obj.forEach(o => result = [...result, ...this.deserializeAndCache(o, requestHref)])
return result;
}
let type: ResourceType = obj["type"];
if (hasValue(type)) {
const normObjConstructor = NormalizedObjectFactory.getConstructor(type);
if (hasValue(normObjConstructor)) {
const serializer = new DSpaceRESTv2Serializer(normObjConstructor);
if (isArray) {
obj.forEach(o => {
if (isNotEmpty(o._embedded)) {
this.processEmbedded(o._embedded, requestHref);
}
let processed;
if (isNotEmpty(obj._embedded)) {
processed = this.process(obj._embedded, requestHref);
}
let normalizedObj = serializer.deserialize(obj);
if (isNotEmpty(processed)) {
let linksOnly = {};
Object.keys(processed).forEach(key => {
linksOnly[key] = processed[key].map((no: NormalizedObject) => no.self);
});
const normalizedObjArr = serializer.deserializeArray(obj);
normalizedObjArr.forEach(t => this.addToObjectCache(t, requestHref));
return normalizedObjArr.map(t => t.uuid);
}
else {
if (isNotEmpty(obj._embedded)) {
this.processEmbedded(obj._embedded, requestHref);
}
const normalizedObj = serializer.deserialize(obj);
this.addToObjectCache(normalizedObj, requestHref);
return [normalizedObj.uuid];
Object.assign(normalizedObj, linksOnly);
}
this.addToObjectCache(normalizedObj, requestHref);
return [normalizedObj];
}
else {
//TODO move check to Validator?
throw new Error(`The server returned an object with an unknown a known type: ${type}`);
// throw new Error(`The server returned an object with an unknown a known type: ${type}`);
return [];
}
}
else {
//TODO move check to Validator
throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`);
// throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`);
return [];
}
}
@@ -131,4 +168,14 @@ export class RequestEffects {
}
this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref);
}
protected processPageInfo(pageObj: any): PageInfo {
if (isNotEmpty(pageObj)) {
return new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj);
}
else {
return undefined;
}
}
}

View File

@@ -1,5 +1,5 @@
import { SortOptions } from "../cache/models/sort-options.model";
import { PaginationOptions } from "../cache/models/pagination-options.model";
import { PaginationComponentOptions } from "../../shared/pagination/pagination-component-options.model";
import { GenericConstructor } from "../shared/generic-constructor";
export class Request<T> {
@@ -17,13 +17,22 @@ export class FindByIDRequest<T> extends Request<T> {
}
}
export class FindAllOptions {
scopeID?: string;
elementsPerPage?: number;
currentPage?: number;
sort?: SortOptions;
}
export class FindAllRequest<T> extends Request<T> {
constructor(
href: string,
public scopeID?: string,
public paginationOptions?: PaginationOptions,
public sortOptions?: SortOptions
public options?: FindAllOptions,
) {
super(href);
}
}
export class RequestError extends Error {
statusText: string;
}

View File

@@ -1,4 +1,8 @@
export interface DSpaceRESTV2Response {
_embedded?: any;
_links?: any;
payload: {
_embedded?: any;
_links?: any;
page?: any;
},
statusCode: string
}

View File

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

View File

@@ -26,10 +26,8 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
* @param model The model to serialize
* @returns An object to send to the backend
*/
serialize(model: T): DSpaceRESTV2Response {
return {
"_embedded": Serialize(model, this.modelType)
};
serialize(model: T): any {
return Serialize(model, this.modelType);
}
/**
@@ -38,10 +36,8 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
* @param models The array of models to serialize
* @returns An object to send to the backend
*/
serializeArray(models: Array<T>): DSpaceRESTV2Response {
return {
"_embedded": Serialize(models, this.modelType)
};
serializeArray(models: Array<T>): any {
return Serialize(models, this.modelType);
}
/**

View File

@@ -4,6 +4,7 @@ import { Observable } from 'rxjs/Observable';
import { RESTURLCombiner } from "../url-combiner/rest-url-combiner";
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { DSpaceRESTV2Response } from "./dspace-rest-v2-response.model";
/**
* Service to access DSpace's REST API
@@ -17,16 +18,16 @@ export class DSpaceRESTv2Service {
/**
* Performs a request to the REST API with the `get` http method.
*
* @param relativeURL
* A URL, relative to the basepath of the rest api
* @param absoluteURL
* A URL
* @param options
* A RequestOptionsArgs object, with options for the http call.
* @return {Observable<string>}
* An Observablse<string> containing the response from the server
* An Observable<string> containing the response from the server
*/
get(relativeURL: string, options?: RequestOptionsArgs): Observable<string> {
return this.http.get(new RESTURLCombiner(this.EnvConfig, relativeURL).toString(), options)
.map(res => res.json())
get(absoluteURL: string, options?: RequestOptionsArgs): Observable<DSpaceRESTV2Response> {
return this.http.get(absoluteURL, options)
.map(res => ({ payload: res.json(), statusCode: res.statusText }))
.catch(err => {
console.log('Error: ', err);
return Observable.throw(err);

View File

@@ -1,42 +1,42 @@
import { DSpaceObject } from "./dspace-object.model";
import { Bundle } from "./bundle.model";
import { RemoteData } from "../data/remote-data";
import { Item } from "./item.model";
export class Bitstream extends DSpaceObject {
/**
* The size of this bitstream in bytes(?)
*/
size: number;
/**
* The size of this bitstream in bytes
*/
sizeBytes: number;
/**
* The relative path to this Bitstream's file
*/
url: string;
/**
* The mime type of this Bitstream
*/
mimetype: string;
/**
* The mime type of this Bitstream
*/
mimetype: string;
/**
* The description of this Bitstream
*/
description: string;
/**
* The description of this Bitstream
*/
description: string;
/**
* The name of the Bundle this Bitstream is part of
*/
bundleName: string;
/**
* An array of Bundles that are direct parents of this Bitstream
*/
parents: RemoteData<Bundle[]>;
/**
* An array of Items that are direct parents of this Bitstream
*/
parents: RemoteData<Item[]>;
/**
* The Bundle that owns this Bitstream
*/
owner: Bundle;
/**
* The Bundle that owns this Bitstream
*/
owner: RemoteData<Item>;
/**
* The Bundle that owns this Bitstream
*/
retrieve: string;
/**
* The URL to retrieve this Bitstream's file
*/
retrieve: string;
}

View File

@@ -17,7 +17,7 @@ export class Bundle extends DSpaceObject {
/**
* The Item that owns this Bundle
*/
owner: Item;
owner: RemoteData<Item>;
bitstreams: RemoteData<Bitstream[]>

View File

@@ -63,7 +63,7 @@ export class Collection extends DSpaceObject {
/**
* The Collection that owns this Collection
*/
owner: Collection;
owner: RemoteData<Collection>;
items: RemoteData<Item[]>;

View File

@@ -55,7 +55,7 @@ export class Community extends DSpaceObject {
/**
* The Community that owns this Community
*/
owner: Community;
owner: RemoteData<Community>;
collections: RemoteData<Collection[]>;

View File

@@ -44,7 +44,7 @@ export abstract class DSpaceObject implements CacheableObject {
/**
* The DSpaceObject that owns this DSpaceObject
*/
owner: DSpaceObject;
owner: RemoteData<DSpaceObject>;
/**
* Find a metadata field by key and language

View File

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

View File

@@ -1,10 +1,9 @@
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";
import { hasValue } from "../../shared/empty.util";
import { isNotEmpty } from "../../shared/empty.util";
export class Item extends DSpaceObject {
@@ -23,6 +22,11 @@ export class Item extends DSpaceObject {
*/
isArchived: boolean;
/**
* A boolean representing if this Item is currently discoverable or not
*/
isDiscoverable: boolean;
/**
* A boolean representing if this Item is currently withdrawn or not
*/
@@ -36,9 +40,13 @@ export class Item extends DSpaceObject {
/**
* The Collection that owns this Item
*/
owner: Collection;
owningCollection: RemoteData<Collection>;
bundles: RemoteData<Bundle[]>;
get owner(): RemoteData<Collection> {
return this.owningCollection;
}
bitstreams: RemoteData<Bitstream[]>;
/**
@@ -46,57 +54,44 @@ export class Item extends DSpaceObject {
* @returns {Observable<Bitstream>} the primaryBitstream of the "THUMBNAIL" bundle
*/
getThumbnail(): Observable<Bitstream> {
const bundle: Observable<Bundle> = this.getBundle("THUMBNAIL");
return bundle
.filter(bundle => hasValue(bundle))
.flatMap(bundle => bundle.primaryBitstream.payload)
.startWith(undefined);
//TODO currently this just picks the first thumbnail
//should be adjusted when we have a way to determine
//the primary thumbnail from rest
return this.getBitstreamsByBundleName("THUMBNAIL")
.filter(thumbnails => isNotEmpty(thumbnails))
.map(thumbnails => thumbnails[0])
}
/**
* Retrieves the thumbnail for the given original of this item
* @returns {Observable<Bitstream>} the primaryBitstream of the "THUMBNAIL" bundle
*/
getThumbnailForOriginal(original: Bitstream): Observable<Bitstream> {
const bundle: Observable<Bundle> = this.getBundle("THUMBNAIL");
return bundle
.filter(bundle => hasValue(bundle))
.flatMap(bundle => bundle
.bitstreams.payload.map(files => files
.find(thumbnail => thumbnail
.name.startsWith(original.name)
)
)
)
.startWith(undefined);;
/**
* Retrieves the thumbnail for the given original of this item
* @returns {Observable<Bitstream>} the primaryBitstream of the "THUMBNAIL" bundle
*/
getThumbnailForOriginal(original: Bitstream): Observable<Bitstream> {
return this.getBitstreamsByBundleName("THUMBNAIL").map(files => files
.find(thumbnail => thumbnail
.name.startsWith(original.name)
)
).startWith(undefined);
}
/**
* Retrieves all files that should be displayed on the item page of this item
* @returns {Observable<Array<Observable<Bitstream>>>} an array of all Bitstreams in the "ORIGINAL" bundle
*/
getFiles(): Observable<Bitstream[]> {
return this.getBitstreamsByBundleName("ORIGINAL");
}
/**
* Retrieves all files that should be displayed on the item page of this item
* @returns {Observable<Array<Observable<Bitstream>>>} an array of all Bitstreams in the "ORIGINAL" bundle
*/
getFiles(name: String = "ORIGINAL"): Observable<Bitstream[]> {
const bundle: Observable <Bundle> = this.getBundle(name);
return bundle
.filter(bundle => hasValue(bundle))
.flatMap(bundle => bundle.bitstreams.payload)
.startWith([]);
}
/**
* Retrieves the bundle of this item by its name
* @param name The name of the Bundle that should be returned
* @returns {Observable<Bundle>} the Bundle that belongs to this item with the given name
*/
getBundle(name: String): Observable<Bundle> {
return this.bundles.payload
.filter(bundles => hasValue(bundles))
.map(bundles => {
return bundles.find((bundle: Bundle) => {
return bundle.name === name
});
})
.startWith(undefined);
/**
* Retrieves bitstreams by bundle name
* @param bundleName The name of the Bundle that should be returned
* @returns {Observable<Bitstream[]>} the bitstreams with the given bundleName
*/
getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> {
return this.bitstreams.payload.startWith([])
.map(bitstreams => bitstreams
.filter(bitstream => bitstream.bundleName === bundleName)
);
}
}

View File

@@ -0,0 +1,30 @@
import { autoserialize, autoserializeAs } from "cerialize";
/**
* Represents the state of a paginated response
*/
export class PageInfo {
/**
* The number of elements on a page
*/
@autoserializeAs(Number, 'size')
elementsPerPage: number;
/**
* The total number of elements in the entire set
*/
@autoserialize
totalElements: number;
/**
* The total number of pages
*/
@autoserialize
totalPages: number;
/**
* The number of the current page, zero-based
*/
@autoserializeAs(Number, 'number')
currentPage: number;
}

View File

@@ -5,6 +5,7 @@
export enum ResourceType {
Bundle = <any> "bundle",
Bitstream = <any> "bitstream",
BitstreamFormat = <any> "bitstreamformat",
Item = <any> "item",
Collection = <any> "collection",
Community = <any> "community"

View File

@@ -7,12 +7,14 @@ import { TopLevelCommunityListComponent } from "./top-level-community-list/top-l
import { HomeNewsComponent } from "./home-news/home-news.component";
import { RouterModule } from "@angular/router";
import { TranslateModule } from "@ngx-translate/core";
import { SharedModule } from "../shared/shared.module";
@NgModule({
imports: [
CommonModule,
HomeRoutingModule,
RouterModule,
SharedModule,
TranslateModule
],
declarations: [

View File

@@ -1,12 +1,10 @@
<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>
<ds-object-list [config]="config" [sortConfig]="sortConfig"
[objects]="topLevelCommunities" [hideGear]="true"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
(sortDirectionChange)="onSortDirectionChange($event)"
(sortFieldChange)="onSortDirectionChange($event)"></ds-object-list>
</div>

View File

@@ -1,18 +1,24 @@
import { Component, OnInit } from '@angular/core';
import { CommunityDataService } from "../../core/data/community-data.service";
import { Component, OnInit, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
import { RemoteData } from "../../core/data/remote-data";
import { CommunityDataService } from "../../core/data/community-data.service";
import { Community } from "../../core/shared/community.model";
import { PaginationComponentOptions } from "../../shared/pagination/pagination-component-options.model";
import { SortOptions, SortDirection } from "../../core/cache/models/sort-options.model";
@Component({
selector: 'ds-top-level-community-list',
styleUrls: ['./top-level-community-list.component.css'],
templateUrl: './top-level-community-list.component.html'
templateUrl: './top-level-community-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TopLevelCommunityListComponent implements OnInit {
topLevelCommunities: RemoteData<Community[]>;
config : PaginationComponentOptions;
sortConfig : SortOptions;
constructor(
private cds: CommunityDataService
private cds: CommunityDataService,
private ref: ChangeDetectorRef
) {
this.universalInit();
}
@@ -22,6 +28,38 @@ export class TopLevelCommunityListComponent implements OnInit {
}
ngOnInit(): void {
this.topLevelCommunities = this.cds.findAll();
this.config = new PaginationComponentOptions();
this.config.id = "top-level-pagination";
this.config.pageSizeOptions = [ 4 ];
this.config.pageSize = 4;
this.sortConfig = new SortOptions();
this.updateResults();
}
onPageChange(currentPage: number): void {
this.config.currentPage = currentPage;
this.updateResults();
}
onPageSizeChange(elementsPerPage: number): void {
this.config.pageSize = elementsPerPage;
this.updateResults();
}
onSortDirectionChange(sortDirection: SortDirection): void {
this.sortConfig = new SortOptions(this.sortConfig.field, sortDirection);
this.updateResults();
}
onSortFieldChange(field: string): void {
this.sortConfig = new SortOptions(field, this.sortConfig.direction);
this.updateResults();
}
updateResults() {
this.topLevelCommunities = undefined;
this.topLevelCommunities = this.cds.findAll({ currentPage: this.config.currentPage, elementsPerPage: this.config.pageSize, sort: this.sortConfig });
// this.ref.detectChanges();
}
}

View File

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

View File

@@ -2,6 +2,7 @@ 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";
import { RemoteDataBuildService } from "../../../core/cache/builders/remote-data-build.service";
/**
* This component renders the parent collections section of the item
@@ -18,11 +19,13 @@ export class CollectionsComponent implements OnInit {
label : string = "item.page.collections";
separator: string = "<br/>"
separator: string = "<br/>";
collections: Observable<Collection[]>;
constructor() {
constructor(
private rdbs: RemoteDataBuildService
) {
this.universalInit();
}
@@ -31,7 +34,11 @@ export class CollectionsComponent implements OnInit {
}
ngOnInit(): void {
this.collections = this.item.parents.payload;
// this.collections = this.item.parents.payload;
//TODO this should use parents, but the collections
// for an Item aren't returned by the REST API yet,
// only the owning collection
this.collections = this.item.owner.payload.map(c => [c]);
}

View File

@@ -9,7 +9,7 @@
<dd class="col-md-8">{{file.name}}</dd>
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<dd class="col-md-8">{{(file.size) | dsFileSize }}</dd>
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
@@ -21,7 +21,7 @@
</dl>
</div>
<div class="col-2">
<a [href]="file.retrieve">
<a [href]="file.retrieve" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</a>
</div>

View File

@@ -35,8 +35,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
}
initialize(): void {
const originals = this.item.getFiles("ORIGINAL");
const licenses = this.item.getFiles("LICENSE");
const originals = this.item.getFiles();
const licenses = this.item.getBitstreamsByBundleName("LICENSE");
this.files = Observable.combineLatest(originals, licenses, (originals, licenses) => [...originals, ...licenses]);
this.files.subscribe(
files =>

View File

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

View File

@@ -0,0 +1,6 @@
<a [routerLink]="['/collections/' + collection.id]" class="lead">
{{collection.name}}
</a>
<div *ngIf="collection.shortDescription" class="text-muted">
{{collection.shortDescription}}
</div>

View File

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

View File

@@ -0,0 +1,23 @@
import { Component, Input } from '@angular/core';
import { Collection } from "../../core/shared/collection.model";
@Component({
selector: 'ds-collection-list-element',
styleUrls: ['./collection-list-element.component.css'],
templateUrl: './collection-list-element.component.html'
})
export class CollectionListElementComponent {
@Input() collection: Collection;
data: any = {};
constructor() {
this.universalInit();
}
universalInit() {
}
}

View File

@@ -0,0 +1,6 @@
<a [routerLink]="['/communities/' + community.id]" class="lead">
{{community.name}}
</a>
<div *ngIf="community.shortDescription" class="text-muted">
{{community.shortDescription}}
</div>

View File

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

View File

@@ -0,0 +1,23 @@
import { Component, Input } from '@angular/core';
import { Community } from "../../core/shared/community.model";
@Component({
selector: 'ds-community-list-element',
styleUrls: ['./community-list-element.component.css'],
templateUrl: './community-list-element.component.html'
})
export class CommunityListElementComponent {
@Input() community: Community;
data: any = {};
constructor() {
this.universalInit();
}
universalInit() {
}
}

View File

@@ -0,0 +1,14 @@
<a [routerLink]="['/items/' + item.id]" class="lead">
{{item.findMetadata("dc.title")}}
</a>
<div>
<span class="text-muted">
<span *ngIf="item.filterMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']);" class="item-list-authors">
<span *ngFor="let authorMd of item.filterMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']); let last=last;">{{authorMd.value}}
<span *ngIf="!last">; </span>
</span>
</span>
(<span *ngIf="item.findMetadata('dc.publisher')" class="item-list-publisher">{{item.findMetadata("dc.publisher")}}, </span><span *ngIf="item.findMetadata('dc.date.issued')" class="item-list-date">{{item.findMetadata("dc.date.issued")}}</span>)
</span>
<div *ngIf="item.findMetadata('dc.description.abstract')" class="item-list-abstract">{{item.findMetadata("dc.description.abstract") | dsTruncate:[200] }}</div>
</div>

View File

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

View File

@@ -0,0 +1,22 @@
import { Component, Input } from '@angular/core';
import { Item } from "../../core/shared/item.model";
@Component({
selector: 'ds-item-list-element',
styleUrls: ['./item-list-element.component.css'],
templateUrl: './item-list-element.component.html'
})
export class ItemListElementComponent {
@Input() item: Item;
data: any = {};
constructor() {
this.universalInit();
}
universalInit() {
}
}

View File

@@ -0,0 +1,5 @@
<div [ngSwitch]="object.type">
<ds-item-list-element *ngSwitchCase="type.Item" [item]="object"></ds-item-list-element>
<ds-collection-list-element *ngSwitchCase="type.Collection" [collection]="object"></ds-collection-list-element>
<ds-community-list-element *ngSwitchCase="type.Community" [community]="object"></ds-community-list-element>
</div>

View File

@@ -0,0 +1,7 @@
@import '../../../styles/variables.scss';
@import '../../../../node_modules/bootstrap/scss/variables';
:host {
display: block;
margin-bottom: $spacer-y;
}

View File

@@ -0,0 +1,26 @@
import { Component, Input, OnInit } from '@angular/core';
import { DSpaceObject } from "../../core/shared/dspace-object.model";
import { ResourceType } from "../../core/shared/resource-type";
@Component({
selector: 'ds-object-list-element',
styleUrls: ['./object-list-element.component.css'],
templateUrl: './object-list-element.component.html'
})
export class ObjectListElementComponent {
public type = ResourceType;
@Input() object: DSpaceObject;
data: any = {};
constructor() {
this.universalInit();
}
universalInit() {
}
}

View File

@@ -0,0 +1,16 @@
<ds-pagination [paginationOptions]="config"
[collectionSize]="(pageInfo | async)?.totalElements"
[sortOptions]="sortConfig"
[hideGear]="hideGear"
[hidePagerWhenSinglePage]="hidePagerWhenSinglePage"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
(sortDirectionChange)="onSortDirectionChange($event)"
(sortFieldChange)="onSortDirectionChange($event)">
<ul *ngIf="objects.hasSucceeded | async"> <!--class="list-unstyled"-->
<li *ngFor="let object of (objects.payload | async) | paginate: { itemsPerPage: (pageInfo | async)?.elementsPerPage, currentPage: (pageInfo | async)?.currentPage, totalItems: (pageInfo | async)?.totalElements }">
<ds-object-list-element [object]="object"></ds-object-list-element>
</li>
</ul>
</ds-pagination>

View File

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

View File

@@ -1,3 +1,3 @@
<div *ngIf="logo" class="dso-logo">
<img [src]="logo.url" class="img-responsive" [attr.alt]="alternateText ? alternateText : null" />
</div>
<img [src]="logo.retrieve" class="img-responsive" [attr.alt]="alternateText ? alternateText : null" (error)="errorHandler($event)"/>
</div>

View File

@@ -12,4 +12,14 @@ export class ComcolPageLogoComponent {
@Input() logo: Bitstream;
@Input() alternateText: string;
}
/**
* The default 'holder.js' image
*/
holderSource: string = "data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23EEEEEE%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E";
errorHandler(event) {
event.currentTarget.src = this.holderSource;
}
}

View File

@@ -0,0 +1,82 @@
import {
Component, Input, ViewEncapsulation, ChangeDetectionStrategy,
OnInit, Output
} from '@angular/core';
import { RemoteData } from "../../core/data/remote-data";
import { DSpaceObject } from "../../core/shared/dspace-object.model";
import { PageInfo } from "../../core/shared/page-info.model";
import { Observable } from "rxjs";
import { PaginationComponentOptions } from "../pagination/pagination-component-options.model";
import { EventEmitter } from "@angular/common/src/facade/async";
import { SortOptions, SortDirection } from "../../core/cache/models/sort-options.model";
@Component({
changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.Emulated,
selector: 'ds-object-list',
styleUrls: ['../../object-list/object-list.component.css'],
templateUrl: '../../object-list/object-list.component.html'
})
export class ObjectListComponent implements OnInit {
@Input() objects: RemoteData<DSpaceObject[]>;
@Input() config : PaginationComponentOptions;
@Input() sortConfig : SortOptions;
@Input() hideGear : boolean = false;
@Input() hidePagerWhenSinglePage : boolean = true;
pageInfo : Observable<PageInfo>;
/**
* An event fired when the page is changed.
* Event's payload equals to the newly selected page.
*/
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>();
/**
* An event fired when the page wsize is changed.
* Event's payload equals to the newly selected page size.
*/
@Output() pageSizeChange: EventEmitter<number> = new EventEmitter<number>();
/**
* An event fired when the sort direction is changed.
* Event's payload equals to the newly selected sort direction.
*/
@Output() sortDirectionChange: EventEmitter<SortDirection> = new EventEmitter<SortDirection>();
/**
* An event fired when the sort field is changed.
* Event's payload equals to the newly selected sort field.
*/
@Output() sortFieldChange: EventEmitter<string> = new EventEmitter<string>();
data: any = {};
constructor() {
this.universalInit();
}
universalInit() {
}
ngOnInit(): void {
this.pageInfo = this.objects.pageInfo;
}
onPageChange(event) {
this.pageChange.emit(event);
}
onPageSizeChange(event) {
this.pageSizeChange.emit(event);
}
onSortDirectionChange(event) {
this.sortDirectionChange.emit(event);
}
onSortFieldChange(event) {
this.sortFieldChange.emit(event);
}
}

View File

@@ -1,6 +1,6 @@
import { NgbPaginationConfig } from '@ng-bootstrap/ng-bootstrap';
export class PaginationOptions extends NgbPaginationConfig {
export class PaginationComponentOptions extends NgbPaginationConfig {
/**
* ID for the pagination instance. Only useful if you wish to
* have more than once instance at a time in a given component.

View File

@@ -1,4 +1,4 @@
<div class="pagination-masked clearfix top">
<div *ngIf="!hideGear" class="pagination-masked clearfix top">
<div class="row">
<div class="col pagination-info">
<span class="align-middle hidden-xs-down">{{ 'pagination.showing.label' | translate }}</span>
@@ -9,7 +9,9 @@
<button class="btn btn-outline-primary" id="paginationControls" (click)="$event.stopPropagation(); (paginationControls.isOpen())?paginationControls.close():paginationControls.open();"><i class="fa fa-cog" aria-hidden="true"></i></button>
<div class="dropdown-menu dropdown-menu-right" id="paginationControlsDropdownMenu" aria-labelledby="paginationControls">
<h6 class="dropdown-header">{{ 'pagination.results-per-page' | translate}}</h6>
<button class="dropdown-item" style="padding-left: 20px" *ngFor="let item of pageSizeOptions " (click)="setPageSize(item)"><i class="fa fa-check {{(item != paginationOptions.pageSize) ? 'invisible' : ''}}" aria-hidden="true"></i> {{item}} </button>
<button class="dropdown-item" style="padding-left: 20px" *ngFor="let item of pageSizeOptions" (click)="setPageSize(item)"><i class="fa fa-check {{(item != paginationOptions.pageSize) ? 'invisible' : ''}}" aria-hidden="true"></i> {{item}} </button>
<h6 class="dropdown-header">{{ 'pagination.sort-direction' | translate}}</h6>
<button class="dropdown-item" style="padding-left: 20px" *ngFor="let direction of (sortDirections | dsKeys)" (click)="setSortDirection(direction.key)"><i class="fa fa-check {{(direction.key != sortOptions.direction) ? 'invisible' : ''}}" aria-hidden="true"></i> {{direction.value}} </button>
</div>
</div>
</div>
@@ -18,7 +20,7 @@
<ng-content></ng-content>
<div class="pagination justify-content-center clearfix bottom">
<div *ngIf="shouldShowBottomPager" class="pagination justify-content-center clearfix bottom">
<ngb-pagination [boundaryLinks]="paginationOptions.boundaryLinks"
[collectionSize]="collectionSize"
[disabled]="paginationOptions.disabled"

View File

@@ -25,12 +25,14 @@ import { Ng2PaginationModule } from 'ng2-pagination';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { PaginationComponent } from './pagination.component';
import { PaginationOptions } from '../../core/cache/models/pagination-options.model';
import { PaginationComponentOptions } from './pagination-component-options.model';
import { MockTranslateLoader } from "../testing/mock-translate-loader";
import { GLOBAL_CONFIG, EnvConfig } from '../../../config';
import { ActivatedRouteStub, RouterStub } from "../testing/router-stubs";
import { HostWindowService } from "../host-window.service";
import { EnumKeysPipe } from "../utils/enum-keys-pipe";
import { SortOptions } from "../../core/cache/models/sort-options.model";
function createTestComponent<T>(html: string, type: {new (...args: any[]): T}): ComponentFixture<T> {
@@ -138,7 +140,7 @@ describe('Pagination component', () => {
RouterTestingModule.withRoutes([
{path: 'home', component: TestComponent}
])],
declarations: [PaginationComponent, TestComponent], // declare the test component
declarations: [PaginationComponent, TestComponent, EnumKeysPipe], // declare the test component
providers: [
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: GLOBAL_CONFIG, useValue: EnvConfig },
@@ -156,6 +158,7 @@ describe('Pagination component', () => {
html = `
<ds-pagination #p="paginationComponent"
[paginationOptions]="paginationOptions"
[sortOptions]="sortOptions"
[collectionSize]="collectionSize"
(pageChange)="pageChanged($event)"
(pageSizeChange)="pageSizeChanged($event)">
@@ -247,12 +250,12 @@ describe('Pagination component', () => {
changePage(testFixture, 3);
tick();
expect(routerStub.navigate).toHaveBeenCalledWith([], { queryParams: { pageId: 'test', page: 3, pageSize: 10 } });
expect(routerStub.navigate).toHaveBeenCalledWith([], { queryParams: { pageId: 'test', page: 3, pageSize: 10, sortDirection: 0, sortField: 'name' } });
expect(paginationComponent.currentPage).toEqual(3);
changePageSize(testFixture, '20');
tick();
expect(routerStub.navigate).toHaveBeenCalledWith([], { queryParams: { pageId: 'test', page: 3, pageSize: 20 } });
expect(routerStub.navigate).toHaveBeenCalledWith([], { queryParams: { pageId: 'test', page: 3, pageSize: 20, sortDirection: 0, sortField: 'name' } });
expect(paginationComponent.pageSize).toEqual(20);
}));
@@ -307,7 +310,8 @@ class TestComponent {
collection: string[] = [];
collectionSize: number;
paginationOptions = new PaginationOptions();
paginationOptions = new PaginationComponentOptions();
sortOptions = new SortOptions();
constructor() {
this.collection = Array.from(new Array(100), (x, i) => `item ${i + 1}`);

View File

@@ -1,14 +1,15 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewEncapsulation
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from "rxjs/Subscription";
import { isNumeric } from "rxjs/util/isNumeric";
import 'rxjs/add/operator/switchMap';
import { Observable } from "rxjs";
@@ -17,231 +18,338 @@ import { DEFAULT_TEMPLATE, DEFAULT_STYLES } from 'ng2-pagination/dist/template';
import { HostWindowService } from "../host-window.service";
import { HostWindowState } from "../host-window.reducer";
import { PaginationOptions } from '../../core/cache/models/pagination-options.model';
import { PaginationComponentOptions } from './pagination-component-options.model';
import { SortDirection, SortOptions } from "../../core/cache/models/sort-options.model";
import { hasValue } from "../empty.util";
/**
* The default pagination controls component.
*/
@Component({
exportAs: 'paginationComponent',
selector: 'ds-pagination',
templateUrl: 'pagination.component.html',
changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.Emulated
exportAs: 'paginationComponent',
selector: 'ds-pagination',
templateUrl: 'pagination.component.html',
changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.Emulated
})
export class PaginationComponent implements OnDestroy, OnInit {
/**
* Number of items in collection.
*/
@Input() collectionSize: number;
/**
* Number of items in collection.
*/
@Input() collectionSize: number;
/**
* Configuration for the NgbPagination component.
*/
@Input() paginationOptions: PaginationComponentOptions;
/**
* Sort configuration for this component.
*/
@Input() sortOptions: SortOptions;
/**
* An event fired when the page is changed.
* Event's payload equals to the newly selected page.
*/
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>();
/**
* An event fired when the page wsize is changed.
* Event's payload equals to the newly selected page size.
*/
@Output() pageSizeChange: EventEmitter<number> = new EventEmitter<number>();
/**
* An event fired when the sort direction is changed.
* Event's payload equals to the newly selected sort direction.
*/
@Output() sortDirectionChange: EventEmitter<SortDirection> = new EventEmitter<SortDirection>();
/**
* An event fired when the sort field is changed.
* Event's payload equals to the newly selected sort field.
*/
@Output() sortFieldChange: EventEmitter<string> = new EventEmitter<string>();
/**
* Option for hiding the gear
*/
@Input() public hideGear: boolean = false;
/**
* Option for hiding the pager when there is less than 2 pages
*/
@Input() public hidePagerWhenSinglePage: boolean = true;
/**
* Current page.
*/
public currentPage = 1;
/**
* Current URL query parameters
*/
public currentQueryParams = {};
/**
* An observable of HostWindowState type
*/
public hostWindow: Observable<HostWindowState>;
/**
* ID for the pagination instance. Only useful if you wish to
* have more than once instance at a time in a given component.
*/
private id: string;
/**
* A boolean that indicate if is an extra small devices viewport.
*/
public isXs: boolean;
/**
* Number of items per page.
*/
public pageSize: number = 10;
/**
* Declare SortDirection enumeration to use it in the template
*/
public sortDirections = SortDirection
/**
* A number array that represents options for a context pagination limit.
*/
private pageSizeOptions: Array<number>;
/**
* Direction in which to sort: ascending or descending
*/
public sortDirection: SortDirection = SortDirection.Ascending;
/**
* Name of the field that's used to sort by
*/
public sortField: string = "id";
/**
* Local variable, which can be used in the template to access the paginate controls ngbDropdown methods and properties
*/
public paginationControls;
/**
* Configuration for the NgbPagination component.
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
@Input() paginationOptions: PaginationOptions;
private subs: Subscription[] = [];
/**
* An event fired when the page is changed.
* Event's payload equals to the newly selected page.
*/
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>();
* An object that represents pagination details of the current viewed page
*/
public showingDetail: any = {
range: null,
total: null
};
/**
* An event fired when the page size is changed.
* Event's payload equals to the newly selected page size.
*/
@Output() pageSizeChange: EventEmitter<number> = new EventEmitter<number>();
/**
* Method provided by Angular. Invoked after the constructor.
*/
ngOnInit() {
this.subs.push(this.hostWindowService.isXs()
.subscribe((status: boolean) => {
this.isXs = status;
}));
this.checkConfig(this.paginationOptions);
this.id = this.paginationOptions.id || null;
this.currentPage = this.paginationOptions.currentPage;
this.pageSize = this.paginationOptions.pageSize;
this.pageSizeOptions = this.paginationOptions.pageSizeOptions;
this.sortDirection = this.sortOptions.direction;
this.sortField = this.sortOptions.field;
this.subs.push(this.route.queryParams
.filter(queryParams => hasValue(queryParams))
.subscribe(queryParams => {
this.currentQueryParams = queryParams;
if (this.id == queryParams['pageId']
&& (this.paginationOptions.currentPage != queryParams['page']
|| this.paginationOptions.pageSize != queryParams['pageSize']
|| this.sortOptions.direction != queryParams['sortDirection']
|| this.sortOptions.field != queryParams['sortField'] )
) {
this.validateParams(queryParams['page'], queryParams['pageSize'], queryParams['sortDirection'], queryParams['sortField']);
}
}));
this.setShowingDetail();
}
/**
* Current page.
*/
public currentPage = 1;
/**
* Current URL query parameters
*/
public currentQueryParams = {};
/**
* Method provided by Angular. Invoked when the instance is destroyed.
*/
ngOnDestroy() {
this.subs
.filter(sub => hasValue(sub))
.forEach(sub => sub.unsubscribe());
}
/**
* An observable of HostWindowState type
*/
public hostWindow: Observable<HostWindowState>;
/**
* @param route
* Route is a singleton service provided by Angular.
* @param router
* Router is a singleton service provided by Angular.
*/
constructor(private route: ActivatedRoute,
private router: Router,
public hostWindowService: HostWindowService) {
}
/**
* ID for the pagination instance. Only useful if you wish to
* have more than once instance at a time in a given component.
*/
private id: string;
/**
* Method to set set new page and update route parameters
*
* @param page
* The page being navigated to.
*/
public doPageChange(page: number) {
this.currentPage = page;
this.updateRoute();
this.setShowingDetail();
this.pageChange.emit(page);
}
/**
* A boolean that indicate if is an extra small devices viewport.
*/
public isXs: boolean;
/**
* Method to set set new page size and update route parameters
*
* @param pageSize
* The new page size.
*/
public setPageSize(pageSize: number) {
this.pageSize = pageSize;
this.updateRoute();
this.setShowingDetail();
this.pageSizeChange.emit(pageSize);
}
/**
* Number of items per page.
*/
public pageSize: number = 10;
/**
* Method to set set new sort direction and update route parameters
*
* @param sortDirection
* The new sort direction.
*/
public setSortDirection(sortDirection: SortDirection) {
this.sortDirection = sortDirection;
this.updateRoute();
this.setShowingDetail();
this.sortDirectionChange.emit(sortDirection);
}
/**
* A number array that represents options for a context pagination limit.
*/
private pageSizeOptions: Array<number>;
/**
* Method to set set new sort field and update route parameters
*
* @param sortField
* The new sort field.
*/
public setSortField(field: string) {
this.sortField = field;
this.updateRoute();
this.setShowingDetail();
this.sortFieldChange.emit(field);
}
/**
* Local variable, which can be used in the template to access the paginate controls ngbDropdown methods and properties
*/
public paginationControls;
/**
* Method to update the route parameters
*/
private updateRoute() {
this.router.navigate([], {
queryParams: Object.assign({}, this.currentQueryParams, {
pageId: this.id,
page: this.currentPage,
pageSize: this.pageSize,
sortDirection: this.sortDirection,
sortField: this.sortField
})
});
}
/**
* Subscriber to observable.
*/
private routeSubscription: any;
/**
* Method to set pagination details of the current viewed page.
*/
private setShowingDetail() {
let firstItem;
let lastItem;
let lastPage = Math.round(this.collectionSize / this.pageSize);
/**
* An object that represents pagination details of the current viewed page
*/
public showingDetail: any = {
range: null,
total: null
};
/**
* Subscriber to observable.
*/
private stateSubscription: any;
/**
* Method provided by Angular. Invoked after the constructor.
*/
ngOnInit() {
this.stateSubscription = this.hostWindowService.isXs()
.subscribe((status: boolean) => {
this.isXs = status;
});
this.checkConfig(this.paginationOptions);
this.id = this.paginationOptions.id || null;
this.currentPage = this.paginationOptions.currentPage;
this.pageSize = this.paginationOptions.pageSize;
this.pageSizeOptions = this.paginationOptions.pageSizeOptions;
this.routeSubscription = this.route.queryParams
.map(queryParams => queryParams)
.subscribe(queryParams => {
this.currentQueryParams = queryParams;
if(this.id == queryParams['pageId']
&& (this.paginationOptions.currentPage != queryParams['page']
|| this.paginationOptions.pageSize != queryParams['pageSize'])
) {
this.validateParams(queryParams['page'], queryParams['pageSize']);
firstItem = this.pageSize * (this.currentPage - 1) + 1;
if (this.currentPage != lastPage) {
lastItem = this.pageSize * this.currentPage;
} else {
lastItem = this.collectionSize;
}
this.showingDetail = {
range: firstItem + ' - ' + lastItem,
total: this.collectionSize
}
});
this.setShowingDetail();
}
/**
* Method provided by Angular. Invoked when the instance is destroyed.
*/
ngOnDestroy() {
this.stateSubscription.unsubscribe();
this.routeSubscription.unsubscribe();
}
/**
* @param route
* Route is a singleton service provided by Angular.
* @param router
* Router is a singleton service provided by Angular.
*/
constructor(
private route: ActivatedRoute,
private router: Router,
public hostWindowService: HostWindowService
){
}
/**
* Method to set set new page and update route parameters
*
* @param page
* The page being navigated to.
*/
public doPageChange(page: number) {
this.router.navigate([], { queryParams: Object.assign({}, this.currentQueryParams, { pageId: this.id, page: page, pageSize: this.pageSize }) });
this.currentPage = page;
this.setShowingDetail();
this.pageChange.emit(page);
}
/**
* Method to set set new page size and update route parameters
*
* @param pageSize
* The new page size.
*/
public setPageSize(pageSize: number) {
this.router.navigate([], { queryParams: Object.assign({}, this.currentQueryParams, { pageId: this.id, page: this.currentPage, pageSize: pageSize }) });
this.pageSize = pageSize;
this.setShowingDetail();
this.pageSizeChange.emit(pageSize);
}
/**
* Method to set pagination details of the current viewed page.
*/
private setShowingDetail() {
let firstItem;
let lastItem;
let lastPage = Math.round(this.collectionSize / this.pageSize);
firstItem = this.pageSize * (this.currentPage - 1) + 1;
if (this.currentPage != lastPage) {
lastItem = this.pageSize * this.currentPage;
} else {
lastItem = this.collectionSize;
}
this.showingDetail = {
range: firstItem + ' - ' + lastItem,
total: this.collectionSize
}
}
/**
* Validate query params
*
* @param page
* The page number to validate
* @param pageSize
* The page size to validate
*/
private validateParams(page: any, pageSize: any) {
let filteredPageSize = this.pageSizeOptions.find(x => x == pageSize);
if (!isNumeric(page) || !filteredPageSize) {
let filteredPage = isNumeric(page) ? page : this.currentPage;
filteredPageSize = (filteredPageSize) ? filteredPageSize : this.pageSize;
this.router.navigate([{ pageId: this.id, page: filteredPage, pageSize: filteredPageSize }]);
} else {
// (+) converts string to a number
this.currentPage = +page;
this.pageSize = +pageSize;
this.pageChange.emit(this.currentPage);
this.pageSizeChange.emit(this.pageSize);
/**
* Validate query params
*
* @param page
* The page number to validate
* @param pageSize
* The page size to validate
*/
private validateParams(page: any, pageSize: any, sortDirection: any, sortField: any) {
let filteredPageSize = this.pageSizeOptions.find(x => x == pageSize);
if (!isNumeric(page) || !filteredPageSize) {
let filteredPage = isNumeric(page) ? page : this.currentPage;
filteredPageSize = (filteredPageSize) ? filteredPageSize : this.pageSize;
this.router.navigate([], {
queryParams: {
pageId: this.id,
page: filteredPage,
pageSize: filteredPageSize,
sortDirection: sortDirection,
sortField: sortField
}
}
);
} else {
// (+) converts string to a number
this.currentPage = +page;
this.pageSize = +pageSize;
this.sortDirection = +sortDirection;
this.sortField = sortField;
this.pageChange.emit(this.currentPage);
this.pageSizeChange.emit(this.pageSize);
this.sortDirectionChange.emit(this.sortDirection);
this.sortFieldChange.emit(this.sortField);
}
}
}
/**
* Ensure options passed contains the required properties.
*
* @param paginateOptions
* The paginate options object.
*/
private checkConfig(paginateOptions: any) {
let required = ['id', 'currentPage', 'pageSize', 'pageSizeOptions'];
let missing = required.filter(function (prop) { return !(prop in paginateOptions); });
if (0 < missing.length) {
throw new Error("Paginate: Argument is missing the following required properties: " + missing.join(', '));
/**
* Ensure options passed contains the required properties.
*
* @param paginateOptions
* The paginate options object.
*/
private checkConfig(paginateOptions: any) {
let required = ['id', 'currentPage', 'pageSize', 'pageSizeOptions'];
let missing = required.filter(function (prop) {
return !(prop in paginateOptions);
});
if (0 < missing.length) {
throw new Error("Paginate: Argument is missing the following required properties: " + missing.join(', '));
}
}
get hasMultiplePages(): boolean {
return this.collectionSize > this.pageSize;
}
get shouldShowBottomPager(): boolean {
return this.hasMultiplePages || !this.hidePagerWhenSinglePage
}
}
}

View File

@@ -17,6 +17,13 @@ import { NativeWindowFactory, NativeWindowService } from "./window.service";
import { ComcolPageContentComponent } from "./comcol-page-content/comcol-page-content.component";
import { ComcolPageHeaderComponent } from "./comcol-page-header/comcol-page-header.component";
import { ComcolPageLogoComponent } from "./comcol-page-logo/comcol-page-logo.component";
import { EnumKeysPipe } from "./utils/enum-keys-pipe";
import { ObjectListComponent } from "./object-list/object-list.component";
import { ObjectListElementComponent } from "../object-list/object-list-element/object-list-element.component";
import { ItemListElementComponent } from "../object-list/item-list-element/item-list-element.component";
import { CommunityListElementComponent } from "../object-list/community-list-element/community-list-element.component";
import { CollectionListElementComponent } from "../object-list/collection-list-element/collection-list-element.component";
import { TruncatePipe } from "./utils/truncate.pipe";
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -31,7 +38,9 @@ const MODULES = [
const PIPES = [
FileSizePipe,
SafeUrlPipe
SafeUrlPipe,
EnumKeysPipe,
TruncatePipe
// put pipes here
];
@@ -41,7 +50,12 @@ const COMPONENTS = [
ThumbnailComponent,
ComcolPageContentComponent,
ComcolPageHeaderComponent,
ComcolPageLogoComponent
ComcolPageLogoComponent,
ObjectListComponent,
ObjectListElementComponent,
ItemListElementComponent,
CollectionListElementComponent,
CommunityListElementComponent
];
const PROVIDERS = [

View File

@@ -0,0 +1,13 @@
import { Pipe, PipeTransform } from "@angular/core";
@Pipe({ name: 'dsKeys' })
export class EnumKeysPipe implements PipeTransform {
transform(value, args: string[]): any {
let keys = [];
for (var enumMember in value) {
if (!isNaN(parseInt(enumMember, 10))) {
keys.push({ key: +enumMember, value: value[enumMember] });
}
}
return keys;
}
}

View File

@@ -0,0 +1,26 @@
import { Pipe, PipeTransform } from '@angular/core'
import { hasValue } from "../empty.util";
/**
* Pipe to truncate a value in Angular. (Take a substring, starting at 0)
* Default value: 10
*/
@Pipe({
name: 'dsTruncate'
})
export class TruncatePipe implements PipeTransform {
/**
*
*/
transform(value: string, args: Array<string>) : string {
if (hasValue(value)) {
let limit = (args && args.length > 0) ? parseInt(args[0], 10) : 10; // 10 as default truncate value
return value.length > limit ? value.substring(0, limit) + "..." : value;
}
else {
return value;
}
}
}

View File

@@ -1,4 +1,4 @@
<div class="thumbnail">
<img *ngIf="thumbnail" [src]="thumbnail.retrieve"/>
<img *ngIf="!thumbnail" [src]="holderSource | dsSafeUrl"/>
</div>
<img *ngIf="thumbnail" [src]="thumbnail.retrieve" (error)="errorHandler($event)"/>
<img *ngIf="!thumbnail" [src]="holderSource | dsSafeUrl"/>
</div>

View File

@@ -1,4 +1,4 @@
import { Component, Input, OnInit } from '@angular/core';
import { Component, Input } from '@angular/core';
import { Bitstream } from "../core/shared/bitstream.model";
/**
@@ -14,21 +14,24 @@ import { Bitstream } from "../core/shared/bitstream.model";
})
export class ThumbnailComponent {
@Input() thumbnail: Bitstream;
@Input() thumbnail: Bitstream;
data: any = {};
data: any = {};
/**
* The default 'holder.js' image
*/
holderSource: string = "data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23EEEEEE%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E";
/**
* The default 'holder.js' image
*/
holderSource: string = "data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23EEEEEE%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E";
constructor() {
this.universalInit();
}
constructor() {
this.universalInit();
}
universalInit() {
universalInit() {
}
}
errorHandler(event) {
event.currentTarget.src = this.holderSource;
}
}