diff --git a/package.json b/package.json index f2bb074ff4..60bb3c8d09 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "reflect-metadata": "0.1.10", "rxjs": "5.4.3", "ts-md5": "1.2.2", + "uuid": "^3.1.0", "webfontloader": "1.6.28", "zone.js": "0.8.18" }, @@ -126,6 +127,7 @@ "@types/node": "8.0.34", "@types/serve-static": "1.7.32", "@types/source-map": "0.5.1", + "@types/uuid": "^3.4.3", "@types/webfontloader": "1.6.29", "ajv": "5.2.3", "ajv-keywords": "2.1.0", diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 853bd0d154..de7e9a72d4 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -6,6 +6,7 @@ import { Subscription } from 'rxjs/Subscription'; import { SortOptions } from '../core/cache/models/sort-options.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { ItemDataService } from '../core/data/item-data.service'; +import { PaginatedList } from '../core/data/paginated-list'; import { RemoteData } from '../core/data/remote-data'; import { MetadataService } from '../core/metadata/metadata.service'; @@ -30,7 +31,7 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp }) export class CollectionPageComponent implements OnInit, OnDestroy { collectionRDObs: Observable>; - itemRDObs: Observable>; + itemRDObs: Observable>>; logoRDObs: Observable>; paginationConfig: PaginationComponentOptions; sortConfig: SortOptions; diff --git a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html index b04e93ff71..8e2d04c5cd 100644 --- a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.html @@ -2,7 +2,7 @@

