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. // The REST API server settings.
"rest": { "rest": {
"ssl": false, "ssl": false,
"address": "localhost", "address": "dspace7.4science.it",
"port": 3000, "port": 80,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
"nameSpace": "/api" "nameSpace": "/dspace-spring-rest/api"
}, },
// Angular2 UI server settings. // Angular2 UI server settings.
"ui": { "ui": {

View File

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

View File

@@ -1,32 +1,44 @@
<div class="collection-page" *ngIf="collectionData.hasSucceeded | async"> <div class="collection-page">
<!-- Collection Name --> <div *ngIf="collectionData.hasSucceeded | async">
<ds-comcol-page-header <!-- Collection Name -->
[name]="(collectionData.payload | async)?.name"> <ds-comcol-page-header
</ds-comcol-page-header> [name]="(collectionData.payload | async)?.name">
<!-- Collection logo --> </ds-comcol-page-header>
<ds-comcol-page-logo *ngIf="logoData" <!-- Collection logo -->
[logo]="logoData.payload | async" <ds-comcol-page-logo *ngIf="logoData"
[alternateText]="'Collection Logo'"> [logo]="logoData.payload | async"
</ds-comcol-page-logo> [alternateText]="'Collection Logo'">
<!-- Introductionary text --> </ds-comcol-page-logo>
<ds-comcol-page-content <!-- Introductionary text -->
<ds-comcol-page-content
[content]="(collectionData.payload | async)?.introductoryText" [content]="(collectionData.payload | async)?.introductoryText"
[hasInnerHtml]="true"> [hasInnerHtml]="true">
</ds-comcol-page-content> </ds-comcol-page-content>
<!-- News --> <!-- News -->
<ds-comcol-page-content <ds-comcol-page-content
[content]="(collectionData.payload | async)?.sidebarText" [content]="(collectionData.payload | async)?.sidebarText"
[hasInnerHtml]="true" [hasInnerHtml]="true"
[title]="'community.page.news'"> [title]="'community.page.news'">
</ds-comcol-page-content> </ds-comcol-page-content>
<!-- Copyright --> <!-- Copyright -->
<ds-comcol-page-content <ds-comcol-page-content
[content]="(collectionData.payload | async)?.copyrightText" [content]="(collectionData.payload | async)?.copyrightText"
[hasInnerHtml]="true"> [hasInnerHtml]="true">
</ds-comcol-page-content> </ds-comcol-page-content>
<!-- Licence --> <!-- Licence -->
<ds-comcol-page-content <ds-comcol-page-content
[content]="(collectionData.payload | async)?.license" [content]="(collectionData.payload | async)?.license"
[title]="'collection.page.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> </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 { ActivatedRoute, Params } from '@angular/router';
import { Collection } from "../core/shared/collection.model"; 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 { RemoteData } from "../core/data/remote-data";
import { CollectionDataService } from "../core/data/collection-data.service"; import { CollectionDataService } from "../core/data/collection-data.service";
import { Subscription } from "rxjs/Subscription"; 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({ @Component({
selector: 'ds-collection-page', selector: 'ds-collection-page',
styleUrls: ['./collection-page.component.css'], styleUrls: ['./collection-page.component.css'],
templateUrl: './collection-page.component.html', templateUrl: './collection-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CollectionPageComponent implements OnInit, OnDestroy { export class CollectionPageComponent implements OnInit, OnDestroy {
collectionData: RemoteData<Collection>; collectionData: RemoteData<Collection>;
itemData: RemoteData<Item[]>;
logoData: RemoteData<Bitstream>; logoData: RemoteData<Bitstream>;
config : PaginationComponentOptions;
sortConfig : SortOptions;
private subs: Subscription[] = []; private subs: Subscription[] = [];
private collectionId: string;
constructor( constructor(
private collectionDataService: CollectionDataService, private collectionDataService: CollectionDataService,
private itemDataService: ItemDataService,
private ref: ChangeDetectorRef,
private route: ActivatedRoute private route: ActivatedRoute
) { ) {
this.universalInit(); this.universalInit();
} }
ngOnInit(): void { ngOnInit(): void {
this.route.params.subscribe((params: Params) => { this.subs.push(this.route.params.map((params: Params) => params['id'] )
this.collectionData = this.collectionDataService.findById(params['id']); .subscribe((id: string) => {
this.subs.push(this.collectionData.payload this.collectionId = id;
.subscribe(collection => this.logoData = collection.logo)); 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 { ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe()); this.subs
.filter(sub => hasValue(sub))
.forEach(sub => sub.unsubscribe());
} }
universalInit() { 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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from "@ngx-translate/core";
import { SharedModule } from '../shared/shared.module'; import { SharedModule } from '../shared/shared.module';
import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageRoutingModule } from './collection-page-routing.module'; import { CollectionPageRoutingModule } from './collection-page-routing.module';
@@ -11,8 +11,8 @@ import { CollectionPageRoutingModule } from './collection-page-routing.module';
imports: [ imports: [
CollectionPageRoutingModule, CollectionPageRoutingModule,
CommonModule, CommonModule,
TranslateModule,
SharedModule, SharedModule,
TranslateModule,
], ],
declarations: [ declarations: [
CollectionPageComponent, CollectionPageComponent,

View File

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

View File

@@ -16,7 +16,7 @@ export const getMapsTo = function(target: any) {
return Reflect.getOwnMetadata(mapsToMetadataKey, target); 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) { return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
if (!target || !propertyKey) { if (!target || !propertyKey) {
return; return;
@@ -28,11 +28,11 @@ export const relationship = function(value: ResourceType): any {
} }
relationshipMap.set(target.constructor, metaDataList); 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); 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 { Observable } from "rxjs/Observable";
import { RemoteData } from "../../data/remote-data"; import { RemoteData } from "../../data/remote-data";
import { GenericConstructor } from "../../shared/generic-constructor"; 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 { NormalizedObjectFactory } from "../models/normalized-object-factory";
import { Request } from "../../data/request.models"; import { Request } from "../../data/request.models";
@@ -55,17 +55,54 @@ export class RemoteDataBuildService {
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage) .map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged(); .distinctUntilChanged();
const payload = this.objectCache.getBySelfLink<TNormalized>(href, normalizedType) const statusCode = responseCacheObs
.map((normalized: TNormalized) => { .map((entry: ResponseCacheEntry) => entry.response.statusCode)
return this.build<TNormalized, TDomain>(normalized); .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( return new RemoteData(
href, href,
requestPending, requestPending,
responsePending, responsePending,
isSuccessFul, isSuccessFul,
errorMessage, errorMessage,
statusCode,
pageInfo,
payload payload
); );
} }
@@ -90,6 +127,15 @@ export class RemoteDataBuildService {
.map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage) .map((entry: ResponseCacheEntry) => (<ErrorResponse> entry.response).errorMessage)
.distinctUntilChanged(); .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 const payload = responseCacheObs
.filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful)
.map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs) .map((entry: ResponseCacheEntry) => (<SuccessResponse> entry.response).resourceUUIDs)
@@ -109,6 +155,8 @@ export class RemoteDataBuildService {
responsePending, responsePending,
isSuccessFul, isSuccessFul,
errorMessage, errorMessage,
statusCode,
pageInfo,
payload payload
); );
} }
@@ -121,7 +169,7 @@ export class RemoteDataBuildService {
relationships.forEach((relationship: string) => { relationships.forEach((relationship: string) => {
if (hasValue(normalized[relationship])) { if (hasValue(normalized[relationship])) {
const resourceType = getResourceType(normalized, relationship); const { resourceType, isList } = getRelationMetadata(normalized, relationship);
const resourceConstructor = NormalizedObjectFactory.getConstructor(resourceType); const resourceConstructor = NormalizedObjectFactory.getConstructor(resourceType);
if (Array.isArray(normalized[relationship])) { if (Array.isArray(normalized[relationship])) {
// without the setTimeout, the actions inside requestService.configure // without the setTimeout, the actions inside requestService.configure
@@ -137,7 +185,12 @@ export class RemoteDataBuildService {
rdArr.push(this.buildSingle(href, resourceConstructor)); 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 { else {
// without the setTimeout, the actions inside requestService.configure // without the setTimeout, the actions inside requestService.configure
@@ -146,7 +199,14 @@ export class RemoteDataBuildService {
this.requestService.configure(new Request(normalized[relationship])); this.requestService.configure(new Request(normalized[relationship]));
},0); },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(", ") .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( const payload = <Observable<T[]>> Observable.combineLatest(
...input.map(rd => rd.payload) ...input.map(rd => rd.payload)
); );
@@ -196,6 +270,8 @@ export class RemoteDataBuildService {
responsePending, responsePending,
isSuccessFul, isSuccessFul,
errorMessage, errorMessage,
statusCode,
pageInfo,
payload 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 { inheritSerialization, autoserialize } from "cerialize";
import { NormalizedDSpaceObject } from "./normalized-dspace-object.model"; import { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { Bitstream } from "../../shared/bitstream.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) @mapsTo(Bitstream)
@inheritSerialization(NormalizedDSpaceObject) @inheritSerialization(NormalizedDSpaceObject)
export class NormalizedBitstream extends NormalizedDSpaceObject { export class NormalizedBitstream extends NormalizedDSpaceObject {
/** /**
* The size of this bitstream in bytes(?) * The size of this bitstream in bytes
*/ */
@autoserialize @autoserialize
size: number; sizeBytes: number;
/** /**
* The relative path to this Bitstream's file * The relative path to this Bitstream's file
*/ */
@autoserialize @autoserialize
url: string; retrieve: string;
/** /**
* The mime type of this Bitstream * The mime type of this Bitstream
*/ */
@autoserialize @autoserialize
mimetype: string; mimetype: string;
/** /**
* The format of this Bitstream * The format of this Bitstream
*/ */
format: string; @autoserialize
format: string;
/** /**
* The description of this Bitstream * The description of this Bitstream
*/ */
description: string; @autoserialize
description: string;
/** /**
* An array of Bundles that are direct parents of this Bitstream * An array of Bundles that are direct parents of this Bitstream
*/ */
parents: Array<string>; @autoserialize
@relationship(ResourceType.Item, true)
parents: Array<string>;
/** /**
* The Bundle that owns this Bitstream * The Bundle that owns this Bitstream
*/ */
owner: string; @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 * The primary bitstream of this Bundle
*/ */
@autoserialize @autoserialize
@relationship(ResourceType.Bitstream) @relationship(ResourceType.Bitstream, false)
primaryBitstream: string; primaryBitstream: string;
/** /**
@@ -25,6 +25,6 @@ export class NormalizedBundle extends NormalizedDSpaceObject {
owner: string; owner: string;
@autoserialize @autoserialize
@relationship(ResourceType.Bitstream) @relationship(ResourceType.Bitstream, true)
bitstreams: Array<string>; bitstreams: Array<string>;
} }

View File

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

View File

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

View File

@@ -1,24 +1,36 @@
import { autoserialize, autoserializeAs } from "cerialize"; import { autoserialize, autoserializeAs, inheritSerialization } from "cerialize";
import { CacheableObject } from "../object-cache.reducer";
import { Metadatum } from "../../shared/metadatum.model"; import { Metadatum } from "../../shared/metadatum.model";
import { ResourceType } from "../../shared/resource-type"; import { ResourceType } from "../../shared/resource-type";
import { NormalizedObject } from "./normalized-object.model";
/** /**
* An abstract model class for a DSpaceObject. * 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 @autoserialize
self: string; self: string;
/** /**
* The human-readable identifier of this DSpaceObject * 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; id: string;
/** /**
* The universally unique identifier of this DSpaceObject * 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 @autoserialize
uuid: string; 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 { NormalizedDSpaceObject } from "./normalized-dspace-object.model";
import { Item } from "../../shared/item.model"; import { Item } from "../../shared/item.model";
import { mapsTo, relationship } from "../builders/build-decorators"; 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 * The Date of the last modification of this Item
*/ */
@autoserialize
lastModified: Date; lastModified: Date;
/** /**
* A boolean representing if this Item is currently archived or not * A boolean representing if this Item is currently archived or not
*/ */
@autoserializeAs(Boolean, 'inArchive')
isArchived: boolean; 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 * A boolean representing if this Item is currently withdrawn or not
*/ */
@autoserializeAs(Boolean, 'withdrawn')
isWithdrawn: boolean; isWithdrawn: boolean;
/** /**
* An array of Collections that are direct parents of this Item * An array of Collections that are direct parents of this Item
*/ */
@autoserialize @autoserialize
@relationship(ResourceType.Collection) @relationship(ResourceType.Collection, true)
parents: Array<string>; parents: Array<string>;
/** /**
* The Collection that owns this Item * The Collection that owns this Item
*/ */
owner: string; @relationship(ResourceType.Collection, false)
owningCollection: string;
@autoserialize @autoserialize
@relationship(ResourceType.Bundle) @relationship(ResourceType.Bitstream, true)
bundles: Array<string>; bitstreams: Array<string>;
} }

View File

@@ -6,13 +6,20 @@ import { NormalizedCollection } from "./normalized-collection.model";
import { GenericConstructor } from "../../shared/generic-constructor"; import { GenericConstructor } from "../../shared/generic-constructor";
import { NormalizedCommunity } from "./normalized-community.model"; import { NormalizedCommunity } from "./normalized-community.model";
import { ResourceType } from "../../shared/resource-type"; import { ResourceType } from "../../shared/resource-type";
import { NormalizedObject } from "./normalized-object.model";
import { NormalizedBitstreamFormat } from "./normalized-bitstream-format.model";
export class NormalizedObjectFactory { export class NormalizedObjectFactory {
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedDSpaceObject> { public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject> {
switch (type) { switch (type) {
case ResourceType.Bitstream: { case ResourceType.Bitstream: {
return NormalizedBitstream 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: { case ResourceType.Bundle: {
return NormalizedBundle 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 { 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>; let store: Store<ObjectCacheState>;
const uuid = '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; const uuid = '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const requestHref = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const timestamp = new Date().getTime(); const timestamp = new Date().getTime();
const msToLive = 900000; const msToLive = 900000;
const objectToCache = { const objectToCache = {
@@ -44,8 +45,8 @@ describe("ObjectCacheService", () => {
describe("add", () => { describe("add", () => {
it("should dispatch an ADD action with the object to add, the time to live, and the current timestamp", () => { it("should dispatch an ADD action with the object to add, the time to live, and the current timestamp", () => {
service.add(objectToCache, msToLive); service.add(objectToCache, msToLive, requestHref);
expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive)); 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 { export class Response {
constructor(public isSuccessful: boolean) {} constructor(
public isSuccessful: boolean,
public statusCode: string
) {}
} }
export class SuccessResponse extends Response { export class SuccessResponse extends Response {
constructor(public resourceUUIDs: Array<String>) { constructor(
super(true); public resourceUUIDs: Array<String>,
public statusCode: string,
public pageInfo?: PageInfo
) {
super(true, statusCode);
} }
} }
export class ErrorResponse extends Response { export class ErrorResponse extends Response {
errorMessage: string; errorMessage: string;
constructor(error: Error) { constructor(error: RequestError) {
super(false); super(false, error.statusText);
console.error(error); console.error(error);
this.errorMessage = error.message; this.errorMessage = error.message;
} }

View File

@@ -12,7 +12,7 @@ import { ItemDataService } from "./data/item-data.service";
import { RequestService } from "./data/request.service"; import { RequestService } from "./data/request.service";
import { RemoteDataBuildService } from "./cache/builders/remote-data-build.service"; import { RemoteDataBuildService } from "./cache/builders/remote-data-build.service";
import { CommunityDataService } from "./data/community-data.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 = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -33,7 +33,7 @@ const PROVIDERS = [
ItemDataService, ItemDataService,
DSpaceRESTv2Service, DSpaceRESTv2Service,
ObjectCacheService, ObjectCacheService,
PaginationOptions, PaginationComponentOptions,
ResponseCacheService, ResponseCacheService,
RequestService, RequestService,
RemoteDataBuildService RemoteDataBuildService

View File

@@ -1,4 +1,4 @@
import { Injectable } from "@angular/core"; import { Inject, Injectable } from "@angular/core";
import { DataService } from "./data.service"; import { DataService } from "./data.service";
import { Collection } from "../shared/collection.model"; import { Collection } from "../shared/collection.model";
import { ObjectCacheService } from "../cache/object-cache.service"; 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 { CoreState } from "../core.reducers";
import { RequestService } from "./request.service"; import { RequestService } from "./request.service";
import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service"; import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service";
import { GLOBAL_CONFIG, GlobalConfig } from "../../../config";
@Injectable() @Injectable()
export class CollectionDataService extends DataService<NormalizedCollection, Collection> { export class CollectionDataService extends DataService<NormalizedCollection, Collection> {
protected endpoint = '/collections'; protected resourceEndpoint = '/core/collections';
protected browseEndpoint = '/discover/browses/dateissued/collections';
constructor( constructor(
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService, protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, 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 { DataService } from "./data.service";
import { Community } from "../shared/community.model"; import { Community } from "../shared/community.model";
import { ObjectCacheService } from "../cache/object-cache.service"; 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 { CoreState } from "../core.reducers";
import { RequestService } from "./request.service"; import { RequestService } from "./request.service";
import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service"; import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service";
import { GLOBAL_CONFIG, GlobalConfig } from "../../../config";
@Injectable() @Injectable()
export class CommunityDataService extends DataService<NormalizedCommunity, Community> { export class CommunityDataService extends DataService<NormalizedCommunity, Community> {
protected endpoint = '/communities'; protected resourceEndpoint = '/core/communities';
protected browseEndpoint = '/discover/browses/dateissued/communities';
constructor( constructor(
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService, protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, 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 { ObjectCacheService } from "../cache/object-cache.service";
import { ResponseCacheService } from "../cache/response-cache.service"; import { ResponseCacheService } from "../cache/response-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer"; 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 { 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 { Store } from "@ngrx/store";
import { RequestConfigureAction, RequestExecuteAction } from "./request.actions"; import { RequestConfigureAction, RequestExecuteAction } from "./request.actions";
import { CoreState } from "../core.reducers"; import { CoreState } from "../core.reducers";
import { RequestService } from "./request.service"; import { RequestService } from "./request.service";
import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service"; import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service";
import { GenericConstructor } from "../shared/generic-constructor"; 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> { export abstract class DataService<TNormalized extends CacheableObject, TDomain> {
protected abstract objectCache: ObjectCacheService; protected abstract objectCache: ObjectCacheService;
@@ -17,30 +20,61 @@ export abstract class DataService<TNormalized extends CacheableObject, TDomain>
protected abstract requestService: RequestService; protected abstract requestService: RequestService;
protected abstract rdbService: RemoteDataBuildService; protected abstract rdbService: RemoteDataBuildService;
protected abstract store: Store<CoreState>; 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 { protected getFindAllHref(options: FindAllOptions = {}): string {
let result = this.endpoint; let result;
if (hasValue(scopeID)) { let args = [];
result += `?scope=${scopeID}`
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>> { findAll(options: FindAllOptions = {}): RemoteData<Array<TDomain>> {
const href = this.getFindAllHref(scopeID); const href = this.getFindAllHref(options);
const request = new FindAllRequest(href, scopeID); const request = new FindAllRequest(href, options);
this.requestService.configure(request); this.requestService.configure(request);
return this.rdbService.buildList<TNormalized, TDomain>(href, this.normalizedResourceType); return this.rdbService.buildList<TNormalized, TDomain>(href, this.normalizedResourceType);
// return this.rdbService.buildList(href); // return this.rdbService.buildList(href);
} }
protected getFindByIDHref(resourceID): string { protected getFindByIDHref(resourceID): string {
return `${this.endpoint}/${resourceID}`; return new RESTURLCombiner(this.EnvConfig, `${this.resourceEndpoint}/${resourceID}`).toString();
} }
findById(id: string): RemoteData<TDomain> { 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 { DataService } from "./data.service";
import { Item } from "../shared/item.model"; import { Item } from "../shared/item.model";
import { ObjectCacheService } from "../cache/object-cache.service"; 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 { NormalizedItem } from "../cache/models/normalized-item.model";
import { RequestService } from "./request.service"; import { RequestService } from "./request.service";
import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service"; import { RemoteDataBuildService } from "../cache/builders/remote-data-build.service";
import { GLOBAL_CONFIG, GlobalConfig } from "../../../config";
@Injectable() @Injectable()
export class ItemDataService extends DataService<NormalizedItem, Item> { export class ItemDataService extends DataService<NormalizedItem, Item> {
protected endpoint = '/items'; protected resourceEndpoint = '/core/items';
protected browseEndpoint = '/discover/browses/dateissued/items';
constructor( constructor(
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService, protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, 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 { Observable } from "rxjs";
import { PageInfo } from "../shared/page-info.model";
export enum RemoteDataState { export enum RemoteDataState {
RequestPending, RequestPending = <any> "RequestPending",
ResponsePending, ResponsePending = <any> "ResponsePending",
Failed, Failed = <any> "Failed",
Success Success = <any> "Success"
} }
/** /**
@@ -17,6 +18,8 @@ export class RemoteData<T> {
private responsePending: Observable<boolean>, private responsePending: Observable<boolean>,
private isSuccessFul: Observable<boolean>, private isSuccessFul: Observable<boolean>,
public errorMessage: Observable<string>, public errorMessage: Observable<string>,
public statusCode: Observable<string>,
public pageInfo: Observable<PageInfo>,
public payload: Observable<T> public payload: Observable<T>
) { ) {
} }

View File

@@ -1,6 +1,5 @@
import { Injectable, Inject } from "@angular/core"; import { Injectable, Inject } from "@angular/core";
import { Actions, Effect } from "@ngrx/effects"; import { Actions, Effect } from "@ngrx/effects";
import { Store } from "@ngrx/store";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { ObjectCacheService } from "../cache/object-cache.service"; import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model"; 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 { Response, SuccessResponse, ErrorResponse } from "../cache/response-cache.models";
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from "../../shared/empty.util"; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from "../../shared/empty.util";
import { GlobalConfig, GLOBAL_CONFIG } from "../../../config"; import { GlobalConfig, GLOBAL_CONFIG } from "../../../config";
import { RequestState, RequestEntry } from "./request.reducer"; import { RequestEntry } from "./request.reducer";
import { import {
RequestActionTypes, RequestExecuteAction, RequestActionTypes, RequestExecuteAction,
RequestCompleteAction RequestCompleteAction
@@ -19,11 +18,31 @@ import { ResponseCacheService } from "../cache/response-cache.service";
import { RequestService } from "./request.service"; import { RequestService } from "./request.service";
import { NormalizedObjectFactory } from "../cache/models/normalized-object-factory"; import { NormalizedObjectFactory } from "../cache/models/normalized-object-factory";
import { ResourceType } from "../shared/resource-type"; 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) { function isObjectLevel(halObj: any) {
return isNotEmpty(halObj._links) && hasValue(halObj._links.self); 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() @Injectable()
export class RequestEffects { export class RequestEffects {
@@ -33,8 +52,7 @@ export class RequestEffects {
private restApi: DSpaceRESTv2Service, private restApi: DSpaceRESTv2Service,
private objectCache: ObjectCacheService, private objectCache: ObjectCacheService,
private responseCache: ResponseCacheService, private responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService
private store: Store<RequestState>
) { } ) { }
@Effect() execute = this.actions$ @Effect() execute = this.actions$
@@ -45,83 +63,102 @@ export class RequestEffects {
}) })
.flatMap((entry: RequestEntry) => { .flatMap((entry: RequestEntry) => {
return this.restApi.get(entry.request.href) return this.restApi.get(entry.request.href)
.map((data: DSpaceRESTV2Response) => this.processEmbedded(data._embedded, entry.request.href)) .map((data: DSpaceRESTV2Response) => {
.map((ids: Array<string>) => new SuccessResponse(ids)) const processRequestDTO = this.process(data.payload, entry.request.href);
.do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) 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)) .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)) .do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive))
.map((response: Response) => new RequestCompleteAction(entry.request.href))); .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 (isNotEmpty(data)) {
if (isObjectLevel(_embedded)) { if (isPaginatedResponse(data)) {
return this.deserializeAndCache(_embedded, requestHref); return this.process(data._embedded, requestHref);
}
else if (isObjectLevel(data)) {
return { "topLevel": this.deserializeAndCache(data, requestHref) };
} }
else { else {
let uuids = []; let result = new ProcessRequestDTO();
Object.keys(_embedded) if (Array.isArray(data)) {
.filter(property => _embedded.hasOwnProperty(property)) result['topLevel'] = [];
.forEach(property => { data.forEach(datum => {
uuids = [...uuids, ...this.deserializeAndCache(_embedded[property], requestHref)]; 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> { protected deserializeAndCache(obj, requestHref: string): NormalizedObject[] {
let type: ResourceType; if(Array.isArray(obj)) {
const isArray = Array.isArray(obj); let result = [];
obj.forEach(o => result = [...result, ...this.deserializeAndCache(o, requestHref)])
if (isArray && isEmpty(obj)) { return result;
return [];
}
if (isArray) {
type = obj[0]["type"];
}
else {
type = obj["type"];
} }
let type: ResourceType = obj["type"];
if (hasValue(type)) { if (hasValue(type)) {
const normObjConstructor = NormalizedObjectFactory.getConstructor(type); const normObjConstructor = NormalizedObjectFactory.getConstructor(type);
if (hasValue(normObjConstructor)) { if (hasValue(normObjConstructor)) {
const serializer = new DSpaceRESTv2Serializer(normObjConstructor); const serializer = new DSpaceRESTv2Serializer(normObjConstructor);
if (isArray) { let processed;
obj.forEach(o => { if (isNotEmpty(obj._embedded)) {
if (isNotEmpty(o._embedded)) { processed = this.process(obj._embedded, requestHref);
this.processEmbedded(o._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); Object.assign(normalizedObj, linksOnly);
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];
} }
this.addToObjectCache(normalizedObj, requestHref);
return [normalizedObj];
} }
else { else {
//TODO move check to Validator? //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 { else {
//TODO move check to Validator //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); 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 { 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"; import { GenericConstructor } from "../shared/generic-constructor";
export class Request<T> { 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> { export class FindAllRequest<T> extends Request<T> {
constructor( constructor(
href: string, href: string,
public scopeID?: string, public options?: FindAllOptions,
public paginationOptions?: PaginationOptions,
public sortOptions?: SortOptions
) { ) {
super(href); super(href);
} }
} }
export class RequestError extends Error {
statusText: string;
}

View File

@@ -1,4 +1,8 @@
export interface DSpaceRESTV2Response { export interface DSpaceRESTV2Response {
_embedded?: any; payload: {
_links?: any; _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", () => { it("should turn a model in to a valid document", () => {
const serializer = new DSpaceRESTv2Serializer(TestModel); const serializer = new DSpaceRESTv2Serializer(TestModel);
const doc = serializer.serialize(testModels[0]); const doc = serializer.serialize(testModels[0]);
expect(testModels[0].id).toBe(doc._embedded.id); expect(testModels[0].id).toBe(doc.id);
expect(testModels[0].name).toBe(doc._embedded.name); expect(testModels[0].name).toBe(doc.name);
}); });
}); });
@@ -72,10 +72,10 @@ describe("DSpaceRESTv2Serializer", () => {
const serializer = new DSpaceRESTv2Serializer(TestModel); const serializer = new DSpaceRESTv2Serializer(TestModel);
const doc = serializer.serializeArray(testModels); const doc = serializer.serializeArray(testModels);
expect(testModels[0].id).toBe(doc._embedded[0].id); expect(testModels[0].id).toBe(doc[0].id);
expect(testModels[0].name).toBe(doc._embedded[0].name); expect(testModels[0].name).toBe(doc[0].name);
expect(testModels[1].id).toBe(doc._embedded[1].id); expect(testModels[1].id).toBe(doc[1].id);
expect(testModels[1].name).toBe(doc._embedded[1].name); 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 * @param model The model to serialize
* @returns An object to send to the backend * @returns An object to send to the backend
*/ */
serialize(model: T): DSpaceRESTV2Response { serialize(model: T): any {
return { return Serialize(model, this.modelType);
"_embedded": Serialize(model, this.modelType)
};
} }
/** /**
@@ -38,10 +36,8 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
* @param models The array of models to serialize * @param models The array of models to serialize
* @returns An object to send to the backend * @returns An object to send to the backend
*/ */
serializeArray(models: Array<T>): DSpaceRESTV2Response { serializeArray(models: Array<T>): any {
return { return Serialize(models, this.modelType);
"_embedded": Serialize(models, this.modelType)
};
} }
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ export abstract class DSpaceObject implements CacheableObject {
/** /**
* The DSpaceObject that owns this DSpaceObject * The DSpaceObject that owns this DSpaceObject
*/ */
owner: DSpaceObject; owner: RemoteData<DSpaceObject>;
/** /**
* Find a metadata field by key and language * 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 { Item } from "./item.model";
import { Bundle } from "./bundle.model";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { RemoteData } from "../data/remote-data"; import { RemoteData } from "../data/remote-data";
import { Bitstream } from "./bitstream.model"; import { Bitstream } from "./bitstream.model";
import { isEmpty } from "../../shared/empty.util";
import { PageInfo } from "./page-info.model";
describe('Item', () => { describe('Item', () => {
@@ -17,23 +17,25 @@ describe('Item', () => {
const bitstream2Path = "otherfile.doc"; const bitstream2Path = "otherfile.doc";
const nonExistingBundleName = "c1e568f7-d14e-496b-bdd7-07026998cc00"; const nonExistingBundleName = "c1e568f7-d14e-496b-bdd7-07026998cc00";
let remoteBundles; let bitstreams;
let thumbnailBundle; let remoteDataThumbnail;
let originalBundle; let remoteDataFiles;
let remoteDataAll;
beforeEach(() => { beforeEach(() => {
const thumbnail = { const thumbnail = {
retrieve: thumbnailPath retrieve: thumbnailPath
}; };
const bitstreams = [{ bitstreams = [{
retrieve: bitstream1Path retrieve: bitstream1Path
}, { }, {
retrieve: bitstream2Path retrieve: bitstream2Path
}]; }];
const remoteDataThumbnail = createRemoteDataObject(thumbnail); remoteDataThumbnail = createRemoteDataObject(thumbnail);
const remoteDataFiles = createRemoteDataObject(bitstreams); remoteDataFiles = createRemoteDataObject(bitstreams);
remoteDataAll = createRemoteDataObject([...bitstreams, thumbnail]);
// Create Bundles // Create Bundles
@@ -50,32 +52,30 @@ describe('Item', () => {
bitstreams: remoteDataFiles 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', () => { it('should return the bitstreams related to this item with the specified bundle name', () => {
let name: string = thumbnailBundleName; const bitObs: Observable<Bitstream[]> = item.getBitstreamsByBundleName(thumbnailBundleName);
let bundle: Observable<Bundle> = item.getBundle(name); bitObs.take(1).subscribe(bs =>
bundle.map(b => expect(b.name).toBe(name)); expect(bs.every(b => b.name === thumbnailBundleName)).toBeTruthy());
}); });
it('should return null when no bundle with this name exists for this item', () => { it('should return an empty array when no bitstreams with this bundleName exist for this item', () => {
let name: string = nonExistingBundleName; const bitstreams: Observable<Bitstream[]> = item.getBitstreamsByBundleName(nonExistingBundleName);
let bundle: Observable<Bundle> = item.getBundle(name); bitstreams.take(1).subscribe(bs => expect(isEmpty(bs)).toBeTruthy());
bundle.map(b => expect(b).toBeUndefined());
}); });
describe("get thumbnail", () => { describe("get thumbnail", () => {
beforeEach(() => { 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 path: string = thumbnailPath;
let bitstream: Observable<Bitstream> = item.getThumbnail(); let bitstream: Observable<Bitstream> = item.getThumbnail();
bitstream.map(b => expect(b.retrieve).toBe(path)); bitstream.map(b => expect(b.retrieve).toBe(path));
@@ -85,10 +85,10 @@ describe('Item', () => {
describe("get files", () => { describe("get files", () => {
beforeEach(() => { 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 paths = [bitstream1Path, bitstream2Path];
let files: Observable<Bitstream[]> = item.getFiles(); let files: Observable<Bitstream[]> = item.getFiles();
@@ -110,6 +110,23 @@ describe('Item', () => {
}); });
function createRemoteDataObject(object: Object) { 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 { DSpaceObject } from "./dspace-object.model";
import { Collection } from "./collection.model"; import { Collection } from "./collection.model";
import { RemoteData } from "../data/remote-data"; import { RemoteData } from "../data/remote-data";
import { Bundle } from "./bundle.model";
import { Bitstream } from "./bitstream.model"; import { Bitstream } from "./bitstream.model";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { hasValue } from "../../shared/empty.util"; import { isNotEmpty } from "../../shared/empty.util";
export class Item extends DSpaceObject { export class Item extends DSpaceObject {
@@ -23,6 +22,11 @@ export class Item extends DSpaceObject {
*/ */
isArchived: boolean; 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 * 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 * 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 * @returns {Observable<Bitstream>} the primaryBitstream of the "THUMBNAIL" bundle
*/ */
getThumbnail(): Observable<Bitstream> { getThumbnail(): Observable<Bitstream> {
const bundle: Observable<Bundle> = this.getBundle("THUMBNAIL"); //TODO currently this just picks the first thumbnail
return bundle //should be adjusted when we have a way to determine
.filter(bundle => hasValue(bundle)) //the primary thumbnail from rest
.flatMap(bundle => bundle.primaryBitstream.payload) return this.getBitstreamsByBundleName("THUMBNAIL")
.startWith(undefined); .filter(thumbnails => isNotEmpty(thumbnails))
.map(thumbnails => thumbnails[0])
} }
/** /**
* Retrieves the thumbnail for the given original of this item * Retrieves the thumbnail for the given original of this item
* @returns {Observable<Bitstream>} the primaryBitstream of the "THUMBNAIL" bundle * @returns {Observable<Bitstream>} the primaryBitstream of the "THUMBNAIL" bundle
*/ */
getThumbnailForOriginal(original: Bitstream): Observable<Bitstream> { getThumbnailForOriginal(original: Bitstream): Observable<Bitstream> {
const bundle: Observable<Bundle> = this.getBundle("THUMBNAIL"); return this.getBitstreamsByBundleName("THUMBNAIL").map(files => files
return bundle .find(thumbnail => thumbnail
.filter(bundle => hasValue(bundle)) .name.startsWith(original.name)
.flatMap(bundle => bundle )
.bitstreams.payload.map(files => files ).startWith(undefined);
.find(thumbnail => thumbnail }
.name.startsWith(original.name)
) /**
) * 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
.startWith(undefined);; */
getFiles(): Observable<Bitstream[]> {
return this.getBitstreamsByBundleName("ORIGINAL");
} }
/** /**
* Retrieves all files that should be displayed on the item page of this item * Retrieves bitstreams by bundle name
* @returns {Observable<Array<Observable<Bitstream>>>} an array of all Bitstreams in the "ORIGINAL" bundle * @param bundleName The name of the Bundle that should be returned
*/ * @returns {Observable<Bitstream[]>} the bitstreams with the given bundleName
getFiles(name: String = "ORIGINAL"): Observable<Bitstream[]> { */
const bundle: Observable <Bundle> = this.getBundle(name); getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> {
return bundle return this.bitstreams.payload.startWith([])
.filter(bundle => hasValue(bundle)) .map(bitstreams => bitstreams
.flatMap(bundle => bundle.bitstreams.payload) .filter(bitstream => bitstream.bundleName === bundleName)
.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);
} }
} }

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 { export enum ResourceType {
Bundle = <any> "bundle", Bundle = <any> "bundle",
Bitstream = <any> "bitstream", Bitstream = <any> "bitstream",
BitstreamFormat = <any> "bitstreamformat",
Item = <any> "item", Item = <any> "item",
Collection = <any> "collection", Collection = <any> "collection",
Community = <any> "community" 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 { HomeNewsComponent } from "./home-news/home-news.component";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from "@ngx-translate/core";
import { SharedModule } from "../shared/shared.module";
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
HomeRoutingModule, HomeRoutingModule,
RouterModule, RouterModule,
SharedModule,
TranslateModule TranslateModule
], ],
declarations: [ declarations: [

View File

@@ -1,12 +1,10 @@
<div *ngIf="topLevelCommunities.hasSucceeded | async"> <div *ngIf="topLevelCommunities.hasSucceeded | async">
<h2>{{'home.top-level-communities.head' | translate}}</h2> <h2>{{'home.top-level-communities.head' | translate}}</h2>
<p class="lead">{{'home.top-level-communities.help' | translate}}</p> <p class="lead">{{'home.top-level-communities.help' | translate}}</p>
<ul> <ds-object-list [config]="config" [sortConfig]="sortConfig"
<li *ngFor="let community of (topLevelCommunities.payload | async)"> [objects]="topLevelCommunities" [hideGear]="true"
<p> (pageChange)="onPageChange($event)"
<span class="lead"><a [routerLink]="['/communities', community.id]">{{community.name}}</a></span><br> (pageSizeChange)="onPageSizeChange($event)"
<span class="text-muted">{{community.shortDescription}}</span> (sortDirectionChange)="onSortDirectionChange($event)"
</p> (sortFieldChange)="onSortDirectionChange($event)"></ds-object-list>
</li>
</ul>
</div> </div>

View File

@@ -1,18 +1,24 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
import { CommunityDataService } from "../../core/data/community-data.service";
import { RemoteData } from "../../core/data/remote-data"; import { RemoteData } from "../../core/data/remote-data";
import { CommunityDataService } from "../../core/data/community-data.service";
import { Community } from "../../core/shared/community.model"; 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({ @Component({
selector: 'ds-top-level-community-list', selector: 'ds-top-level-community-list',
styleUrls: ['./top-level-community-list.component.css'], 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 { export class TopLevelCommunityListComponent implements OnInit {
topLevelCommunities: RemoteData<Community[]>; topLevelCommunities: RemoteData<Community[]>;
config : PaginationComponentOptions;
sortConfig : SortOptions;
constructor( constructor(
private cds: CommunityDataService private cds: CommunityDataService,
private ref: ChangeDetectorRef
) { ) {
this.universalInit(); this.universalInit();
} }
@@ -22,6 +28,38 @@ export class TopLevelCommunityListComponent implements OnInit {
} }
ngOnInit(): void { 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"> <ds-metadata-field-wrapper [label]="label | translate">
<div class="collections"> <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> <span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
</a> </a>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core';
import { Collection } from "../../../core/shared/collection.model"; import { Collection } from "../../../core/shared/collection.model";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { Item } from "../../../core/shared/item.model"; 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 * This component renders the parent collections section of the item
@@ -18,11 +19,13 @@ export class CollectionsComponent implements OnInit {
label : string = "item.page.collections"; label : string = "item.page.collections";
separator: string = "<br/>" separator: string = "<br/>";
collections: Observable<Collection[]>; collections: Observable<Collection[]>;
constructor() { constructor(
private rdbs: RemoteDataBuildService
) {
this.universalInit(); this.universalInit();
} }
@@ -31,7 +34,11 @@ export class CollectionsComponent implements OnInit {
} }
ngOnInit(): void { 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> <dd class="col-md-8">{{file.name}}</dd>
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt> <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> <dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
@@ -21,7 +21,7 @@
</dl> </dl>
</div> </div>
<div class="col-2"> <div class="col-2">
<a [href]="file.retrieve"> <a [href]="file.retrieve" [download]="file.name">
{{"item.page.filesection.download" | translate}} {{"item.page.filesection.download" | translate}}
</a> </a>
</div> </div>

View File

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

View File

@@ -1,8 +1,8 @@
<ds-metadata-field-wrapper [label]="label | translate"> <ds-metadata-field-wrapper [label]="label | translate">
<div class="file-section"> <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?.name}}</span>
<span>({{(file?.size) | dsFileSize }})</span> <span>({{(file?.sizeBytes) | dsFileSize }})</span>
<span *ngIf="!last" innerHTML="{{separator}}"></span> <span *ngIf="!last" innerHTML="{{separator}}"></span>
</a> </a>
</div> </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"> <div *ngIf="logo" class="dso-logo">
<img [src]="logo.url" class="img-responsive" [attr.alt]="alternateText ? alternateText : null" /> <img [src]="logo.retrieve" class="img-responsive" [attr.alt]="alternateText ? alternateText : null" (error)="errorHandler($event)"/>
</div> </div>

View File

@@ -12,4 +12,14 @@ export class ComcolPageLogoComponent {
@Input() logo: Bitstream; @Input() logo: Bitstream;
@Input() alternateText: string; @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'; 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 * ID for the pagination instance. Only useful if you wish to
* have more than once instance at a time in a given component. * 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="row">
<div class="col pagination-info"> <div class="col pagination-info">
<span class="align-middle hidden-xs-down">{{ 'pagination.showing.label' | translate }}</span> <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> <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"> <div class="dropdown-menu dropdown-menu-right" id="paginationControlsDropdownMenu" aria-labelledby="paginationControls">
<h6 class="dropdown-header">{{ 'pagination.results-per-page' | translate}}</h6> <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> </div>
</div> </div>
@@ -18,7 +20,7 @@
<ng-content></ng-content> <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" <ngb-pagination [boundaryLinks]="paginationOptions.boundaryLinks"
[collectionSize]="collectionSize" [collectionSize]="collectionSize"
[disabled]="paginationOptions.disabled" [disabled]="paginationOptions.disabled"

View File

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

View File

@@ -1,14 +1,15 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output, Output,
ViewEncapsulation ViewEncapsulation
} from '@angular/core' } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from "rxjs/Subscription";
import { isNumeric } from "rxjs/util/isNumeric"; import { isNumeric } from "rxjs/util/isNumeric";
import 'rxjs/add/operator/switchMap'; import 'rxjs/add/operator/switchMap';
import { Observable } from "rxjs"; 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 { HostWindowService } from "../host-window.service";
import { HostWindowState } from "../host-window.reducer"; 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. * The default pagination controls component.
*/ */
@Component({ @Component({
exportAs: 'paginationComponent', exportAs: 'paginationComponent',
selector: 'ds-pagination', selector: 'ds-pagination',
templateUrl: 'pagination.component.html', templateUrl: 'pagination.component.html',
changeDetection: ChangeDetectionStrategy.Default, changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.Emulated encapsulation: ViewEncapsulation.Emulated
}) })
export class PaginationComponent implements OnDestroy, OnInit { export class PaginationComponent implements OnDestroy, OnInit {
/** /**
* Number of items in collection. * Number of items in collection.
*/ */
@Input() collectionSize: number; @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. * An object that represents pagination details of the current viewed page
* Event's payload equals to the newly selected page. */
*/ public showingDetail: any = {
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>(); range: null,
total: null
};
/** /**
* An event fired when the page size is changed. * Method provided by Angular. Invoked after the constructor.
* Event's payload equals to the newly selected page size. */
*/ ngOnInit() {
@Output() pageSizeChange: EventEmitter<number> = new EventEmitter<number>(); 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. * Method provided by Angular. Invoked when the instance is destroyed.
*/ */
public currentPage = 1; ngOnDestroy() {
this.subs
/** .filter(sub => hasValue(sub))
* Current URL query parameters .forEach(sub => sub.unsubscribe());
*/ }
public currentQueryParams = {};
/** /**
* An observable of HostWindowState type * @param route
*/ * Route is a singleton service provided by Angular.
public hostWindow: Observable<HostWindowState>; * @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 * Method to set set new page and update route parameters
* have more than once instance at a time in a given component. *
*/ * @param page
private id: string; * 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. * Method to set set new page size and update route parameters
*/ *
public isXs: boolean; * @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. * Method to set set new sort direction and update route parameters
*/ *
public pageSize: number = 10; * @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. * Method to set set new sort field and update route parameters
*/ *
private pageSizeOptions: Array<number>; * @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 * Method to update the route parameters
*/ */
public paginationControls; 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. * Method to set pagination details of the current viewed page.
*/ */
private routeSubscription: any; private setShowingDetail() {
let firstItem;
let lastItem;
let lastPage = Math.round(this.collectionSize / this.pageSize);
/** firstItem = this.pageSize * (this.currentPage - 1) + 1;
* An object that represents pagination details of the current viewed page if (this.currentPage != lastPage) {
*/ lastItem = this.pageSize * this.currentPage;
public showingDetail: any = { } else {
range: null, lastItem = this.collectionSize;
total: null }
}; this.showingDetail = {
range: firstItem + ' - ' + lastItem,
/** total: this.collectionSize
* 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']);
} }
});
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 * Validate query params
* *
* @param page * @param page
* The page number to validate * The page number to validate
* @param pageSize * @param pageSize
* The page size to validate * The page size to validate
*/ */
private validateParams(page: any, pageSize: any) { private validateParams(page: any, pageSize: any, sortDirection: any, sortField: any) {
let filteredPageSize = this.pageSizeOptions.find(x => x == pageSize); let filteredPageSize = this.pageSizeOptions.find(x => x == pageSize);
if (!isNumeric(page) || !filteredPageSize) { if (!isNumeric(page) || !filteredPageSize) {
let filteredPage = isNumeric(page) ? page : this.currentPage; let filteredPage = isNumeric(page) ? page : this.currentPage;
filteredPageSize = (filteredPageSize) ? filteredPageSize : this.pageSize; filteredPageSize = (filteredPageSize) ? filteredPageSize : this.pageSize;
this.router.navigate([{ pageId: this.id, page: filteredPage, pageSize: filteredPageSize }]); this.router.navigate([], {
} else { queryParams: {
// (+) converts string to a number pageId: this.id,
this.currentPage = +page; page: filteredPage,
this.pageSize = +pageSize; pageSize: filteredPageSize,
this.pageChange.emit(this.currentPage); sortDirection: sortDirection,
this.pageSizeChange.emit(this.pageSize); 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. * Ensure options passed contains the required properties.
* *
* @param paginateOptions * @param paginateOptions
* The paginate options object. * The paginate options object.
*/ */
private checkConfig(paginateOptions: any) { private checkConfig(paginateOptions: any) {
let required = ['id', 'currentPage', 'pageSize', 'pageSizeOptions']; let required = ['id', 'currentPage', 'pageSize', 'pageSizeOptions'];
let missing = required.filter(function (prop) { return !(prop in paginateOptions); }); let missing = required.filter(function (prop) {
if (0 < missing.length) { return !(prop in paginateOptions);
throw new Error("Paginate: Argument is missing the following required properties: " + missing.join(', ')); });
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 { ComcolPageContentComponent } from "./comcol-page-content/comcol-page-content.component";
import { ComcolPageHeaderComponent } from "./comcol-page-header/comcol-page-header.component"; import { ComcolPageHeaderComponent } from "./comcol-page-header/comcol-page-header.component";
import { ComcolPageLogoComponent } from "./comcol-page-logo/comcol-page-logo.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 = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -31,7 +38,9 @@ const MODULES = [
const PIPES = [ const PIPES = [
FileSizePipe, FileSizePipe,
SafeUrlPipe SafeUrlPipe,
EnumKeysPipe,
TruncatePipe
// put pipes here // put pipes here
]; ];
@@ -41,7 +50,12 @@ const COMPONENTS = [
ThumbnailComponent, ThumbnailComponent,
ComcolPageContentComponent, ComcolPageContentComponent,
ComcolPageHeaderComponent, ComcolPageHeaderComponent,
ComcolPageLogoComponent ComcolPageLogoComponent,
ObjectListComponent,
ObjectListElementComponent,
ItemListElementComponent,
CollectionListElementComponent,
CommunityListElementComponent
]; ];
const PROVIDERS = [ 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"> <div class="thumbnail">
<img *ngIf="thumbnail" [src]="thumbnail.retrieve"/> <img *ngIf="thumbnail" [src]="thumbnail.retrieve" (error)="errorHandler($event)"/>
<img *ngIf="!thumbnail" [src]="holderSource | dsSafeUrl"/> <img *ngIf="!thumbnail" [src]="holderSource | dsSafeUrl"/>
</div> </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"; import { Bitstream } from "../core/shared/bitstream.model";
/** /**
@@ -14,21 +14,24 @@ import { Bitstream } from "../core/shared/bitstream.model";
}) })
export class ThumbnailComponent { export class ThumbnailComponent {
@Input() thumbnail: Bitstream; @Input() thumbnail: Bitstream;
data: any = {}; data: any = {};
/** /**
* The default 'holder.js' image * 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"; 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() { constructor() {
this.universalInit(); this.universalInit();
} }
universalInit() { universalInit() {
}
} errorHandler(event) {
event.currentTarget.src = this.holderSource;
}
} }