{{'community.sub-collection-list.head' | translate}}

    -
  • +
  • {{collection.name}}
    {{collection.shortDescription}} diff --git a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts index 8edc275437..cb371617c9 100644 --- a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts @@ -1,11 +1,12 @@ import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; import { CollectionDataService } from '../../core/data/collection-data.service'; +import { PaginatedList } from '../../core/data/paginated-list'; import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; import { fadeIn } from '../../shared/animations/fade'; -import { Observable } from 'rxjs/Observable'; @Component({ selector: 'ds-community-page-sub-collection-list', @@ -14,7 +15,7 @@ import { Observable } from 'rxjs/Observable'; animations:[fadeIn] }) export class CommunityPageSubCollectionListComponent implements OnInit { - subCollectionsRDObs: Observable>; + subCollectionsRDObs: Observable>>; constructor(private cds: CollectionDataService) { diff --git a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts index b364985fc1..1b71220382 100644 --- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts +++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Observable } from 'rxjs/Observable'; import { SortOptions } from '../../core/cache/models/sort-options.model'; import { CommunityDataService } from '../../core/data/community-data.service'; +import { PaginatedList } from '../../core/data/paginated-list'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; @@ -17,7 +18,7 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c animations: [fadeInOut] }) export class TopLevelCommunityListComponent { - communitiesRDObs: Observable>; + communitiesRDObs: Observable>>; config: PaginationComponentOptions; sortConfig: SortOptions; diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 68a7bf6a9e..81f0c78527 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -8,7 +8,7 @@ [query]="query" [scope]="(scopeObjectRDObs | async)?.payload" [currentParams]="currentParams" - [scopes]="(scopeListRDObs | async)?.payload"> + [scopes]="(scopeListRDObs | async)?.payload?.page">

    >; + scopeListRDObs: Observable>>; isMobileView: Observable; constructor(private service: SearchService, diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 36ec4dcaa1..c70fe22ce0 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,23 +1,24 @@ import { Injectable, OnDestroy } from '@angular/core'; -import { RemoteData } from '../../core/data/remote-data'; +import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; import { Observable } from 'rxjs/Observable'; -import { SearchResult } from '../search-result.model'; -import { ItemDataService } from '../../core/data/item-data.service'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { SearchOptions } from '../search-options.model'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { Metadatum } from '../../core/shared/metadatum.model'; -import { Item } from '../../core/shared/item.model'; -import { SearchFilterConfig } from './search-filter-config.model'; -import { FilterType } from './filter-type.model'; -import { FacetValue } from './facet-value.model'; -import { ItemSearchResult } from '../../shared/object-collection/shared/item-search-result.model'; import { ViewMode } from '../../+search-page/search-options.model'; -import { Router, NavigationExtras, ActivatedRoute } from '@angular/router'; -import { RouteService } from '../../shared/route.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { Metadatum } from '../../core/shared/metadatum.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { ItemSearchResult } from '../../shared/object-collection/shared/item-search-result.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { RouteService } from '../../shared/route.service'; +import { SearchOptions } from '../search-options.model'; +import { SearchResult } from '../search-result.model'; +import { FacetValue } from './facet-value.model'; +import { FilterType } from './filter-type.model'; +import { SearchFilterConfig } from './search-filter-config.model'; function shuffle(array: any[]) { let i = 0; @@ -118,8 +119,7 @@ export class SearchService implements OnDestroy { self += `&sortField=${searchOptions.sort.field}`; } - const errorMessage = undefined; - const statusCode = '200'; + const error = undefined; const returningPageInfo = new PageInfo(); if (isNotEmpty(searchOptions)) { @@ -137,13 +137,12 @@ export class SearchService implements OnDestroy { }); return itemsObs - .filter((rd: RemoteData) => rd.hasSucceeded) - .map((rd: RemoteData) => { + .filter((rd: RemoteData>) => rd.hasSucceeded) + .map((rd: RemoteData>) => { - const totalElements = rd.pageInfo.totalElements > 20 ? 20 : rd.pageInfo.totalElements; - const pageInfo = Object.assign({}, rd.pageInfo, { totalElements: totalElements }); + const totalElements = rd.payload.totalElements > 20 ? 20 : rd.payload.totalElements; - const payload = shuffle(rd.payload) + const page = shuffle(rd.payload.page) .map((item: Item, index: number) => { const mockResult: SearchResult = new ItemSearchResult(); mockResult.dspaceObject = item; @@ -154,24 +153,20 @@ export class SearchService implements OnDestroy { return mockResult; }); + const payload = Object.assign({}, rd.payload, { totalElements: totalElements, page }); + return new RemoteData( - self, rd.isRequestPending, rd.isResponsePending, rd.hasSucceeded, - errorMessage, - statusCode, - pageInfo, + error, payload ) }).startWith(new RemoteData( - '', true, false, undefined, undefined, - undefined, - undefined, undefined )); } @@ -180,17 +175,12 @@ export class SearchService implements OnDestroy { const requestPending = false; const responsePending = false; const isSuccessful = true; - const errorMessage = undefined; - const statusCode = '200'; - const returningPageInfo = new PageInfo(); + const error = undefined; return Observable.of(new RemoteData( - 'https://dspace7.4science.it/dspace-spring-rest/api/search', requestPending, responsePending, isSuccessful, - errorMessage, - statusCode, - returningPageInfo, + error, this.config )); } @@ -198,12 +188,12 @@ export class SearchService implements OnDestroy { getFacetValuesFor(searchFilterConfigName: string): Observable> { const filterConfig = this.config.find((config: SearchFilterConfig) => config.name === searchFilterConfigName); return this.routeService.getQueryParameterValues(filterConfig.paramName).map((selectedValues: string[]) => { - const values: FacetValue[] = []; + const payload: FacetValue[] = []; const totalFilters = 13; for (let i = 0; i < totalFilters; i++) { const value = searchFilterConfigName + ' ' + (i + 1); if (!selectedValues.includes(value)) { - values.push({ + payload.push({ value: value, count: Math.floor(Math.random() * 20) + 20 * (totalFilters - i), // make sure first results have the highest (random) count search: decodeURI(this.router.url) + (this.router.url.includes('?') ? '&' : '?') + filterConfig.paramName + '=' + value @@ -213,18 +203,13 @@ export class SearchService implements OnDestroy { const requestPending = false; const responsePending = false; const isSuccessful = true; - const errorMessage = undefined; - const statusCode = '200'; - const returningPageInfo = new PageInfo(); + const error = undefined; return new RemoteData( - 'https://dspace7.4science.it/dspace-spring-rest/api/search', requestPending, responsePending, isSuccessful, - errorMessage, - statusCode, - returningPageInfo, - values + error, + payload ) } ) diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 65b32b3c0b..764107837b 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -1,3 +1,5 @@ +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { BrowseService } from './browse.service'; import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; @@ -73,20 +75,16 @@ describe('BrowseService', () => { ]; function initMockResponseCacheService(isSuccessful: boolean) { - return jasmine.createSpyObj('responseCache', { - get: cold('b-', { - b: { - response: { - isSuccessful, - browseDefinitions, - } + const rcs = getMockResponseCacheService(); + (rcs.get as any).and.returnValue(cold('b-', { + b: { + response: { + isSuccessful, + browseDefinitions, } - }) - }); - } - - function initMockRequestService() { - return jasmine.createSpyObj('requestService', ['configure']); + } + })); + return rcs; } function initTestService() { @@ -106,7 +104,7 @@ describe('BrowseService', () => { describe('if getEndpoint fires', () => { beforeEach(() => { responseCache = initMockResponseCacheService(true); - requestService = initMockRequestService(); + requestService = getMockRequestService(); service = initTestService(); spyOn(service, 'getEndpoint').and .returnValue(hot('--a-', { a: browsesEndpointURL })); @@ -157,7 +155,7 @@ describe('BrowseService', () => { it('should configure a new BrowseEndpointRequest', () => { const metadatumKey = 'dc.date.issued'; const linkName = 'items'; - const expected = new BrowseEndpointRequest(browsesEndpointURL); + const expected = new BrowseEndpointRequest(requestService.generateRequestId(), browsesEndpointURL); scheduler.schedule(() => service.getBrowseURLFor(metadatumKey, linkName).subscribe()); scheduler.flush(); @@ -171,7 +169,7 @@ describe('BrowseService', () => { describe('if getEndpoint doesn\'t fire', () => { it('should return undefined', () => { responseCache = initMockResponseCacheService(true); - requestService = initMockRequestService(); + requestService = getMockRequestService(); service = initTestService(); spyOn(service, 'getEndpoint').and .returnValue(hot('----')); @@ -188,7 +186,7 @@ describe('BrowseService', () => { describe('if the browses endpoint can\'t be retrieved', () => { it('should throw an error', () => { responseCache = initMockResponseCacheService(false); - requestService = initMockRequestService(); + requestService = getMockRequestService(); service = initTestService(); spyOn(service, 'getEndpoint').and .returnValue(hot('--a-', { a: browsesEndpointURL })); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 6d8d504b82..a321e14706 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -40,7 +40,7 @@ export class BrowseService extends HALEndpointService { return this.getEndpoint() .filter((href: string) => isNotEmpty(href)) .distinctUntilChanged() - .map((endpointURL: string) => new BrowseEndpointRequest(endpointURL)) + .map((endpointURL: string) => new BrowseEndpointRequest(this.requestService.generateRequestId(), endpointURL)) .do((request: RestRequest) => this.requestService.configure(request)) .flatMap((request: RestRequest) => { const [successResponse, errorResponse] = this.responseCache.get(request.href) diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 2e3fc01b52..9ed43c242b 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,20 +1,21 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs/Observable'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { PaginatedList } from '../../data/paginated-list'; +import { RemoteData } from '../../data/remote-data'; +import { RemoteDataError } from '../../data/remote-data-error'; +import { GetRequest } from '../../data/request.models'; +import { RequestEntry } from '../../data/request.reducer'; +import { RequestService } from '../../data/request.service'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { NormalizedObjectFactory } from '../models/normalized-object-factory'; import { CacheableObject } from '../object-cache.reducer'; import { ObjectCacheService } from '../object-cache.service'; -import { RequestService } from '../../data/request.service'; -import { ResponseCacheService } from '../response-cache.service'; -import { RequestEntry } from '../../data/request.reducer'; -import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models'; import { ResponseCacheEntry } from '../response-cache.reducer'; -import { ErrorResponse, DSOSuccessResponse } from '../response-cache.models'; -import { RemoteData } from '../../data/remote-data'; -import { GenericConstructor } from '../../shared/generic-constructor'; +import { ResponseCacheService } from '../response-cache.service'; import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators'; -import { NormalizedObjectFactory } from '../models/normalized-object-factory'; -import { RestRequest } from '../../data/request.models'; -import { PageInfo } from '../../shared/page-info.model'; @Injectable() export class RemoteDataBuildService { @@ -37,10 +38,10 @@ export class RemoteDataBuildService { this.objectCache.getRequestHrefBySelfLink(href)); const requestObs = Observable.race( - hrefObs.flatMap((href: string) => this.requestService.get(href)) + hrefObs.flatMap((href: string) => this.requestService.getByHref(href)) .filter((entry) => hasValue(entry)), requestHrefObs.flatMap((requestHref) => - this.requestService.get(requestHref)).filter((entry) => hasValue(entry)) + this.requestService.getByHref(requestHref)).filter((entry) => hasValue(entry)) ); const responseCacheObs = Observable.race( @@ -87,33 +88,19 @@ export class RemoteDataBuildService { (href: string, reqEntry: RequestEntry, resEntry: ResponseCacheEntry, payload: T) => { const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; - let isSuccessFul: boolean; - let errorMessage: string; - let statusCode: string; - let pageInfo: PageInfo; + let isSuccessful: boolean; + let error: RemoteDataError; if (hasValue(resEntry) && hasValue(resEntry.response)) { - isSuccessFul = resEntry.response.isSuccessful; - errorMessage = isSuccessFul === false ? (resEntry.response as ErrorResponse).errorMessage : undefined; - statusCode = resEntry.response.statusCode; - - if (hasValue((resEntry.response as DSOSuccessResponse).pageInfo)) { - const resPageInfo = (resEntry.response as DSOSuccessResponse).pageInfo; - if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { - pageInfo = Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); - } else { - pageInfo = resPageInfo; - } - } + isSuccessful = resEntry.response.isSuccessful; + const errorMessage = isSuccessful === false ? (resEntry.response as ErrorResponse).errorMessage : undefined; + error = new RemoteDataError(resEntry.response.statusCode, errorMessage); } return new RemoteData( - href, requestPending, responsePending, - isSuccessFul, - errorMessage, - statusCode, - pageInfo, + isSuccessful, + error, payload ); }); @@ -122,17 +109,17 @@ export class RemoteDataBuildService { buildList( hrefObs: string | Observable, normalizedType: GenericConstructor - ): Observable> { + ): Observable>> { if (typeof hrefObs === 'string') { hrefObs = Observable.of(hrefObs); } - const requestObs = hrefObs.flatMap((href: string) => this.requestService.get(href)) + const requestObs = hrefObs.flatMap((href: string) => this.requestService.getByHref(href)) .filter((entry) => hasValue(entry)); const responseCacheObs = hrefObs.flatMap((href: string) => this.responseCache.get(href)) .filter((entry) => hasValue(entry)); - const payloadObs = responseCacheObs + const tDomainListObs = responseCacheObs .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) .map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks) .flatMap((resourceUUIDs: string[]) => { @@ -146,6 +133,27 @@ export class RemoteDataBuildService { .startWith([]) .distinctUntilChanged(); + const pageInfoObs = responseCacheObs + .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => { + if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) { + const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo; + if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { + return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); + } else { + return resPageInfo; + } + } + }); + + const payloadObs = Observable.combineLatest(tDomainListObs, pageInfoObs, (tDomainList, pageInfo) => { + if (hasValue(pageInfo)) { + return new PaginatedList(pageInfo, tDomainList); + } else { + return tDomainList; + } + }); + return this.toRemoteDataObservable(hrefObs, requestObs, responseCacheObs, payloadObs); } @@ -160,7 +168,7 @@ export class RemoteDataBuildService { const resourceConstructor = NormalizedObjectFactory.getConstructor(resourceType); if (Array.isArray(normalized[relationship])) { normalized[relationship].forEach((href: string) => { - this.requestService.configure(new RestRequest(href)) + this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href)) }); const rdArr = []; @@ -174,7 +182,7 @@ export class RemoteDataBuildService { links[relationship] = rdArr[0]; } } else { - this.requestService.configure(new RestRequest(normalized[relationship])); + this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), normalized[relationship])); // 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), @@ -204,40 +212,37 @@ export class RemoteDataBuildService { .map((d: RemoteData) => d.isResponsePending) .every((b: boolean) => b === true); - const isSuccessFul: boolean = arr + const isSuccessful: boolean = arr .map((d: RemoteData) => d.hasSucceeded) .every((b: boolean) => b === true); const errorMessage: string = arr - .map((d: RemoteData) => d.errorMessage) - .map((e: string, idx: number) => { + .map((d: RemoteData) => d.error) + .map((e: RemoteDataError, idx: number) => { if (hasValue(e)) { - return `[${idx}]: ${e}`; + return `[${idx}]: ${e.message}`; } }).filter((e: string) => hasValue(e)) .join(', '); const statusCode: string = arr - .map((d: RemoteData) => d.statusCode) - .map((c: string, idx: number) => { - if (hasValue(c)) { - return `[${idx}]: ${c}`; + .map((d: RemoteData) => d.error) + .map((e: RemoteDataError, idx: number) => { + if (hasValue(e)) { + return `[${idx}]: ${e.statusCode}`; } }).filter((c: string) => hasValue(c)) .join(', '); - const pageInfo = undefined; + const error = new RemoteDataError(statusCode, errorMessage); const payload: T[] = arr.map((d: RemoteData) => d.payload); return new RemoteData( - `dspace-angular://aggregated/object/${new Date().getTime()}`, requestPending, responsePending, - isSuccessFul, - errorMessage, - statusCode, - pageInfo, + isSuccessful, + error, payload ); }) diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 3af7209b24..39c623deed 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -5,6 +5,12 @@ import { import { hasValue } from '../../shared/empty.util'; import { CacheEntry } from './cache-entry'; +export enum DirtyType { + Created = 'Created', + Updated = 'Updated', + Deleted = 'Deleted' +} + /** * An interface to represent objects that can be cached * @@ -13,6 +19,11 @@ import { CacheEntry } from './cache-entry'; export interface CacheableObject { uuid?: string; self: string; + // isNew: boolean; + // dirtyType: DirtyType; + // hasDirtyAttributes: boolean; + // changedAttributes: AttributeDiffh; + // save(): void; } /** diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 31eb2d0b6a..ae41c38fbe 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -2,20 +2,21 @@ import { Injectable } from '@angular/core'; import { MemoizedSelector, Store } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; +import { IndexName } from '../index/index.reducer'; import { ObjectCacheEntry, CacheableObject } from './object-cache.reducer'; import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; import { hasNoValue } from '../../shared/empty.util'; import { GenericConstructor } from '../shared/generic-constructor'; -import { CoreState } from '../core.reducers'; -import { keySelector } from '../shared/selectors'; +import { coreSelector, CoreState } from '../core.reducers'; +import { pathSelector } from '../shared/selectors'; function selfLinkFromUuidSelector(uuid: string): MemoizedSelector { - return keySelector('index/uuid', uuid); + return pathSelector(coreSelector, 'index', IndexName.OBJECT, uuid); } function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector { - return keySelector('data/object', selfLink); + return pathSelector(coreSelector, 'data/object', selfLink); } /** @@ -60,7 +61,7 @@ export class ObjectCacheService { * the cached plain javascript object in to an instance of * a class. * - * e.g. get('c96588c6-72d3-425d-9d47-fa896255a695', Item) + * e.g. getByUUID('c96588c6-72d3-425d-9d47-fa896255a695', Item) * * @param uuid * The UUID of the object to get diff --git a/src/app/core/cache/response-cache.service.ts b/src/app/core/cache/response-cache.service.ts index 65ce8b2bac..77a2402043 100644 --- a/src/app/core/cache/response-cache.service.ts +++ b/src/app/core/cache/response-cache.service.ts @@ -7,11 +7,11 @@ import { ResponseCacheEntry } from './response-cache.reducer'; import { hasNoValue } from '../../shared/empty.util'; import { ResponseCacheRemoveAction, ResponseCacheAddAction } from './response-cache.actions'; import { RestResponse } from './response-cache.models'; -import { CoreState } from '../core.reducers'; -import { keySelector } from '../shared/selectors'; +import { coreSelector, CoreState } from '../core.reducers'; +import { pathSelector } from '../shared/selectors'; function entryFromKeySelector(key: string): MemoizedSelector { - return keySelector('data/response', key); + return pathSelector(coreSelector, 'data/response', key); } /** diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts index c0d02be82a..b0c364a86e 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -1,6 +1,7 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/Rx'; import { GlobalConfig } from '../../../config'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { ResponseCacheService } from '../cache/response-cache.service'; import { ConfigService } from './config.service'; import { RequestService } from '../data/request.service'; @@ -38,10 +39,6 @@ describe('ConfigService', () => { const scopedEndpoint = `${serviceEndpoint}/${scopeName}`; const searchEndpoint = `${serviceEndpoint}/${BROWSE}?uuid=${scopeID}`; - function initMockRequestService(): RequestService { - return jasmine.createSpyObj('requestService', ['configure']); - } - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { return jasmine.createSpyObj('responseCache', { get: cold('c-', { @@ -60,7 +57,7 @@ describe('ConfigService', () => { beforeEach(() => { responseCache = initMockResponseCacheService(true); - requestService = initMockRequestService(); + requestService = getMockRequestService(); service = initTestService(); scheduler = getTestScheduler(); spyOn(service, 'getEndpoint').and @@ -70,7 +67,7 @@ describe('ConfigService', () => { describe('getConfigByHref', () => { it('should configure a new ConfigRequest', () => { - const expected = new ConfigRequest(scopedEndpoint); + const expected = new ConfigRequest(requestService.generateRequestId(), scopedEndpoint); scheduler.schedule(() => service.getConfigByHref(scopedEndpoint).subscribe()); scheduler.flush(); @@ -81,7 +78,7 @@ describe('ConfigService', () => { describe('getConfigByName', () => { it('should configure a new ConfigRequest', () => { - const expected = new ConfigRequest(scopedEndpoint); + const expected = new ConfigRequest(requestService.generateRequestId(), scopedEndpoint); scheduler.schedule(() => service.getConfigByName(scopeName).subscribe()); scheduler.flush(); @@ -93,7 +90,7 @@ describe('ConfigService', () => { it('should configure a new ConfigRequest', () => { findOptions.scopeID = scopeID; - const expected = new ConfigRequest(searchEndpoint); + const expected = new ConfigRequest(requestService.generateRequestId(), searchEndpoint); scheduler.schedule(() => service.getConfigBySearch(findOptions).subscribe()); scheduler.flush(); diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index 55c4055ed7..9ad4684300 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -75,14 +75,14 @@ export abstract class ConfigService extends HALEndpointService { return this.getEndpoint() .filter((href: string) => isNotEmpty(href)) .distinctUntilChanged() - .map((endpointURL: string) => new ConfigRequest(endpointURL)) + .map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)) .do((request: RestRequest) => this.requestService.configure(request)) .flatMap((request: RestRequest) => this.getConfig(request)) .distinctUntilChanged(); } public getConfigByHref(href: string): Observable { - const request = new ConfigRequest(href); + const request = new ConfigRequest(this.requestService.generateRequestId(), href); this.requestService.configure(request); return this.getConfig(request); @@ -93,7 +93,7 @@ export abstract class ConfigService extends HALEndpointService { .map((endpoint: string) => this.getConfigByNameHref(endpoint, name)) .filter((href: string) => isNotEmpty(href)) .distinctUntilChanged() - .map((endpointURL: string) => new ConfigRequest(endpointURL)) + .map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)) .do((request: RestRequest) => this.requestService.configure(request)) .flatMap((request: RestRequest) => this.getConfig(request)) .distinctUntilChanged(); @@ -104,7 +104,7 @@ export abstract class ConfigService extends HALEndpointService { .map((endpoint: string) => this.getConfigSearchHref(endpoint, options)) .filter((href: string) => isNotEmpty(href)) .distinctUntilChanged() - .map((endpointURL: string) => new ConfigRequest(endpointURL)) + .map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)) .do((request: RestRequest) => this.requestService.configure(request)) .flatMap((request: RestRequest) => this.getConfig(request)) .distinctUntilChanged(); diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index f144f773a1..7cda10b4ae 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,7 +1,7 @@ import { ObjectCacheEffects } from './cache/object-cache.effects'; import { ResponseCacheEffects } from './cache/response-cache.effects'; -import { UUIDIndexEffects } from './index/uuid-index.effects'; +import { UUIDIndexEffects } from './index/index.effects'; import { RequestEffects } from './data/request.effects'; export const coreEffects = [ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index f4c7e2bbcc..768f05f24b 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -37,6 +37,7 @@ import { RouteService } from '../shared/route.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; +import { UUIDService } from './shared/uuid.service'; const IMPORTS = [ CommonModule, @@ -75,6 +76,7 @@ const PROVIDERS = [ SubmissionDefinitionsConfigService, SubmissionFormsConfigService, SubmissionSectionsConfigService, + UUIDService, { provide: NativeWindowService, useFactory: NativeWindowFactory } ]; diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 493c9e96d9..d2898eb3c3 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -2,21 +2,21 @@ import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; import { responseCacheReducer, ResponseCacheState } from './cache/response-cache.reducer'; import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; -import { uuidIndexReducer, UUIDIndexState } from './index/uuid-index.reducer'; +import { indexReducer, IndexState } from './index/index.reducer'; import { requestReducer, RequestState } from './data/request.reducer'; export interface CoreState { 'data/object': ObjectCacheState, 'data/response': ResponseCacheState, 'data/request': RequestState, - 'index/uuid': UUIDIndexState + 'index': IndexState } export const coreReducers: ActionReducerMap = { 'data/object': objectCacheReducer, 'data/response': responseCacheReducer, 'data/request': requestReducer, - 'index/uuid': uuidIndexReducer + 'index': indexReducer }; export const coreSelector = createFeatureSelector('core'); diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index 263c4a0532..3e3d2190f7 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -12,7 +12,7 @@ describe('BrowseResponseParsingService', () => { }); describe('parse', () => { - const validRequest = new BrowseEndpointRequest('https://rest.api/discover/browses'); + const validRequest = new BrowseEndpointRequest('clients/b186e8ce-e99c-4183-bc9a-42b4821bdb78', 'https://rest.api/discover/browses'); const validResponse = { payload: { diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 0d78a9fa8d..fefe7d3730 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -2,6 +2,7 @@ import { Store } from '@ngrx/store'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/Rx'; import { GlobalConfig } from '../../../config'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { CacheableObject } from '../cache/object-cache.reducer'; @@ -62,10 +63,6 @@ describe('ComColDataService', () => { }); } - function initMockRequestService(): RequestService { - return jasmine.createSpyObj('requestService', ['configure']); - } - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { return jasmine.createSpyObj('responseCache', { get: cold('c-', { @@ -105,12 +102,12 @@ describe('ComColDataService', () => { it('should configure a new FindByIDRequest for the scope Community', () => { cds = initMockCommunityDataService(); - requestService = initMockRequestService(); + requestService = getMockRequestService(); objectCache = initMockObjectCacheService(); responseCache = initMockResponseCacheService(true); service = initTestService(); - const expected = new FindByIDRequest(communityEndpoint, scopeID); + const expected = new FindByIDRequest(requestService.generateRequestId(), communityEndpoint, scopeID); scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe()); scheduler.flush(); @@ -121,7 +118,7 @@ describe('ComColDataService', () => { describe('if the scope Community can be found', () => { beforeEach(() => { cds = initMockCommunityDataService(); - requestService = initMockRequestService(); + requestService = getMockRequestService(); objectCache = initMockObjectCacheService(); responseCache = initMockResponseCacheService(true); service = initTestService(); @@ -144,7 +141,7 @@ describe('ComColDataService', () => { describe('if the scope Community can\'t be found', () => { beforeEach(() => { cds = initMockCommunityDataService(); - requestService = initMockRequestService(); + requestService = getMockRequestService(); objectCache = initMockObjectCacheService(); responseCache = initMockResponseCacheService(false); service = initTestService(); @@ -161,7 +158,7 @@ describe('ComColDataService', () => { describe('if the scope is not specified', () => { beforeEach(() => { cds = initMockCommunityDataService(); - requestService = initMockRequestService(); + requestService = getMockRequestService(); objectCache = initMockObjectCacheService(); responseCache = initMockResponseCacheService(true); service = initTestService(); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 17d2fb313c..68981121c1 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -33,7 +33,7 @@ export abstract class ComColDataService isNotEmpty(href)) .take(1) .do((href: string) => { - const request = new FindByIDRequest(href, scopeID); + const request = new FindByIDRequest(this.requestService.generateRequestId(), href, scopeID); this.requestService.configure(request); }); diff --git a/src/app/core/data/config-response-parsing.service.spec.ts b/src/app/core/data/config-response-parsing.service.spec.ts index 46e6d61f8f..3a09de6e4c 100644 --- a/src/app/core/data/config-response-parsing.service.spec.ts +++ b/src/app/core/data/config-response-parsing.service.spec.ts @@ -1,4 +1,5 @@ import { ConfigSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { ConfigResponseParsingService } from './config-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; @@ -21,7 +22,7 @@ describe('ConfigResponseParsingService', () => { }); describe('parse', () => { - const validRequest = new ConfigRequest('https://rest.api/config/submissiondefinitions/traditional'); + const validRequest = new ConfigRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', 'https://rest.api/config/submissiondefinitions/traditional'); const validResponse = { payload: { diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index e2f41f5962..2d003d6fd1 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -8,10 +8,11 @@ import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { GenericConstructor } from '../shared/generic-constructor'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteData } from './remote-data'; -import { FindAllOptions, FindAllRequest, FindByIDRequest, RestRequest } from './request.models'; -import { RequestService } from './request.service'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { FindAllOptions, FindAllRequest, FindByIDRequest, GetRequest } from './request.models'; +import { RequestService } from './request.service'; export abstract class DataService extends HALEndpointService { protected abstract responseCache: ResponseCacheService; @@ -63,7 +64,7 @@ export abstract class DataService } } - findAll(options: FindAllOptions = {}): Observable> { + findAll(options: FindAllOptions = {}): Observable>> { const hrefObs = this.getEndpoint().filter((href: string) => isNotEmpty(href)) .flatMap((endpoint: string) => this.getFindAllHref(endpoint, options)); @@ -71,11 +72,11 @@ export abstract class DataService .filter((href: string) => hasValue(href)) .take(1) .subscribe((href: string) => { - const request = new FindAllRequest(href, options); + const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); this.requestService.configure(request); }); - return this.rdbService.buildList(hrefObs, this.normalizedResourceType); + return this.rdbService.buildList(hrefObs, this.normalizedResourceType) as Observable>>; } getFindByIDHref(endpoint, resourceID): string { @@ -90,7 +91,7 @@ export abstract class DataService .filter((href: string) => hasValue(href)) .take(1) .subscribe((href: string) => { - const request = new FindByIDRequest(href, id); + const request = new FindByIDRequest(this.requestService.generateRequestId(), href, id); this.requestService.configure(request); }); @@ -98,8 +99,25 @@ export abstract class DataService } findByHref(href: string): Observable> { - this.requestService.configure(new RestRequest(href)); + this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href)); return this.rdbService.buildSingle(href, this.normalizedResourceType); } + // TODO implement, after the structure of the REST server's POST response is finalized + // create(dso: DSpaceObject): Observable> { + // const postHrefObs = this.getEndpoint(); + // + // // TODO ID is unknown at this point + // const idHrefObs = postHrefObs.map((href: string) => this.getFindByIDHref(href, dso.id)); + // + // postHrefObs + // .filter((href: string) => hasValue(href)) + // .take(1) + // .subscribe((href: string) => { + // const request = new RestRequest(this.requestService.generateRequestId(), href, RestRequestMethod.Post, dso); + // this.requestService.configure(request); + // }); + // + // return this.rdbService.buildSingle(idHrefObs, this.normalizedResourceType); + // } } diff --git a/src/app/core/data/paginated-list.ts b/src/app/core/data/paginated-list.ts new file mode 100644 index 0000000000..f1d076927d --- /dev/null +++ b/src/app/core/data/paginated-list.ts @@ -0,0 +1,42 @@ +import { PageInfo } from '../shared/page-info.model'; + +export class PaginatedList { + + constructor( + private pageInfo: PageInfo, + public page: T[] + ) { + } + + get elementsPerPage(): number { + return this.pageInfo.elementsPerPage; + } + + set elementsPerPage(value: number) { + this.pageInfo.elementsPerPage = value; + } + + get totalElements(): number { + return this.pageInfo.totalElements; + } + + set totalElements(value: number) { + this.pageInfo.totalElements = value; + } + + get totalPages(): number { + return this.pageInfo.totalPages; + } + + set totalPages(value: number) { + this.pageInfo.totalPages = value; + } + + get currentPage(): number { + return this.pageInfo.currentPage; + } + + set currentPage(value: number) { + this.pageInfo.currentPage = value; + } +} diff --git a/src/app/core/data/remote-data-error.ts b/src/app/core/data/remote-data-error.ts new file mode 100644 index 0000000000..a2ff27a073 --- /dev/null +++ b/src/app/core/data/remote-data-error.ts @@ -0,0 +1,7 @@ +export class RemoteDataError { + constructor( + public statusCode: string, + public message: string + ) { + } +} diff --git a/src/app/core/data/remote-data.ts b/src/app/core/data/remote-data.ts index d8a2f79e66..2aa3227d12 100644 --- a/src/app/core/data/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -1,5 +1,5 @@ -import { PageInfo } from '../shared/page-info.model'; import { hasValue } from '../../shared/empty.util'; +import { RemoteDataError } from './remote-data-error'; export enum RemoteDataState { RequestPending = 'RequestPending', @@ -13,21 +13,18 @@ export enum RemoteDataState { */ export class RemoteData { constructor( - public self: string, private requestPending: boolean, private responsePending: boolean, - private isSuccessFul: boolean, - public errorMessage: string, - public statusCode: string, - public pageInfo: PageInfo, + private isSuccessful: boolean, + public error: RemoteDataError, public payload: T ) { } get state(): RemoteDataState { - if (this.isSuccessFul === true && hasValue(this.payload)) { + if (this.isSuccessful === true && hasValue(this.payload)) { return RemoteDataState.Success - } else if (this.isSuccessFul === false) { + } else if (this.isSuccessful === false) { return RemoteDataState.Failed } else if (this.requestPending === true) { return RemoteDataState.RequestPending diff --git a/src/app/core/data/request.actions.ts b/src/app/core/data/request.actions.ts index 31f0dc5996..436c365caa 100644 --- a/src/app/core/data/request.actions.ts +++ b/src/app/core/data/request.actions.ts @@ -27,8 +27,14 @@ export class RequestExecuteAction implements Action { type = RequestActionTypes.EXECUTE; payload: string; - constructor(key: string) { - this.payload = key + /** + * Create a new RequestExecuteAction + * + * @param uuid + * the request's uuid + */ + constructor(uuid: string) { + this.payload = uuid } } @@ -42,11 +48,11 @@ export class RequestCompleteAction implements Action { /** * Create a new RequestCompleteAction * - * @param key - * the key under which this request is stored, + * @param uuid + * the request's uuid */ - constructor(key: string) { - this.payload = key; + constructor(uuid: string) { + this.payload = uuid; } } /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/request.effects.ts b/src/app/core/data/request.effects.ts index 84f19679b1..379540c779 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -1,18 +1,23 @@ import { Inject, Injectable, Injector } from '@angular/core'; +import { Request } from '@angular/http'; +import { RequestArgs } from '@angular/http/src/interfaces'; import { Actions, Effect } from '@ngrx/effects'; // tslint:disable-next-line:import-blacklist import { Observable } from 'rxjs'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { isNotEmpty } from '../../shared/empty.util'; import { ErrorResponse, RestResponse } from '../cache/response-cache.models'; import { ResponseCacheService } from '../cache/response-cache.service'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; import { RequestActionTypes, RequestCompleteAction, RequestExecuteAction } from './request.actions'; -import { RequestError } from './request.models'; +import { RequestError, RestRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; @Injectable() export class RequestEffects { @@ -20,18 +25,24 @@ export class RequestEffects { @Effect() execute = this.actions$ .ofType(RequestActionTypes.EXECUTE) .flatMap((action: RequestExecuteAction) => { - return this.requestService.get(action.payload) + return this.requestService.getByUUID(action.payload) .take(1); }) - .flatMap((entry: RequestEntry) => { - return this.restApi.get(entry.request.href) + .map((entry: RequestEntry) => entry.request) + .flatMap((request: RestRequest) => { + let body; + if (isNotEmpty(request.body)) { + const serializer = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(request.body.type)); + body = JSON.stringify(serializer.serialize(request.body)); + } + return this.restApi.request(request.method, request.href, body) .map((data: DSpaceRESTV2Response) => - this.injector.get(entry.request.getResponseParser()).parse(entry.request, data)) - .do((response: RestResponse) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) - .map((response: RestResponse) => new RequestCompleteAction(entry.request.href)) + this.injector.get(request.getResponseParser()).parse(request, data)) + .do((response: RestResponse) => this.responseCache.add(request.href, response, this.EnvConfig.cache.msToLive)) + .map((response: RestResponse) => new RequestCompleteAction(request.uuid)) .catch((error: RequestError) => Observable.of(new ErrorResponse(error)) - .do((response: RestResponse) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) - .map((response: RestResponse) => new RequestCompleteAction(entry.request.href))); + .do((response: RestResponse) => this.responseCache.add(request.href, response, this.EnvConfig.cache.msToLive)) + .map((response: RestResponse) => new RequestCompleteAction(request.uuid))); }); constructor( diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index a80eccfaa8..ee37f9c3d4 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -9,22 +9,117 @@ import { BrowseResponseParsingService } from './browse-response-parsing.service' import { ConfigResponseParsingService } from './config-response-parsing.service'; /* tslint:disable:max-classes-per-file */ -export class RestRequest { + +/** + * Represents a Request Method. + * + * I didn't reuse the RequestMethod enum in @angular/http because + * it uses numbers. The string values here are more clear when + * debugging. + * + * The ones commented out are still unsupported in the rest of the codebase + */ +export enum RestRequestMethod { + Get = 'GET', + Post = 'POST', + Put = 'PUT', + Delete = 'DELETE', + Options = 'OPTIONS', + Head = 'HEAD', + Patch = 'PATCH' +} + +export abstract class RestRequest { constructor( + public uuid: string, public href: string, - ) { } + public method: RestRequestMethod = RestRequestMethod.Get, + public body?: any + ) { + } getResponseParser(): GenericConstructor { return DSOResponseParsingService; } } -export class FindByIDRequest extends RestRequest { +export class GetRequest extends RestRequest { constructor( + public uuid: string, + public href: string, + public body?: any + ) { + super(uuid, href, RestRequestMethod.Get, body) + } +} + +export class PostRequest extends RestRequest { + constructor( + public uuid: string, + public href: string, + public body?: any + ) { + super(uuid, href, RestRequestMethod.Post, body) + } +} + +export class PutRequest extends RestRequest { + constructor( + public uuid: string, + public href: string, + public body?: any + ) { + super(uuid, href, RestRequestMethod.Put, body) + } +} + +export class DeleteRequest extends RestRequest { + constructor( + public uuid: string, + public href: string, + public body?: any + ) { + super(uuid, href, RestRequestMethod.Delete, body) + } +} + +export class OptionsRequest extends RestRequest { + constructor( + public uuid: string, + public href: string, + public body?: any + ) { + super(uuid, href, RestRequestMethod.Options, body) + } +} + +export class HeadRequest extends RestRequest { + constructor( + public uuid: string, + public href: string, + public body?: any + ) { + super(uuid, href, RestRequestMethod.Head, body) + } +} + +export class PatchRequest extends RestRequest { + constructor( + public uuid: string, + public href: string, + public body?: any + ) { + super(uuid, href, RestRequestMethod.Patch, body) + } +} + +export class FindByIDRequest extends GetRequest { + constructor( + uuid: string, href: string, public resourceID: string ) { - super(href); + super(uuid, href); } } @@ -35,19 +130,20 @@ export class FindAllOptions { sort?: SortOptions; } -export class FindAllRequest extends RestRequest { +export class FindAllRequest extends GetRequest { constructor( + uuid: string, href: string, public options?: FindAllOptions, ) { - super(href); + super(uuid, href); } } -export class RootEndpointRequest extends RestRequest { - constructor(EnvConfig: GlobalConfig) { +export class RootEndpointRequest extends GetRequest { + constructor(uuid: string, EnvConfig: GlobalConfig) { const href = new RESTURLCombiner(EnvConfig, '/').toString(); - super(href); + super(uuid, href); } getResponseParser(): GenericConstructor { @@ -55,9 +151,9 @@ export class RootEndpointRequest extends RestRequest { } } -export class BrowseEndpointRequest extends RestRequest { - constructor(href: string) { - super(href); +export class BrowseEndpointRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); } getResponseParser(): GenericConstructor { @@ -65,9 +161,9 @@ export class BrowseEndpointRequest extends RestRequest { } } -export class ConfigRequest extends RestRequest { - constructor(href: string) { - super(href); +export class ConfigRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); } getResponseParser(): GenericConstructor { diff --git a/src/app/core/data/request.reducer.spec.ts b/src/app/core/data/request.reducer.spec.ts index a6d84ffe80..bd8fad5de7 100644 --- a/src/app/core/data/request.reducer.spec.ts +++ b/src/app/core/data/request.reducer.spec.ts @@ -4,7 +4,7 @@ import { requestReducer, RequestState } from './request.reducer'; import { RequestCompleteAction, RequestConfigureAction, RequestExecuteAction } from './request.actions'; -import { RestRequest } from './request.models'; +import { GetRequest, RestRequest } from './request.models'; class NullAction extends RequestCompleteAction { type = null; @@ -16,11 +16,13 @@ class NullAction extends RequestCompleteAction { } describe('requestReducer', () => { + const id1 = 'clients/eca2ea1d-6a6a-4f62-8907-176d5fec5014'; + const id2 = 'clients/eb7cde2e-a03f-4f0b-ac5d-888a4ef2b4eb'; const link1 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/567a639f-f5ff-4126-807c-b7d0910808c8'; const link2 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/1911e8a4-6939-490c-b58b-a5d70f8d91fb'; const testState: RequestState = { - [link1]: { - request: new RestRequest(link1), + [id1]: { + request: new GetRequest(id1, link1), requestPending: false, responsePending: false, completed: false @@ -44,37 +46,40 @@ describe('requestReducer', () => { it('should add the new RestRequest and set \'requestPending\' to true, \'responsePending\' to false and \'completed\' to false for the given RestRequest in the state, in response to a CONFIGURE action', () => { const state = testState; - const request = new RestRequest(link2); + const request = new GetRequest(id2, link2); const action = new RequestConfigureAction(request); const newState = requestReducer(state, action); - expect(newState[link2].request.href).toEqual(link2); - expect(newState[link2].requestPending).toEqual(true); - expect(newState[link2].responsePending).toEqual(false); - expect(newState[link2].completed).toEqual(false); + expect(newState[id2].request.uuid).toEqual(id2); + expect(newState[id2].request.href).toEqual(link2); + expect(newState[id2].requestPending).toEqual(true); + expect(newState[id2].responsePending).toEqual(false); + expect(newState[id2].completed).toEqual(false); }); it('should set \'requestPending\' to false, \'responsePending\' to true and leave \'completed\' untouched for the given RestRequest in the state, in response to an EXECUTE action', () => { const state = testState; - const action = new RequestExecuteAction(link1); + const action = new RequestExecuteAction(id1); const newState = requestReducer(state, action); - expect(newState[link1].request.href).toEqual(link1); - expect(newState[link1].requestPending).toEqual(false); - expect(newState[link1].responsePending).toEqual(true); - expect(newState[link1].completed).toEqual(state[link1].completed); + expect(newState[id1].request.uuid).toEqual(id1); + expect(newState[id1].request.href).toEqual(link1); + expect(newState[id1].requestPending).toEqual(false); + expect(newState[id1].responsePending).toEqual(true); + expect(newState[id1].completed).toEqual(state[id1].completed); }); it('should leave \'requestPending\' untouched, set \'responsePending\' to false and \'completed\' to true for the given RestRequest in the state, in response to a COMPLETE action', () => { const state = testState; - const action = new RequestCompleteAction(link1); + const action = new RequestCompleteAction(id1); const newState = requestReducer(state, action); - expect(newState[link1].request.href).toEqual(link1); - expect(newState[link1].requestPending).toEqual(state[link1].requestPending); - expect(newState[link1].responsePending).toEqual(false); - expect(newState[link1].completed).toEqual(true); + expect(newState[id1].request.uuid).toEqual(id1); + expect(newState[id1].request.href).toEqual(link1); + expect(newState[id1].requestPending).toEqual(state[id1].requestPending); + expect(newState[id1].responsePending).toEqual(false); + expect(newState[id1].completed).toEqual(true); }); }); diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts index 628725f745..3ac35d2741 100644 --- a/src/app/core/data/request.reducer.ts +++ b/src/app/core/data/request.reducer.ts @@ -12,7 +12,7 @@ export class RequestEntry { } export interface RequestState { - [key: string]: RequestEntry + [uuid: string]: RequestEntry } // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) @@ -41,7 +41,7 @@ export function requestReducer(state = initialState, action: RequestAction): Req function configureRequest(state: RequestState, action: RequestConfigureAction): RequestState { return Object.assign({}, state, { - [action.payload.href]: { + [action.payload.uuid]: { request: action.payload, requestPending: true, responsePending: false, diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts new file mode 100644 index 0000000000..17d4a89d05 --- /dev/null +++ b/src/app/core/data/request.service.spec.ts @@ -0,0 +1,446 @@ +import { Store } from '@ngrx/store'; +import { cold, hot } from 'jasmine-marbles'; +import { Observable } from 'rxjs/Observable'; +import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; +import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; +import { getMockStore } from '../../shared/mocks/mock-store'; +import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ResponseCacheService } from '../cache/response-cache.service'; +import { CoreState } from '../core.reducers'; +import { UUIDService } from '../shared/uuid.service'; +import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; +import { + DeleteRequest, + GetRequest, + HeadRequest, + OptionsRequest, + PatchRequest, + PostRequest, + PutRequest, RestRequest +} from './request.models'; +import { RequestService } from './request.service'; + +describe('RequestService', () => { + let service: RequestService; + let serviceAsAny: any; + let objectCache: ObjectCacheService; + let responseCache: ResponseCacheService; + let uuidService: UUIDService; + let store: Store; + + const testUUID = '5f2a0d2a-effa-4d54-bd54-5663b960f9eb'; + const testHref = 'https://rest.api/endpoint/selfLink'; + const testGetRequest = new GetRequest(testUUID, testHref); + const testPostRequest = new PostRequest(testUUID, testHref); + const testPutRequest = new PutRequest(testUUID, testHref); + const testDeleteRequest = new DeleteRequest(testUUID, testHref); + const testOptionsRequest = new OptionsRequest(testUUID, testHref); + const testHeadRequest = new HeadRequest(testUUID, testHref); + const testPatchRequest = new PatchRequest(testUUID, testHref); + + beforeEach(() => { + objectCache = getMockObjectCacheService(); + (objectCache.hasBySelfLink as any).and.returnValue(false); + + responseCache = getMockResponseCacheService(); + (responseCache.has as any).and.returnValue(false); + (responseCache.get as any).and.returnValue(Observable.of(undefined)); + + uuidService = getMockUUIDService(); + + store = getMockStore(); + (store.select as any).and.returnValue(Observable.of(undefined)); + + service = new RequestService( + objectCache, + responseCache, + uuidService, + store + ); + serviceAsAny = service as any; + }); + + describe('generateRequestId', () => { + it('should generate a new request ID', () => { + const result = service.generateRequestId(); + const expected = `client/${defaultUUID}`; + + expect(result).toBe(expected); + }); + }); + + describe('isPending', () => { + describe('before the request is configured', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(Observable.of(undefined)); + }); + + it('should return false', () => { + const result = service.isPending(testGetRequest); + const expected = false; + + expect(result).toBe(expected); + }); + }); + + describe('when the request has been configured but hasn\'t reached the store yet', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(Observable.of(undefined)); + serviceAsAny.requestsOnTheirWayToTheStore = [testHref]; + }); + + it('should return true', () => { + const result = service.isPending(testGetRequest); + const expected = true; + + expect(result).toBe(expected); + }); + }); + + describe('when the request has reached the store, before the server responds', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValue(Observable.of({ + completed: false + })) + }); + + it('should return true', () => { + const result = service.isPending(testGetRequest); + const expected = true; + + expect(result).toBe(expected); + }); + }); + + describe('after the server responds', () => { + beforeEach(() => { + spyOn(service, 'getByHref').and.returnValues(Observable.of({ + completed: true + })); + }); + + it('should return false', () => { + const result = service.isPending(testGetRequest); + const expected = false; + + expect(result).toBe(expected); + }); + }); + + }); + + describe('getByUUID', () => { + describe('if the request with the specified UUID exists in the store', () => { + beforeEach(() => { + (store.select as any).and.returnValues(hot('a', { + a: { + completed: true + } + })); + }); + + it('should return an Observable of the RequestEntry', () => { + const result = service.getByUUID(testUUID); + const expected = cold('b', { + b: { + completed: true + } + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('if the request with the specified UUID doesn\'t exist in the store', () => { + beforeEach(() => { + (store.select as any).and.returnValues(hot('a', { + a: undefined + })); + }); + + it('should return an Observable of undefined', () => { + const result = service.getByUUID(testUUID); + const expected = cold('b', { + b: undefined + }); + + expect(result).toBeObservable(expected); + }); + }); + + }); + + describe('getByHref', () => { + describe('when the request with the specified href exists in the store', () => { + beforeEach(() => { + (store.select as any).and.returnValues(hot('a', { + a: testUUID + })); + spyOn(service, 'getByUUID').and.returnValue(cold('b', { + b: { + completed: true + } + })); + }); + + it('should return an Observable of the RequestEntry', () => { + const result = service.getByHref(testHref); + const expected = cold('c', { + c: { + completed: true + } + }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('when the request with the specified href doesn\'t exist in the store', () => { + beforeEach(() => { + (store.select as any).and.returnValues(hot('a', { + a: undefined + })); + spyOn(service, 'getByUUID').and.returnValue(cold('b', { + b: undefined + })); + }); + + it('should return an Observable of undefined', () => { + const result = service.getByHref(testHref); + const expected = cold('c', { + c: undefined + }); + + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('configure', () => { + beforeEach(() => { + spyOn(serviceAsAny, 'dispatchRequest'); + }); + + describe('when the request is a GET request', () => { + let request: RestRequest; + + beforeEach(() => { + request = testGetRequest; + }); + + describe('and it isn\'t cached or pending', () => { + beforeEach(() => { + spyOn(serviceAsAny, 'isCachedOrPending').and.returnValue(false); + }); + + it('should dispatch the request', () => { + service.configure(request); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(request); + }); + }); + describe('and it is already cached or pending', () => { + beforeEach(() => { + spyOn(serviceAsAny, 'isCachedOrPending').and.returnValue(true); + }); + + it('shouldn\'t dispatch the request', () => { + service.configure(request); + expect(serviceAsAny.dispatchRequest).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when the request isn\'t a GET request', () => { + it('should dispatch the request', () => { + service.configure(testPostRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPostRequest); + + service.configure(testPutRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPutRequest); + + service.configure(testDeleteRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testDeleteRequest); + + service.configure(testOptionsRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testOptionsRequest); + + service.configure(testHeadRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testHeadRequest); + + service.configure(testPatchRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest); + }); + }); + }); + + describe('isCachedOrPending', () => { + describe('when the request is cached', () => { + describe('in the ObjectCache', () => { + beforeEach(() => { + (objectCache.hasBySelfLink as any).and.returnValues(true); + }); + + it('should return true', () => { + const result = serviceAsAny.isCachedOrPending(testGetRequest); + const expected = true; + + expect(result).toEqual(expected); + }); + }); + describe('in the responseCache', () => { + beforeEach(() => { + (responseCache.has as any).and.returnValues(true); + }); + + describe('and it\'s a DSOSuccessResponse', () => { + beforeEach(() => { + (responseCache.get as any).and.returnValues(Observable.of({ + response: { + isSuccessful: true, + resourceSelfLinks: [ + 'https://rest.api/endpoint/selfLink1', + 'https://rest.api/endpoint/selfLink2' + ] + } + } + )); + }); + + it('should return true if all top level links in the response are cached in the object cache', () => { + (objectCache.hasBySelfLink as any).and.returnValues(false, true, true); + + const result = serviceAsAny.isCachedOrPending(testGetRequest); + const expected = true; + + expect(result).toEqual(expected); + }); + it('should return false if not all top level links in the response are cached in the object cache', () => { + (objectCache.hasBySelfLink as any).and.returnValues(false, true, false); + + const result = serviceAsAny.isCachedOrPending(testGetRequest); + const expected = false; + + expect(result).toEqual(expected); + }); + }); + describe('and it isn\'t a DSOSuccessResponse', () => { + beforeEach(() => { + (objectCache.hasBySelfLink as any).and.returnValues(false); + (responseCache.has as any).and.returnValues(true); + (responseCache.get as any).and.returnValues(Observable.of({ + response: { + isSuccessful: true + } + } + )); + }); + + it('should return true', () => { + const result = serviceAsAny.isCachedOrPending(testGetRequest); + const expected = true; + + expect(result).toEqual(expected); + }); + }); + }); + }); + + describe('when the request is pending', () => { + beforeEach(() => { + spyOn(service, 'isPending').and.returnValue(true); + }); + + it('should return true', () => { + const result = serviceAsAny.isCachedOrPending(testGetRequest); + const expected = true; + + expect(result).toEqual(expected); + }); + }); + + describe('when the request is neither cached nor pending', () => { + it('should return false', () => { + const result = serviceAsAny.isCachedOrPending(testGetRequest); + const expected = false; + + expect(result).toEqual(expected); + }); + }); + }); + + describe('dispatchRequest', () => { + it('should dispatch a RequestConfigureAction', () => { + const request = testGetRequest; + serviceAsAny.dispatchRequest(request); + expect(store.dispatch).toHaveBeenCalledWith(new RequestConfigureAction(request)); + }); + + it('should dispatch a RequestExecuteAction', () => { + const request = testGetRequest; + serviceAsAny.dispatchRequest(request); + expect(store.dispatch).toHaveBeenCalledWith(new RequestExecuteAction(request.uuid)); + }); + + describe('when it\'s a GET request', () => { + let request: RestRequest; + beforeEach(() => { + request = testGetRequest; + }); + + it('should track it on it\'s way to the store', () => { + spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore'); + serviceAsAny.dispatchRequest(request); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).toHaveBeenCalledWith(request); + }); + }); + + describe('when it\'s not a GET request', () => { + it('shouldn\'t track it', () => { + spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore'); + + serviceAsAny.dispatchRequest(testPostRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testPutRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testDeleteRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testOptionsRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testHeadRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testPatchRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + }); + }); + }); + + describe('trackRequestsOnTheirWayToTheStore', () => { + let request: GetRequest; + + beforeEach(() => { + request = testGetRequest; + }); + + describe('when the method is called with a new request', () => { + it('should start tracking the request', () => { + expect(serviceAsAny.requestsOnTheirWayToTheStore.includes(request.href)).toBeFalsy(); + serviceAsAny.trackRequestsOnTheirWayToTheStore(request); + expect(serviceAsAny.requestsOnTheirWayToTheStore.includes(request.href)).toBeTruthy(); + }); + }); + + describe('when the request is added to the store', () => { + it('should stop tracking the request', () => { + (store.select as any).and.returnValues(Observable.of({ request })); + serviceAsAny.trackRequestsOnTheirWayToTheStore(request); + expect(serviceAsAny.requestsOnTheirWayToTheStore.includes(request.href)).toBeFalsy(); + }); + }); + }); +}); diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 0eee771a52..f589221e63 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -10,42 +10,45 @@ import { DSOSuccessResponse, RestResponse } from '../cache/response-cache.models import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheService } from '../cache/response-cache.service'; import { coreSelector, CoreState } from '../core.reducers'; -import { keySelector } from '../shared/selectors'; +import { IndexName } from '../index/index.reducer'; +import { pathSelector } from '../shared/selectors'; +import { UUIDService } from '../shared/uuid.service'; import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; -import { RestRequest } from './request.models'; +import { GetRequest, RestRequest, RestRequestMethod } from './request.models'; import { RequestEntry, RequestState } from './request.reducer'; -function entryFromHrefSelector(href: string): MemoizedSelector { - return keySelector('data/request', href); -} - -export function requestStateSelector(): MemoizedSelector { - return createSelector(coreSelector, (state: CoreState) => { - return state['data/request'] as RequestState; - }); -} - @Injectable() export class RequestService { private requestsOnTheirWayToTheStore: string[] = []; - constructor( - private objectCache: ObjectCacheService, - private responseCache: ResponseCacheService, - private store: Store - ) { + constructor(private objectCache: ObjectCacheService, + private responseCache: ResponseCacheService, + private uuidService: UUIDService, + private store: Store) { } - isPending(href: string): boolean { + private entryFromUUIDSelector(uuid: string): MemoizedSelector { + return pathSelector(coreSelector, 'data/request', uuid); + } + + private uuidFromHrefSelector(href: string): MemoizedSelector { + return pathSelector(coreSelector, 'index', IndexName.REQUEST, href); + } + + generateRequestId(): string { + return `client/${this.uuidService.generate()}`; + } + + isPending(request: GetRequest): boolean { // first check requests that haven't made it to the store yet - if (this.requestsOnTheirWayToTheStore.includes(href)) { + if (this.requestsOnTheirWayToTheStore.includes(request.href)) { return true; } // then check the store let isPending = false; - this.store.select(entryFromHrefSelector(href)) + this.getByHref(request.href) .take(1) .subscribe((re: RequestEntry) => { isPending = (hasValue(re) && !re.completed) @@ -54,11 +57,22 @@ export class RequestService { return isPending; } - get(href: string): Observable { - return this.store.select(entryFromHrefSelector(href)); + getByUUID(uuid: string): Observable { + return this.store.select(this.entryFromUUIDSelector(uuid)); + } + + getByHref(href: string): Observable { + return this.store.select(this.uuidFromHrefSelector(href)) + .flatMap((uuid: string) => this.getByUUID(uuid)); } configure(request: RestRequest): void { + if (request.method !== RestRequestMethod.Get || !this.isCachedOrPending(request)) { + this.dispatchRequest(request); + } + } + + private isCachedOrPending(request: GetRequest) { let isCached = this.objectCache.hasBySelfLink(request.href); if (!isCached && this.responseCache.has(request.href)) { const [successResponse, errorResponse] = this.responseCache.get(request.href) @@ -82,29 +96,33 @@ export class RequestService { ).subscribe((c) => isCached = c); } - const isPending = this.isPending(request.href); + const isPending = this.isPending(request); - if (!(isCached || isPending)) { - this.store.dispatch(new RequestConfigureAction(request)); - this.store.dispatch(new RequestExecuteAction(request.href)); - this.trackRequestsOnTheirWayToTheStore(request.href); + return isCached || isPending; + } + + private dispatchRequest(request: RestRequest) { + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(request.uuid)); + if (request.method === RestRequestMethod.Get) { + this.trackRequestsOnTheirWayToTheStore(request); } } /** * ngrx action dispatches are asynchronous. But this.isPending needs to return true as soon as the - * configure method for a request has been executed, otherwise certain requests will happen multiple times. + * configure method for a GET request has been executed, otherwise certain requests will happen multiple times. * - * This method will store the href of every request that gets configured in a local variable, and + * This method will store the href of every GET request that gets configured in a local variable, and * remove it as soon as it can be found in the store. */ - private trackRequestsOnTheirWayToTheStore(href: string) { - this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, href]; - this.store.select(entryFromHrefSelector(href)) + private trackRequestsOnTheirWayToTheStore(request: GetRequest) { + this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, request.href]; + this.store.select(this.entryFromUUIDSelector(request.href)) .filter((re: RequestEntry) => hasValue(re)) .take(1) .subscribe((re: RequestEntry) => { - this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== href) + this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== request.href) }); } } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts index cb39fc718e..d225eadcc4 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts @@ -1,6 +1,6 @@ export interface DSpaceRESTV2Response { payload: { - [name: string]: string; + [name: string]: any; _embedded?: any; _links?: any; page?: any; diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index e72121c4a4..b2d3197723 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Request } from '@angular/http'; +import { HttpClient, HttpResponse } from '@angular/common/http' import { Observable } from 'rxjs/Observable'; +import { RestRequestMethod } from '../data/request.models'; import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; @@ -19,10 +21,8 @@ export class DSpaceRESTv2Service { * * @param absoluteURL * A URL - * @param options - * An object, with options for the http call. - * @return {Observable} - * An Observable containing the response from the server + * @return {Observable} + * An Observable containing the response from the server */ get(absoluteURL: string): Observable { return this.http.get(absoluteURL, { observe: 'response' }) @@ -33,4 +33,25 @@ export class DSpaceRESTv2Service { }); } + /** + * Performs a request to the REST API. + * + * @param method + * the HTTP method for the request + * @param url + * the URL for the request + * @param body + * an optional body for the request + * @return {Observable} + * An Observable containing the response from the server + */ + request(method: RestRequestMethod, url: string, body?: any): Observable { + return this.http.request(method, url, { body, observe: 'response' }) + .map((res) => ({ payload: res.body, statusCode: res.statusText })) + .catch((err) => { + console.log('Error: ', err); + return Observable.throw(err); + }); + } + } diff --git a/src/app/core/index/index.actions.ts b/src/app/core/index/index.actions.ts new file mode 100644 index 0000000000..014b6561a3 --- /dev/null +++ b/src/app/core/index/index.actions.ts @@ -0,0 +1,69 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; +import { IndexName } from './index.reducer'; + +/** + * The list of HrefIndexAction type definitions + */ +export const IndexActionTypes = { + ADD: type('dspace/core/index/ADD'), + REMOVE_BY_VALUE: type('dspace/core/index/REMOVE_BY_VALUE') +}; + +/* tslint:disable:max-classes-per-file */ +/** + * An ngrx action to add an value to the index + */ +export class AddToIndexAction implements Action { + type = IndexActionTypes.ADD; + payload: { + name: IndexName; + value: string; + key: string; + }; + + /** + * Create a new AddToIndexAction + * + * @param name + * the name of the index to add to + * @param key + * the key to add + * @param value + * the self link of the resource the key belongs to + */ + constructor(name: IndexName, key: string, value: string) { + this.payload = { name, key, value }; + } +} + +/** + * An ngrx action to remove an value from the index + */ +export class RemoveFromIndexByValueAction implements Action { + type = IndexActionTypes.REMOVE_BY_VALUE; + payload: { + name: IndexName, + value: string + }; + + /** + * Create a new RemoveFromIndexByValueAction + * + * @param name + * the name of the index to remove from + * @param value + * the value to remove the UUID for + */ + constructor(name: IndexName, value: string) { + this.payload = { name, value }; + } + +} +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all HrefIndexActions + */ +export type IndexAction = AddToIndexAction | RemoveFromIndexByValueAction; diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts new file mode 100644 index 0000000000..05ae529c8e --- /dev/null +++ b/src/app/core/index/index.effects.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { Effect, Actions } from '@ngrx/effects'; + +import { + ObjectCacheActionTypes, AddToObjectCacheAction, + RemoveFromObjectCacheAction +} from '../cache/object-cache.actions'; +import { RequestActionTypes, RequestConfigureAction } from '../data/request.actions'; +import { RestRequestMethod } from '../data/request.models'; +import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions'; +import { hasValue } from '../../shared/empty.util'; +import { IndexName } from './index.reducer'; + +@Injectable() +export class UUIDIndexEffects { + + @Effect() addObject$ = this.actions$ + .ofType(ObjectCacheActionTypes.ADD) + .filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache.uuid)) + .map((action: AddToObjectCacheAction) => { + return new AddToIndexAction( + IndexName.OBJECT, + action.payload.objectToCache.uuid, + action.payload.objectToCache.self + ); + }); + + @Effect() removeObject$ = this.actions$ + .ofType(ObjectCacheActionTypes.REMOVE) + .map((action: RemoveFromObjectCacheAction) => { + return new RemoveFromIndexByValueAction( + IndexName.OBJECT, + action.payload + ); + }); + + @Effect() addRequest$ = this.actions$ + .ofType(RequestActionTypes.CONFIGURE) + .filter((action: RequestConfigureAction) => action.payload.method === RestRequestMethod.Get) + .map((action: RequestConfigureAction) => { + return new AddToIndexAction( + IndexName.REQUEST, + action.payload.href, + action.payload.uuid + ); + }); + + // @Effect() removeRequest$ = this.actions$ + // .ofType(ObjectCacheActionTypes.REMOVE) + // .map((action: RemoveFromObjectCacheAction) => { + // return new RemoveFromIndexByValueAction( + // IndexName.OBJECT, + // action.payload + // ); + // }); + + constructor(private actions$: Actions) { + + } + +} diff --git a/src/app/core/index/index.reducer.spec.ts b/src/app/core/index/index.reducer.spec.ts new file mode 100644 index 0000000000..a1cf92aeb3 --- /dev/null +++ b/src/app/core/index/index.reducer.spec.ts @@ -0,0 +1,58 @@ +import * as deepFreeze from 'deep-freeze'; + +import { IndexName, indexReducer, IndexState } from './index.reducer'; +import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions'; + +class NullAction extends AddToIndexAction { + type = null; + payload = null; + + constructor() { + super(null, null, null); + } +} + +describe('requestReducer', () => { + const key1 = '567a639f-f5ff-4126-807c-b7d0910808c8'; + const key2 = '1911e8a4-6939-490c-b58b-a5d70f8d91fb'; + const val1 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/567a639f-f5ff-4126-807c-b7d0910808c8'; + const val2 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/1911e8a4-6939-490c-b58b-a5d70f8d91fb'; + const testState: IndexState = { + [IndexName.OBJECT]: { + [key1]: val1 + } + }; + deepFreeze(testState); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = indexReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it('should start with an empty state', () => { + const action = new NullAction(); + const initialState = indexReducer(undefined, action); + + expect(initialState).toEqual(Object.create(null)); + }); + + it('should add the \'key\' with the corresponding \'value\' to the correct substate, in response to an ADD action', () => { + const state = testState; + + const action = new AddToIndexAction(IndexName.REQUEST, key2, val2); + const newState = indexReducer(state, action); + + expect(newState[IndexName.REQUEST][key2]).toEqual(val2); + }); + + it('should remove the given \'value\' from its corresponding \'key\' in the correct substate, in response to a REMOVE_BY_VALUE action', () => { + const state = testState; + + const action = new RemoveFromIndexByValueAction(IndexName.OBJECT, val1); + const newState = indexReducer(state, action); + + expect(newState[IndexName.OBJECT][key1]).toBeUndefined(); + }); +}); diff --git a/src/app/core/index/index.reducer.ts b/src/app/core/index/index.reducer.ts new file mode 100644 index 0000000000..869dee9e51 --- /dev/null +++ b/src/app/core/index/index.reducer.ts @@ -0,0 +1,62 @@ +import { + IndexAction, + IndexActionTypes, + AddToIndexAction, + RemoveFromIndexByValueAction +} from './index.actions'; + +export enum IndexName { + OBJECT = 'object/uuid-to-self-link', + REQUEST = 'get-request/href-to-uuid' +} + +export interface IndexState { + // TODO this should be `[name in IndexName]: {` but that's currently broken, + // see https://github.com/Microsoft/TypeScript/issues/13042 + [name: string]: { + [key: string]: string + } +} + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState: IndexState = Object.create(null); + +export function indexReducer(state = initialState, action: IndexAction): IndexState { + switch (action.type) { + + case IndexActionTypes.ADD: { + return addToIndex(state, action as AddToIndexAction); + } + + case IndexActionTypes.REMOVE_BY_VALUE: { + return removeFromIndexByValue(state, action as RemoveFromIndexByValueAction) + } + + default: { + return state; + } + } +} + +function addToIndex(state: IndexState, action: AddToIndexAction): IndexState { + const subState = state[action.payload.name]; + const newSubState = Object.assign({}, subState, { + [action.payload.key]: action.payload.value + }); + return Object.assign({}, state, { + [action.payload.name]: newSubState + }) +} + +function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValueAction): IndexState { + const subState = state[action.payload.name]; + const newSubState = Object.create(null); + for (const value in subState) { + if (subState[value] !== action.payload.value) { + newSubState[value] = subState[value]; + } + } + return Object.assign({}, state, { + [action.payload.name]: newSubState + }); +} diff --git a/src/app/core/index/uuid-index.actions.ts b/src/app/core/index/uuid-index.actions.ts deleted file mode 100644 index 0bea11204c..0000000000 --- a/src/app/core/index/uuid-index.actions.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Action } from '@ngrx/store'; - -import { type } from '../../shared/ngrx/type'; - -/** - * The list of HrefIndexAction type definitions - */ -export const UUIDIndexActionTypes = { - ADD: type('dspace/core/index/uuid/ADD'), - REMOVE_HREF: type('dspace/core/index/uuid/REMOVE_HREF') -}; - -/* tslint:disable:max-classes-per-file */ -/** - * An ngrx action to add an href to the index - */ -export class AddToUUIDIndexAction implements Action { - type = UUIDIndexActionTypes.ADD; - payload: { - href: string; - uuid: string; - }; - - /** - * Create a new AddToUUIDIndexAction - * - * @param uuid - * the uuid to add - * @param href - * the self link of the resource the uuid belongs to - */ - constructor(uuid: string, href: string) { - this.payload = { href, uuid }; - } -} - -/** - * An ngrx action to remove an href from the index - */ -export class RemoveHrefFromUUIDIndexAction implements Action { - type = UUIDIndexActionTypes.REMOVE_HREF; - payload: string; - - /** - * Create a new RemoveHrefFromUUIDIndexAction - * - * @param href - * the href to remove the UUID for - */ - constructor(href: string) { - this.payload = href; - } - -} -/* tslint:enable:max-classes-per-file */ - -/** - * A type to encompass all HrefIndexActions - */ -export type UUIDIndexAction = AddToUUIDIndexAction | RemoveHrefFromUUIDIndexAction; diff --git a/src/app/core/index/uuid-index.effects.ts b/src/app/core/index/uuid-index.effects.ts deleted file mode 100644 index 2f5900ed04..0000000000 --- a/src/app/core/index/uuid-index.effects.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Effect, Actions } from '@ngrx/effects'; - -import { - ObjectCacheActionTypes, AddToObjectCacheAction, - RemoveFromObjectCacheAction -} from '../cache/object-cache.actions'; -import { AddToUUIDIndexAction, RemoveHrefFromUUIDIndexAction } from './uuid-index.actions'; -import { hasValue } from '../../shared/empty.util'; - -@Injectable() -export class UUIDIndexEffects { - - @Effect() add$ = this.actions$ - .ofType(ObjectCacheActionTypes.ADD) - .filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache.uuid)) - .map((action: AddToObjectCacheAction) => { - return new AddToUUIDIndexAction( - action.payload.objectToCache.uuid, - action.payload.objectToCache.self - ); - }); - - @Effect() remove$ = this.actions$ - .ofType(ObjectCacheActionTypes.REMOVE) - .map((action: RemoveFromObjectCacheAction) => { - return new RemoveHrefFromUUIDIndexAction(action.payload); - }); - - constructor(private actions$: Actions) { - - } - -} diff --git a/src/app/core/index/uuid-index.reducer.spec.ts b/src/app/core/index/uuid-index.reducer.spec.ts deleted file mode 100644 index e477d8df2e..0000000000 --- a/src/app/core/index/uuid-index.reducer.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as deepFreeze from 'deep-freeze'; - -import { uuidIndexReducer, UUIDIndexState } from './uuid-index.reducer'; -import { AddToUUIDIndexAction, RemoveHrefFromUUIDIndexAction } from './uuid-index.actions'; - -class NullAction extends AddToUUIDIndexAction { - type = null; - payload = null; - - constructor() { - super(null, null); - } -} - -describe('requestReducer', () => { - const link1 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/567a639f-f5ff-4126-807c-b7d0910808c8'; - const link2 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/1911e8a4-6939-490c-b58b-a5d70f8d91fb'; - const uuid1 = '567a639f-f5ff-4126-807c-b7d0910808c8'; - const uuid2 = '1911e8a4-6939-490c-b58b-a5d70f8d91fb'; - const testState: UUIDIndexState = { - [uuid1]: link1 - }; - deepFreeze(testState); - - it('should return the current state when no valid actions have been made', () => { - const action = new NullAction(); - const newState = uuidIndexReducer(testState, action); - - expect(newState).toEqual(testState); - }); - - it('should start with an empty state', () => { - const action = new NullAction(); - const initialState = uuidIndexReducer(undefined, action); - - expect(initialState).toEqual(Object.create(null)); - }); - - it('should add the \'uuid\' with the corresponding \'href\' to the state, in response to an ADD action', () => { - const state = testState; - - const action = new AddToUUIDIndexAction(uuid2, link2); - const newState = uuidIndexReducer(state, action); - - expect(newState[uuid2]).toEqual(link2); - }); - - it('should remove the given \'href\' from its corresponding \'uuid\' in the state, in response to a REMOVE_HREF action', () => { - const state = testState; - - const action = new RemoveHrefFromUUIDIndexAction(link1); - const newState = uuidIndexReducer(state, action); - - expect(newState[uuid1]).toBeUndefined(); - }); -}); diff --git a/src/app/core/index/uuid-index.reducer.ts b/src/app/core/index/uuid-index.reducer.ts deleted file mode 100644 index 191dd8f463..0000000000 --- a/src/app/core/index/uuid-index.reducer.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - UUIDIndexAction, - UUIDIndexActionTypes, - AddToUUIDIndexAction, - RemoveHrefFromUUIDIndexAction -} from './uuid-index.actions'; - -export interface UUIDIndexState { - [uuid: string]: string -} - -// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) -const initialState: UUIDIndexState = Object.create(null); - -export function uuidIndexReducer(state = initialState, action: UUIDIndexAction): UUIDIndexState { - switch (action.type) { - - case UUIDIndexActionTypes.ADD: { - return addToUUIDIndex(state, action as AddToUUIDIndexAction); - } - - case UUIDIndexActionTypes.REMOVE_HREF: { - return removeHrefFromUUIDIndex(state, action as RemoveHrefFromUUIDIndexAction) - } - - default: { - return state; - } - } -} - -function addToUUIDIndex(state: UUIDIndexState, action: AddToUUIDIndexAction): UUIDIndexState { - return Object.assign({}, state, { - [action.payload.uuid]: action.payload.href - }); -} - -function removeHrefFromUUIDIndex(state: UUIDIndexState, action: RemoveHrefFromUUIDIndexAction): UUIDIndexState { - const newState = Object.create(null); - for (const uuid in state) { - if (state[uuid] !== action.payload) { - newState[uuid] = state[uuid]; - } - } - return newState; -} diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 4c8775fcfb..4182587cc7 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -11,6 +11,8 @@ import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { Store, StoreModule } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; +import { RemoteDataError } from '../data/remote-data-error'; +import { UUIDService } from '../shared/uuid.service'; import { MetadataService } from './metadata.service'; @@ -64,6 +66,7 @@ describe('MetadataService', () => { let objectCacheService: ObjectCacheService; let responseCacheService: ResponseCacheService; let requestService: RequestService; + let uuidService: UUIDService; let remoteDataBuildService: RemoteDataBuildService; let itemDataService: ItemDataService; @@ -82,7 +85,8 @@ describe('MetadataService', () => { objectCacheService = new ObjectCacheService(store); responseCacheService = new ResponseCacheService(store); - requestService = new RequestService(objectCacheService, responseCacheService, store); + uuidService = new UUIDService(); + requestService = new RequestService(objectCacheService, responseCacheService, uuidService, store); remoteDataBuildService = new RemoteDataBuildService(objectCacheService, responseCacheService, requestService); TestBed.configureTestingModule({ @@ -178,13 +182,10 @@ describe('MetadataService', () => { const mockRemoteData = (mockItem: Item): Observable> => { return Observable.of(new RemoteData( - '', false, false, true, - '', - '200', - {} as PageInfo, + undefined, MockItem )); } diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index f7adc1eccf..a47bfd745c 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -1,5 +1,6 @@ import { cold, hot } from 'jasmine-marbles'; import { GlobalConfig } from '../../../config/global-config.interface'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { ResponseCacheService } from '../cache/response-cache.service'; import { RootEndpointRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; @@ -38,7 +39,7 @@ describe('HALEndpointService', () => { }) }); - requestService = jasmine.createSpyObj('requestService', ['configure']); + requestService = getMockRequestService(); envConfig = { rest: { baseUrl: 'https://rest.api/' } @@ -53,7 +54,7 @@ describe('HALEndpointService', () => { it('should configure a new RootEndpointRequest', () => { (service as any).getEndpointMap(); - const expected = new RootEndpointRequest(envConfig); + const expected = new RootEndpointRequest(requestService.generateRequestId(), envConfig); expect(requestService.configure).toHaveBeenCalledWith(expected); }); diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index fa11fed308..84587f1eea 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -14,7 +14,7 @@ export abstract class HALEndpointService { protected abstract EnvConfig: GlobalConfig; protected getEndpointMap(): Observable { - const request = new RootEndpointRequest(this.EnvConfig); + const request = new RootEndpointRequest(this.requestService.generateRequestId(), this.EnvConfig); this.requestService.configure(request); return this.responseCache.get(request.href) .map((entry: ResponseCacheEntry) => entry.response) diff --git a/src/app/core/shared/item.model.spec.ts b/src/app/core/shared/item.model.spec.ts index 1e962f7038..c020cd3454 100644 --- a/src/app/core/shared/item.model.spec.ts +++ b/src/app/core/shared/item.model.spec.ts @@ -104,13 +104,10 @@ describe('Item', () => { function createRemoteDataObject(object: any) { return Observable.of(new RemoteData( - '', false, false, true, undefined, - '200', - new PageInfo(), object )); diff --git a/src/app/core/shared/selectors.ts b/src/app/core/shared/selectors.ts index 06f444b2e6..7bd35d39c1 100644 --- a/src/app/core/shared/selectors.ts +++ b/src/app/core/shared/selectors.ts @@ -1,13 +1,17 @@ import { createSelector, MemoizedSelector } from '@ngrx/store'; -import { coreSelector, CoreState } from '../core.reducers'; -import { hasValue } from '../../shared/empty.util'; +import { hasNoValue, isEmpty } from '../../shared/empty.util'; -export function keySelector(subState: string, key: string): MemoizedSelector { - return createSelector(coreSelector, (state: CoreState) => { - if (hasValue(state[subState])) { - return state[subState][key]; - } else { - return undefined; - } - }); +export function pathSelector(selector: MemoizedSelector, ...path: string[]): MemoizedSelector { + return createSelector(selector, (state: any) => getSubState(state, path)); +} + +function getSubState(state: any, path: string[]) { + const current = path[0]; + const remainingPath = path.slice(1); + const subState = state[current]; + if (hasNoValue(subState) || isEmpty(remainingPath)) { + return subState; + } else { + return getSubState(subState, remainingPath); + } } diff --git a/src/app/core/shared/uuid.service.ts b/src/app/core/shared/uuid.service.ts new file mode 100644 index 0000000000..6c02facbac --- /dev/null +++ b/src/app/core/shared/uuid.service.ts @@ -0,0 +1,9 @@ +import { Injectable } from '@angular/core'; +import * as uuidv4 from 'uuid/v4'; + +@Injectable() +export class UUIDService { + generate(): string { + return uuidv4(); + } +} diff --git a/src/app/shared/mocks/mock-item.ts b/src/app/shared/mocks/mock-item.ts index 6b34a31888..81c1fcf26c 100644 --- a/src/app/shared/mocks/mock-item.ts +++ b/src/app/shared/mocks/mock-item.ts @@ -13,7 +13,7 @@ export const MockItem: Item = Object.assign(new Item(), { self: 'dspace-angular://aggregated/object/1507836003548', requestPending: false, responsePending: false, - isSuccessFul: true, + isSuccessful: true, errorMessage: '', statusCode: '202', pageInfo: {}, @@ -25,7 +25,7 @@ export const MockItem: Item = Object.assign(new Item(), { self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/10', requestPending: false, responsePending: false, - isSuccessFul: true, + isSuccessful: true, errorMessage: '', statusCode: '202', pageInfo: {}, @@ -60,7 +60,7 @@ export const MockItem: Item = Object.assign(new Item(), { self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4', requestPending: false, responsePending: false, - isSuccessFul: true, + isSuccessful: true, errorMessage: '', statusCode: '202', pageInfo: {}, @@ -191,7 +191,7 @@ export const MockItem: Item = Object.assign(new Item(), { self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', requestPending: false, responsePending: false, - isSuccessFul: true, + isSuccessful: true, errorMessage: '', statusCode: '202', pageInfo: {}, diff --git a/src/app/shared/mocks/mock-object-cache.service.ts b/src/app/shared/mocks/mock-object-cache.service.ts new file mode 100644 index 0000000000..9e35a519ff --- /dev/null +++ b/src/app/shared/mocks/mock-object-cache.service.ts @@ -0,0 +1,16 @@ +import { ObjectCacheService } from '../../core/cache/object-cache.service'; + +export function getMockObjectCacheService(): ObjectCacheService { + return jasmine.createSpyObj('objectCacheService', [ + 'add', + 'remove', + 'getByUUID', + 'getBySelfLink', + 'getRequestHrefBySelfLink', + 'getRequestHrefByUUID', + 'getList', + 'hasByUUID', + 'hasBySelfLink' + ]); + +} diff --git a/src/app/shared/mocks/mock-request.service.ts b/src/app/shared/mocks/mock-request.service.ts new file mode 100644 index 0000000000..ed8ffa028d --- /dev/null +++ b/src/app/shared/mocks/mock-request.service.ts @@ -0,0 +1,8 @@ +import { RequestService } from '../../core/data/request.service'; + +export function getMockRequestService(): RequestService { + return jasmine.createSpyObj('requestService', { + configure: () => false, + generateRequestId: () => 'clients/b186e8ce-e99c-4183-bc9a-42b4821bdb78' + }); +} diff --git a/src/app/shared/mocks/mock-response-cache.service.ts b/src/app/shared/mocks/mock-response-cache.service.ts new file mode 100644 index 0000000000..95b4e7aca0 --- /dev/null +++ b/src/app/shared/mocks/mock-response-cache.service.ts @@ -0,0 +1,10 @@ +import { ResponseCacheService } from '../../core/cache/response-cache.service'; + +export function getMockResponseCacheService(): ResponseCacheService { + return jasmine.createSpyObj('ResponseCacheService', [ + 'add', + 'get', + 'has', + ]); + +} diff --git a/src/app/shared/mocks/mock-store.ts b/src/app/shared/mocks/mock-store.ts index c619b5aa77..73c87d324a 100644 --- a/src/app/shared/mocks/mock-store.ts +++ b/src/app/shared/mocks/mock-store.ts @@ -1,23 +1,15 @@ -import { Action } from '@ngrx/store'; +import { Store } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; - -export class MockStore extends BehaviorSubject { - - constructor(private _initialState: T) { - super(_initialState); - } - - dispatch = (action: Action): void => { - console.info(); - } - - select = (pathOrMapFn: any): Observable => { - return Observable.of(this.getValue()); - } - - nextState(_newState: T) { - this.next(_newState); - } +export function getMockStore(): Store { + return jasmine.createSpyObj('store', [ + 'select', + 'dispatch', + 'lift', + 'next', + 'error', + 'complete', + 'addReducer', + 'removeReducer' + ]); } diff --git a/src/app/shared/mocks/mock-uuid.service.ts b/src/app/shared/mocks/mock-uuid.service.ts new file mode 100644 index 0000000000..1acf3e1f3e --- /dev/null +++ b/src/app/shared/mocks/mock-uuid.service.ts @@ -0,0 +1,9 @@ +import { UUIDService } from '../../core/shared/uuid.service'; + +export const defaultUUID = 'c4ce6905-290b-478f-979d-a333bbd7820f'; + +export function getMockUUIDService(uuid = defaultUUID): UUIDService { + return jasmine.createSpyObj('uuidService', { + generate: uuid, + }); +} diff --git a/src/app/shared/object-grid/object-grid.component.html b/src/app/shared/object-grid/object-grid.component.html index 64231bc931..8040a99552 100644 --- a/src/app/shared/object-grid/object-grid.component.html +++ b/src/app/shared/object-grid/object-grid.component.html @@ -1,7 +1,7 @@
    + *ngFor="let object of objects?.payload?.page">
    diff --git a/src/app/shared/object-grid/object-grid.component.ts b/src/app/shared/object-grid/object-grid.component.ts index 2bf77fc0c3..a8f8ebb183 100644 --- a/src/app/shared/object-grid/object-grid.component.ts +++ b/src/app/shared/object-grid/object-grid.component.ts @@ -1,20 +1,20 @@ import { ChangeDetectionStrategy, - Component, EventEmitter, + Component, + EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { PaginatedList } from '../../core/data/paginated-list'; + import { RemoteData } from '../../core/data/remote-data'; -import { PageInfo } from '../../core/shared/page-info.model'; - -import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; - -import { SortOptions, SortDirection } from '../../core/cache/models/sort-options.model'; import { fadeIn } from '../animations/fade'; import { ListableObject } from '../object-collection/shared/listable-object.model'; -import { hasValue } from '../empty.util'; + +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; @Component({ changeDetection: ChangeDetectionStrategy.Default, @@ -31,13 +31,9 @@ export class ObjectGridComponent { @Input() sortConfig: SortOptions; @Input() hideGear = false; @Input() hidePagerWhenSinglePage = true; - private _objects: RemoteData; - pageInfo: PageInfo; - @Input() set objects(objects: RemoteData) { + private _objects: RemoteData>; + @Input() set objects(objects: RemoteData>) { this._objects = objects; - if (hasValue(objects)) { - this.pageInfo = objects.pageInfo; - } } get objects() { return this._objects; diff --git a/src/app/shared/object-list/object-list.component.html b/src/app/shared/object-list/object-list.component.html index 39d0ce8758..8de695ae58 100644 --- a/src/app/shared/object-list/object-list.component.html +++ b/src/app/shared/object-list/object-list.component.html @@ -1,7 +1,7 @@
      -
    • +
    diff --git a/src/app/shared/object-list/object-list.component.ts b/src/app/shared/object-list/object-list.component.ts index b9abed37ac..b0296d5ae1 100644 --- a/src/app/shared/object-list/object-list.component.ts +++ b/src/app/shared/object-list/object-list.component.ts @@ -6,17 +6,12 @@ import { Output, ViewEncapsulation } from '@angular/core'; - +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { PaginatedList } from '../../core/data/paginated-list'; import { RemoteData } from '../../core/data/remote-data'; -import { PageInfo } from '../../core/shared/page-info.model'; - -import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; - -import { SortOptions, SortDirection } from '../../core/cache/models/sort-options.model'; - import { fadeIn } from '../animations/fade'; import { ListableObject } from '../object-collection/shared/listable-object.model'; -import { hasValue } from '../empty.util'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; @Component({ changeDetection: ChangeDetectionStrategy.Default, @@ -32,14 +27,11 @@ export class ObjectListComponent { @Input() sortConfig: SortOptions; @Input() hideGear = false; @Input() hidePagerWhenSinglePage = true; - private _objects: RemoteData; - pageInfo: PageInfo; - @Input() set objects(objects: RemoteData) { + private _objects: RemoteData>; + @Input() set objects(objects: RemoteData>) { this._objects = objects; - if (hasValue(objects)) { - this.pageInfo = objects.pageInfo; - } } + get objects() { return this._objects; } @@ -82,6 +74,7 @@ export class ObjectListComponent { */ @Output() sortFieldChange: EventEmitter = new EventEmitter(); data: any = {}; + onPageChange(event) { this.pageChange.emit(event); } @@ -101,4 +94,5 @@ export class ObjectListComponent { onPaginationChange(event) { this.paginationChange.emit(event); } + } diff --git a/yarn.lock b/yarn.lock index 91b2a787e2..3955f0a44b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -225,6 +225,12 @@ version "0.5.1" resolved "https://registry.yarnpkg.com/@types/source-map/-/source-map-0.5.1.tgz#7e74db5d06ab373a712356eebfaea2fad0ea2367" +"@types/uuid@^3.4.3": + version "3.4.3" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.3.tgz#121ace265f5569ce40f4f6d0ff78a338c732a754" + dependencies: + "@types/node" "*" + "@types/webfontloader@1.6.29": version "1.6.29" resolved "https://registry.yarnpkg.com/@types/webfontloader/-/webfontloader-1.6.29.tgz#c6b5f6eb8ca31d0aae6b02b6c1300349dd93ea8e"