diff --git a/src/app/core/cache/cache.reducers.ts b/src/app/core/cache/cache.reducers.ts index 2edd1e8ebf..b5cd5c7b41 100644 --- a/src/app/core/cache/cache.reducers.ts +++ b/src/app/core/cache/cache.reducers.ts @@ -1,14 +1,14 @@ import { combineReducers } from "@ngrx/store"; -import { RequestCacheState, requestCacheReducer } from "./request-cache.reducer"; +import { ResponseCacheState, responseCacheReducer } from "./response-cache.reducer"; import { ObjectCacheState, objectCacheReducer } from "./object-cache.reducer"; export interface CacheState { - request: RequestCacheState, + response: ResponseCacheState, object: ObjectCacheState } export const reducers = { - request: requestCacheReducer, + response: responseCacheReducer, object: objectCacheReducer }; diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 23b0188216..85e1fdc2b3 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -12,6 +12,7 @@ import { CacheEntry } from "./cache-entry"; */ export interface CacheableObject { uuid: string; + self?: string; } /** diff --git a/src/app/core/cache/request-cache.actions.ts b/src/app/core/cache/request-cache.actions.ts deleted file mode 100644 index 78c6692d71..0000000000 --- a/src/app/core/cache/request-cache.actions.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { OpaqueToken } from "@angular/core"; -import { Action } from "@ngrx/store"; -import { type } from "../../shared/ngrx/type"; -import { PaginationOptions } from "../shared/pagination-options.model"; -import { SortOptions } from "../shared/sort-options.model"; - -/** - * The list of RequestCacheAction type definitions - */ -export const RequestCacheActionTypes = { - FIND_BY_ID: type('dspace/core/cache/request/FIND_BY_ID'), - FIND_ALL: type('dspace/core/cache/request/FIND_ALL'), - SUCCESS: type('dspace/core/cache/request/SUCCESS'), - ERROR: type('dspace/core/cache/request/ERROR'), - REMOVE: type('dspace/core/cache/request/REMOVE'), - RESET_TIMESTAMPS: type('dspace/core/cache/request/RESET_TIMESTAMPS') -}; - -/** - * An ngrx action to find all objects of a certain type - */ -export class RequestCacheFindAllAction implements Action { - type = RequestCacheActionTypes.FIND_ALL; - payload: { - key: string, - service: OpaqueToken, - scopeID: string, - paginationOptions: PaginationOptions, - sortOptions: SortOptions - }; - - /** - * Create a new RequestCacheFindAllAction - * - * @param key - * the key under which to cache this request, should be unique - * @param service - * the name of the service that initiated the action - * @param scopeID - * the id of an optional scope object - * @param paginationOptions - * the pagination options - * @param sortOptions - * the sort options - */ - constructor( - key: string, - service: OpaqueToken, - scopeID?: string, - paginationOptions: PaginationOptions = new PaginationOptions(), - sortOptions: SortOptions = new SortOptions() - ) { - this.payload = { - key, - service, - scopeID, - paginationOptions, - sortOptions - } - } -} - -/** - * An ngrx action to find objects by id - */ -export class RequestCacheFindByIDAction implements Action { - type = RequestCacheActionTypes.FIND_BY_ID; - payload: { - key: string, - service: OpaqueToken, - resourceID: string - }; - - /** - * Create a new RequestCacheFindByIDAction - * - * @param key - * the key under which to cache this request, should be unique - * @param service - * the name of the service that initiated the action - * @param resourceID - * the ID of the resource to find - */ - constructor( - key: string, - service: OpaqueToken, - resourceID: string - ) { - this.payload = { - key, - service, - resourceID - } - } -} - -/** - * An ngrx action to indicate a request was returned successful - */ -export class RequestCacheSuccessAction implements Action { - type = RequestCacheActionTypes.SUCCESS; - payload: { - key: string, - resourceUUIDs: Array, - timeAdded: number, - msToLive: number - }; - - /** - * Create a new RequestCacheSuccessAction - * - * @param key - * the key under which cache this request is cached, - * should be identical to the one used in the corresponding - * find action - * @param resourceUUIDs - * the UUIDs returned from the backend - * @param timeAdded - * the time it was returned - * @param msToLive - * the amount of milliseconds before it should expire - */ - constructor(key: string, resourceUUIDs: Array, timeAdded, msToLive: number) { - this.payload = { - key, - resourceUUIDs, - timeAdded, - msToLive - }; - } -} - -/** - * An ngrx action to indicate a request failed - */ -export class RequestCacheErrorAction implements Action { - type = RequestCacheActionTypes.ERROR; - payload: { - key: string, - errorMessage: string - }; - - /** - * Create a new RequestCacheErrorAction - * - * @param key - * the key under which cache this request is cached, - * should be identical to the one used in the corresponding - * find action - * @param errorMessage - * A message describing the reason the request failed - */ - constructor(key: string, errorMessage: string) { - this.payload = { - key, - errorMessage - }; - } -} - -/** - * An ngrx action to remove a request from the cache - */ -export class RequestCacheRemoveAction implements Action { - type = RequestCacheActionTypes.REMOVE; - payload: string; - - /** - * Create a new RequestCacheRemoveAction - * @param key - * The key of the request to remove - */ - constructor(key: string) { - this.payload = key; - } -} - -/** - * An ngrx action to reset the timeAdded property of all cached objects - */ -export class ResetRequestCacheTimestampsAction implements Action { - type = RequestCacheActionTypes.RESET_TIMESTAMPS; - payload: number; - - /** - * Create a new ResetObjectCacheTimestampsAction - * - * @param newTimestamp - * the new timeAdded all objects should get - */ - constructor(newTimestamp: number) { - this.payload = newTimestamp; - } -} - -/** - * A type to encompass all RequestCacheActions - */ -export type RequestCacheAction - = RequestCacheFindAllAction - | RequestCacheFindByIDAction - | RequestCacheSuccessAction - | RequestCacheErrorAction - | RequestCacheRemoveAction - | ResetRequestCacheTimestampsAction; diff --git a/src/app/core/cache/request-cache.reducer.spec.ts b/src/app/core/cache/request-cache.reducer.spec.ts deleted file mode 100644 index 56bea6a83e..0000000000 --- a/src/app/core/cache/request-cache.reducer.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { requestCacheReducer, RequestCacheState } from "./request-cache.reducer"; -import { - RequestCacheRemoveAction, RequestCacheFindByIDAction, - RequestCacheFindAllAction, RequestCacheSuccessAction, RequestCacheErrorAction, - ResetRequestCacheTimestampsAction -} from "./request-cache.actions"; -import deepFreeze = require("deep-freeze"); -import { OpaqueToken } from "@angular/core"; - -class NullAction extends RequestCacheRemoveAction { - type = null; - payload = null; - - constructor() { - super(null); - } -} - -describe("requestCacheReducer", () => { - const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"]; - const services = [new OpaqueToken('service1'), new OpaqueToken('service2')]; - const msToLive = 900000; - const uuids = [ - "9e32a2e2-6b91-4236-a361-995ccdc14c60", - "598ce822-c357-46f3-ab70-63724d02d6ad", - "be8325f7-243b-49f4-8a4b-df2b793ff3b5" - ]; - const resourceID = "9978"; - const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 }; - const sortOptions = { "field": "id", "direction": 0 }; - const testState = { - [keys[0]]: { - "key": keys[0], - "service": services[0], - "resourceUUIDs": [uuids[0], uuids[1]], - "isLoading": false, - "paginationOptions": paginationOptions, - "sortOptions": sortOptions, - "timeAdded": new Date().getTime(), - "msToLive": msToLive - }, - [keys[1]]: { - "key": keys[1], - "service": services[1], - "resourceID": resourceID, - "resourceUUIDs": [uuids[2]], - "isLoading": false, - "timeAdded": new Date().getTime(), - "msToLive": msToLive - } - }; - deepFreeze(testState); - const errorState: {} = { - [keys[0]]: { - errorMessage: 'error', - resourceUUIDs: uuids - } - }; - deepFreeze(errorState); - - - it("should return the current state when no valid actions have been made", () => { - const action = new NullAction(); - const newState = requestCacheReducer(testState, action); - - expect(newState).toEqual(testState); - }); - - it("should start with an empty cache", () => { - const action = new NullAction(); - const initialState = requestCacheReducer(undefined, action); - - expect(initialState).toEqual(Object.create(null)); - }); - - describe("FIND_BY_ID", () => { - const action = new RequestCacheFindByIDAction(keys[0], services[0], resourceID); - - it("should perform the action without affecting the previous state", () => { - //testState has already been frozen above - requestCacheReducer(testState, action); - }); - - it("should add the request to the cache", () => { - const state = Object.create(null); - const newState = requestCacheReducer(state, action); - expect(newState[keys[0]].key).toBe(keys[0]); - expect(newState[keys[0]].service).toEqual(services[0]); - expect(newState[keys[0]].resourceID).toBe(resourceID); - }); - - it("should set isLoading to true", () => { - const state = Object.create(null); - const newState = requestCacheReducer(state, action); - expect(newState[keys[0]].isLoading).toBe(true); - }); - - it("should remove any previous error message or resourceUUID for the request", () => { - const newState = requestCacheReducer(errorState, action); - expect(newState[keys[0]].resourceUUIDs.length).toBe(0); - expect(newState[keys[0]].errorMessage).toBeUndefined(); - }); - }); - - describe("FIND_ALL", () => { - const action = new RequestCacheFindAllAction(keys[0], services[0], resourceID, paginationOptions, sortOptions); - - it("should perform the action without affecting the previous state", () => { - //testState has already been frozen above - requestCacheReducer(testState, action); - }); - - it("should add the request to the cache", () => { - const state = Object.create(null); - const newState = requestCacheReducer(state, action); - expect(newState[keys[0]].key).toBe(keys[0]); - expect(newState[keys[0]].service).toEqual(services[0]); - expect(newState[keys[0]].scopeID).toBe(resourceID); - expect(newState[keys[0]].paginationOptions).toEqual(paginationOptions); - expect(newState[keys[0]].sortOptions).toEqual(sortOptions); - }); - - it("should set isLoading to true", () => { - const state = Object.create(null); - const newState = requestCacheReducer(state, action); - expect(newState[keys[0]].isLoading).toBe(true); - }); - - it("should remove any previous error message or resourceUUIDs for the request", () => { - const newState = requestCacheReducer(errorState, action); - expect(newState[keys[0]].resourceUUIDs.length).toBe(0); - expect(newState[keys[0]].errorMessage).toBeUndefined(); - }); - }); - - describe("SUCCESS", () => { - const successUUIDs = [uuids[0], uuids[2]]; - const successTimeAdded = new Date().getTime(); - const successMsToLive = 5; - const action = new RequestCacheSuccessAction(keys[0], successUUIDs, successTimeAdded, successMsToLive); - - it("should perform the action without affecting the previous state", () => { - //testState has already been frozen above - requestCacheReducer(testState, action); - }); - - it("should add the response to the cached request", () => { - const newState = requestCacheReducer(testState, action); - expect(newState[keys[0]].resourceUUIDs).toBe(successUUIDs); - expect(newState[keys[0]].timeAdded).toBe(successTimeAdded); - expect(newState[keys[0]].msToLive).toBe(successMsToLive); - }); - - it("should set isLoading to false", () => { - const newState = requestCacheReducer(testState, action); - expect(newState[keys[0]].isLoading).toBe(false); - }); - - it("should remove any previous error message for the request", () => { - const newState = requestCacheReducer(errorState, action); - expect(newState[keys[0]].errorMessage).toBeUndefined(); - }); - }); - - describe("ERROR", () => { - const errorMsg = 'errorMsg'; - const action = new RequestCacheErrorAction(keys[0], errorMsg); - - it("should perform the action without affecting the previous state", () => { - //testState has already been frozen above - requestCacheReducer(testState, action); - }); - - it("should set an error message for the request", () => { - const newState = requestCacheReducer(errorState, action); - expect(newState[keys[0]].errorMessage).toBe(errorMsg); - }); - - it("should set isLoading to false", () => { - const newState = requestCacheReducer(testState, action); - expect(newState[keys[0]].isLoading).toBe(false); - }); - }); - - describe("REMOVE", () => { - it("should perform the action without affecting the previous state", () => { - const action = new RequestCacheRemoveAction(keys[0]); - //testState has already been frozen above - requestCacheReducer(testState, action); - }); - - it("should remove the specified request from the cache", () => { - const action = new RequestCacheRemoveAction(keys[0]); - const newState = requestCacheReducer(testState, action); - expect(testState[keys[0]]).not.toBeUndefined(); - expect(newState[keys[0]]).toBeUndefined(); - }); - - it("shouldn't do anything when the specified key isn't cached", () => { - const wrongKey = "this isn't cached"; - const action = new RequestCacheRemoveAction(wrongKey); - const newState = requestCacheReducer(testState, action); - expect(testState[wrongKey]).toBeUndefined(); - expect(newState).toEqual(testState); - }); - }); - - describe("RESET_TIMESTAMPS", () => { - const newTimeStamp = new Date().getTime(); - const action = new ResetRequestCacheTimestampsAction(newTimeStamp); - - it("should perform the action without affecting the previous state", () => { - //testState has already been frozen above - requestCacheReducer(testState, action); - }); - - it("should set the timestamp of all requests in the cache", () => { - const newState = requestCacheReducer(testState, action); - Object.keys(newState).forEach((key) => { - expect(newState[key].timeAdded).toEqual(newTimeStamp); - }); - }); - - }); - - -}); diff --git a/src/app/core/cache/request-cache.reducer.ts b/src/app/core/cache/request-cache.reducer.ts deleted file mode 100644 index 0aa2e8c920..0000000000 --- a/src/app/core/cache/request-cache.reducer.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { PaginationOptions } from "../shared/pagination-options.model"; -import { SortOptions } from "../shared/sort-options.model"; -import { - RequestCacheAction, RequestCacheActionTypes, RequestCacheFindAllAction, - RequestCacheSuccessAction, RequestCacheErrorAction, RequestCacheFindByIDAction, - RequestCacheRemoveAction, ResetRequestCacheTimestampsAction -} from "./request-cache.actions"; -import { OpaqueToken } from "@angular/core"; -import { CacheEntry } from "./cache-entry"; -import { hasValue } from "../../shared/empty.util"; - -/** - * An entry in the RequestCache - */ -export class RequestCacheEntry implements CacheEntry { - service: OpaqueToken; - key: string; - scopeID: string; - resourceID: string; - resourceUUIDs: Array; - resourceType: String; - isLoading: boolean; - errorMessage: string; - paginationOptions: PaginationOptions; - sortOptions: SortOptions; - timeAdded: number; - msToLive: number; -} - -/** - * The RequestCache State - */ -export interface RequestCacheState { - [key: string]: RequestCacheEntry -} - -// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) -const initialState = Object.create(null); - -/** - * The RequestCache Reducer - * - * @param state - * the current state - * @param action - * the action to perform on the state - * @return RequestCacheState - * the new state - */ -export const requestCacheReducer = (state = initialState, action: RequestCacheAction): RequestCacheState => { - switch (action.type) { - - case RequestCacheActionTypes.FIND_ALL: { - return findAllRequest(state, action); - } - - case RequestCacheActionTypes.FIND_BY_ID: { - return findByIDRequest(state, action); - } - - case RequestCacheActionTypes.SUCCESS: { - return success(state, action); - } - - case RequestCacheActionTypes.ERROR: { - return error(state, action); - } - - case RequestCacheActionTypes.REMOVE: { - return removeFromCache(state, action); - } - - case RequestCacheActionTypes.RESET_TIMESTAMPS: { - return resetRequestCacheTimestamps(state, action) - } - - default: { - return state; - } - } -}; - -/** - * Add a FindAll request to the cache - * - * @param state - * the current state - * @param action - * a RequestCacheFindAllAction - * @return RequestCacheState - * the new state, with the request added, or overwritten - */ -function findAllRequest(state: RequestCacheState, action: RequestCacheFindAllAction): RequestCacheState { - return Object.assign({}, state, { - [action.payload.key]: { - key: action.payload.key, - service: action.payload.service, - scopeID: action.payload.scopeID, - resourceUUIDs: [], - isLoading: true, - errorMessage: undefined, - paginationOptions: action.payload.paginationOptions, - sortOptions: action.payload.sortOptions - } - }); -} - -/** - * Add a FindByID request to the cache - * - * @param state - * the current state - * @param action - * a RequestCacheFindByIDAction - * @return RequestCacheState - * the new state, with the request added, or overwritten - */ -function findByIDRequest(state: RequestCacheState, action: RequestCacheFindByIDAction): RequestCacheState { - return Object.assign({}, state, { - [action.payload.key]: { - key: action.payload.key, - service: action.payload.service, - resourceID: action.payload.resourceID, - resourceUUIDs: [], - isLoading: true, - errorMessage: undefined, - } - }); -} - -/** - * Update a cached request with a successful response - * - * @param state - * the current state - * @param action - * a RequestCacheSuccessAction - * @return RequestCacheState - * the new state, with the response added to the request - */ -function success(state: RequestCacheState, action: RequestCacheSuccessAction): RequestCacheState { - return Object.assign({}, state, { - [action.payload.key]: Object.assign({}, state[action.payload.key], { - isLoading: false, - resourceUUIDs: action.payload.resourceUUIDs, - errorMessage: undefined, - timeAdded: action.payload.timeAdded, - msToLive: action.payload.msToLive - }) - }); -} - -/** - * Update a cached request with an error - * - * @param state - * the current state - * @param action - * a RequestCacheSuccessAction - * @return RequestCacheState - * the new state, with the error added to the request - */ -function error(state: RequestCacheState, action: RequestCacheErrorAction): RequestCacheState { - return Object.assign({}, state, { - [action.payload.key]: Object.assign({}, state[action.payload.key], { - isLoading: false, - errorMessage: action.payload.errorMessage - }) - }); -} - -/** - * Remove a request from the cache - * - * @param state - * the current state - * @param action - * an RequestCacheRemoveAction - * @return RequestCacheState - * the new state, with the request removed if it existed. - */ -function removeFromCache(state: RequestCacheState, action: RequestCacheRemoveAction): RequestCacheState { - if (hasValue(state[action.payload])) { - let newCache = Object.assign({}, state); - delete newCache[action.payload]; - - return newCache; - } - else { - return state; - } -} - -/** - * Set the timeAdded timestamp of every cached request to the specified value - * - * @param state - * the current state - * @param action - * a ResetRequestCacheTimestampsAction - * @return RequestCacheState - * the new state, with all timeAdded timestamps set to the specified value - */ -function resetRequestCacheTimestamps(state: RequestCacheState, action: ResetRequestCacheTimestampsAction): RequestCacheState { - let newState = Object.create(null); - Object.keys(state).forEach(key => { - newState[key] = Object.assign({}, state[key], { - timeAdded: action.payload - }); - }); - return newState; -} diff --git a/src/app/core/cache/request-cache.service.spec.ts b/src/app/core/cache/request-cache.service.spec.ts deleted file mode 100644 index eb4a07a742..0000000000 --- a/src/app/core/cache/request-cache.service.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { RequestCacheService } from "./request-cache.service"; -import { Store } from "@ngrx/store"; -import { RequestCacheState, RequestCacheEntry } from "./request-cache.reducer"; -import { OpaqueToken } from "@angular/core"; -import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "./request-cache.actions"; -import { Observable } from "rxjs"; - -describe("RequestCacheService", () => { - let service: RequestCacheService; - let store: Store; - - const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"]; - const serviceTokens = [new OpaqueToken('service1'), new OpaqueToken('service2')]; - const resourceID = "9978"; - const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 }; - const sortOptions = { "field": "id", "direction": 0 }; - const timestamp = new Date().getTime(); - const validCacheEntry = (key) => { - return { - key: key, - timeAdded: timestamp, - msToLive: 24 * 60 * 60 * 1000 // a day - } - }; - const invalidCacheEntry = (key) => { - return { - key: key, - timeAdded: 0, - msToLive: 0 - } - }; - - beforeEach(() => { - store = new Store(undefined, undefined, undefined); - spyOn(store, 'dispatch'); - service = new RequestCacheService(store); - spyOn(window, 'Date').and.returnValue({ getTime: () => timestamp }); - }); - - describe("findAll", () => { - beforeEach(() => { - spyOn(service, "get").and.callFake((key) => Observable.of({key: key})); - }); - describe("if the key isn't cached", () => { - beforeEach(() => { - spyOn(service, "has").and.returnValue(false); - }); - it("should dispatch a FIND_ALL action with the key, service, scopeID, paginationOptions and sortOptions", () => { - service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions); - expect(store.dispatch).toHaveBeenCalledWith(new RequestCacheFindAllAction(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions)) - }); - it("should return an observable of the newly cached request with the specified key", () => { - let result: RequestCacheEntry; - service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry); - expect(result.key).toEqual(keys[0]); - }); - }); - describe("if the key is already cached", () => { - beforeEach(() => { - spyOn(service, "has").and.returnValue(true); - }); - it("shouldn't dispatch anything", () => { - service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - it("should return an observable of the existing cached request with the specified key", () => { - let result: RequestCacheEntry; - service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry); - expect(result.key).toEqual(keys[0]); - }); - }); - }); - - describe("findById", () => { - beforeEach(() => { - spyOn(service, "get").and.callFake((key) => Observable.of({key: key})); - }); - describe("if the key isn't cached", () => { - beforeEach(() => { - spyOn(service, "has").and.returnValue(false); - }); - it("should dispatch a FIND_BY_ID action with the key, service, and resourceID", () => { - service.findById(keys[0], serviceTokens[0], resourceID); - expect(store.dispatch).toHaveBeenCalledWith(new RequestCacheFindByIDAction(keys[0], serviceTokens[0], resourceID)) - }); - it("should return an observable of the newly cached request with the specified key", () => { - let result: RequestCacheEntry; - service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry); - expect(result.key).toEqual(keys[0]); - }); - }); - describe("if the key is already cached", () => { - beforeEach(() => { - spyOn(service, "has").and.returnValue(true); - }); - it("shouldn't dispatch anything", () => { - service.findById(keys[0], serviceTokens[0], resourceID); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - it("should return an observable of the existing cached request with the specified key", () => { - let result: RequestCacheEntry; - service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry); - expect(result.key).toEqual(keys[0]); - }); - }); - }); - - describe("get", () => { - it("should return an observable of the cached request with the specified key", () => { - spyOn(store, "select").and.callFake((...args:Array) => { - return Observable.of(validCacheEntry(args[args.length - 1])); - }); - - let testObj: RequestCacheEntry; - service.get(keys[1]).take(1).subscribe(entry => testObj = entry); - expect(testObj.key).toEqual(keys[1]); - }); - - it("should not return a cached request that has exceeded its time to live", () => { - spyOn(store, "select").and.callFake((...args:Array) => { - return Observable.of(invalidCacheEntry(args[args.length - 1])); - }); - - let getObsHasFired = false; - const subscription = service.get(keys[1]).subscribe(entry => getObsHasFired = true); - expect(getObsHasFired).toBe(false); - subscription.unsubscribe(); - }); - }); - - describe("has", () => { - it("should return true if the request with the supplied key is cached and still valid", () => { - spyOn(store, 'select').and.returnValue(Observable.of(validCacheEntry(keys[1]))); - expect(service.has(keys[1])).toBe(true); - }); - - it("should return false if the request with the supplied key isn't cached", () => { - spyOn(store, 'select').and.returnValue(Observable.of(undefined)); - expect(service.has(keys[1])).toBe(false); - }); - - it("should return false if the request with the supplied key is cached but has exceeded its time to live", () => { - spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry(keys[1]))); - expect(service.has(keys[1])).toBe(false); - }); - }); -}); diff --git a/src/app/core/cache/request-cache.service.ts b/src/app/core/cache/request-cache.service.ts deleted file mode 100644 index efa7b0d426..0000000000 --- a/src/app/core/cache/request-cache.service.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Injectable, OpaqueToken } from "@angular/core"; -import { Store } from "@ngrx/store"; -import { RequestCacheState, RequestCacheEntry } from "./request-cache.reducer"; -import { Observable } from "rxjs"; -import { hasNoValue } from "../../shared/empty.util"; -import { - RequestCacheRemoveAction, RequestCacheFindAllAction, - RequestCacheFindByIDAction -} from "./request-cache.actions"; -import { SortOptions } from "../shared/sort-options.model"; -import { PaginationOptions } from "../shared/pagination-options.model"; - -/** - * A service to interact with the request cache - */ -@Injectable() -export class RequestCacheService { - constructor( - private store: Store - ) {} - - /** - * Start a new findAll request - * - * This will send a new findAll request to the backend, - * and store the request parameters and the fact that - * the request is pending - * - * @param key - * the key should be a unique identifier for the request and its parameters - * @param service - * the service that initiated the request - * @param scopeID - * the id of an optional scope object - * @param paginationOptions - * the pagination options (optional) - * @param sortOptions - * the sort options (optional) - * @return Observable - * an observable of the RequestCacheEntry for this request - */ - findAll( - key: string, - service: OpaqueToken, - scopeID?: string, - paginationOptions?: PaginationOptions, - sortOptions?: SortOptions - ): Observable { - if (!this.has(key)) { - this.store.dispatch(new RequestCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions)); - } - return this.get(key); - } - - /** - * Start a new findById request - * - * This will send a new findById request to the backend, - * and store the request parameters and the fact that - * the request is pending - * - * @param key - * the key should be a unique identifier for the request and its parameters - * @param service - * the service that initiated the request - * @param resourceID - * the ID of the resource to find - * @return Observable - * an observable of the RequestCacheEntry for this request - */ - findById( - key: string, - service: OpaqueToken, - resourceID: string - ): Observable { - if (!this.has(key)) { - this.store.dispatch(new RequestCacheFindByIDAction(key, service, resourceID)); - } - return this.get(key); - } - - /** - * Get an observable of the request with the specified key - * - * @param key - * the key of the request to get - * @return Observable - * an observable of the RequestCacheEntry with the specified key - */ - get(key: string): Observable { - return this.store.select('core', 'cache', 'request', key) - .filter(entry => this.isValid(entry)) - .distinctUntilChanged() - } - - /** - * Check whether the request with the specified key is cached - * - * @param key - * the key of the request to check - * @return boolean - * true if the request with the specified key is cached, - * false otherwise - */ - has(key: string): boolean { - let result: boolean; - - this.store.select('core', 'cache', 'request', key) - .take(1) - .subscribe(entry => result = this.isValid(entry)); - - return result; - } - - /** - * Check whether a RequestCacheEntry should still be cached - * - * @param entry - * the entry to check - * @return boolean - * false if the entry is null, undefined, or its time to - * live has been exceeded, true otherwise - */ - private isValid(entry: RequestCacheEntry): boolean { - if (hasNoValue(entry)) { - return false; - } - else { - const timeOutdated = entry.timeAdded + entry.msToLive; - const isOutDated = new Date().getTime() > timeOutdated; - if (isOutDated) { - this.store.dispatch(new RequestCacheRemoveAction(entry.key)); - } - return !isOutDated; - } - } - -} diff --git a/src/app/core/cache/response-cache.actions.ts b/src/app/core/cache/response-cache.actions.ts new file mode 100644 index 0000000000..4a55b3fe0c --- /dev/null +++ b/src/app/core/cache/response-cache.actions.ts @@ -0,0 +1,69 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../shared/ngrx/type"; +import { Response } from "./response-cache.models"; + +/** + * The list of ResponseCacheAction type definitions + */ +export const ResponseCacheActionTypes = { + ADD: type('dspace/core/cache/request/ADD'), + REMOVE: type('dspace/core/cache/request/REMOVE'), + RESET_TIMESTAMPS: type('dspace/core/cache/request/RESET_TIMESTAMPS') +}; + +export class ResponseCacheAddAction implements Action { + type = ResponseCacheActionTypes.ADD; + payload: { + key: string, + response: Response + timeAdded: number; + msToLive: number; + }; + + constructor(key: string, response: Response, timeAdded: number, msToLive: number) { + this.payload = { key, response, timeAdded, msToLive }; + } +} + +/** + * An ngrx action to remove a request from the cache + */ +export class ResponseCacheRemoveAction implements Action { + type = ResponseCacheActionTypes.REMOVE; + payload: string; + + /** + * Create a new ResponseCacheRemoveAction + * @param key + * The key of the request to remove + */ + constructor(key: string) { + this.payload = key; + } +} + +/** + * An ngrx action to reset the timeAdded property of all cached objects + */ +export class ResetResponseCacheTimestampsAction implements Action { + type = ResponseCacheActionTypes.RESET_TIMESTAMPS; + payload: number; + + /** + * Create a new ResetObjectCacheTimestampsAction + * + * @param newTimestamp + * the new timeAdded all objects should get + */ + constructor(newTimestamp: number) { + this.payload = newTimestamp; + } +} + +/** + * A type to encompass all ResponseCacheActions + */ +export type ResponseCacheAction + = ResponseCacheAddAction + | ResponseCacheRemoveAction + | ResetResponseCacheTimestampsAction; diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts new file mode 100644 index 0000000000..741acf99a6 --- /dev/null +++ b/src/app/core/cache/response-cache.models.ts @@ -0,0 +1,16 @@ +export class Response { + constructor(public isSuccessful: boolean) {} +} + +export class SuccessResponse extends Response { + constructor(public resourceUUIDs: Array) { + super(true); + } +} + +export class ErrorResponse extends Response { + constructor(public errorMessage: string) { + super(false); + } +} + diff --git a/src/app/core/cache/response-cache.reducer.spec.ts b/src/app/core/cache/response-cache.reducer.spec.ts new file mode 100644 index 0000000000..b084842f7d --- /dev/null +++ b/src/app/core/cache/response-cache.reducer.spec.ts @@ -0,0 +1,225 @@ +import { responseCacheReducer, ResponseCacheState } from "./response-cache.reducer"; +import { + ResponseCacheRemoveAction, + ResetResponseCacheTimestampsAction +} from "./response-cache.actions"; +import deepFreeze = require("deep-freeze"); + +class NullAction extends ResponseCacheRemoveAction { + type = null; + payload = null; + + constructor() { + super(null); + } +} + +// describe("responseCacheReducer", () => { +// const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"]; +// const services = [new OpaqueToken('service1'), new OpaqueToken('service2')]; +// const msToLive = 900000; +// const uuids = [ +// "9e32a2e2-6b91-4236-a361-995ccdc14c60", +// "598ce822-c357-46f3-ab70-63724d02d6ad", +// "be8325f7-243b-49f4-8a4b-df2b793ff3b5" +// ]; +// const resourceID = "9978"; +// const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 }; +// const sortOptions = { "field": "id", "direction": 0 }; +// const testState = { +// [keys[0]]: { +// "key": keys[0], +// "service": services[0], +// "resourceUUIDs": [uuids[0], uuids[1]], +// "isLoading": false, +// "paginationOptions": paginationOptions, +// "sortOptions": sortOptions, +// "timeAdded": new Date().getTime(), +// "msToLive": msToLive +// }, +// [keys[1]]: { +// "key": keys[1], +// "service": services[1], +// "resourceID": resourceID, +// "resourceUUIDs": [uuids[2]], +// "isLoading": false, +// "timeAdded": new Date().getTime(), +// "msToLive": msToLive +// } +// }; +// deepFreeze(testState); +// const errorState: {} = { +// [keys[0]]: { +// errorMessage: 'error', +// resourceUUIDs: uuids +// } +// }; +// deepFreeze(errorState); +// +// +// it("should return the current state when no valid actions have been made", () => { +// const action = new NullAction(); +// const newState = responseCacheReducer(testState, action); +// +// expect(newState).toEqual(testState); +// }); +// +// it("should start with an empty cache", () => { +// const action = new NullAction(); +// const initialState = responseCacheReducer(undefined, action); +// +// expect(initialState).toEqual(Object.create(null)); +// }); +// +// describe("FIND_BY_ID", () => { +// const action = new ResponseCacheFindByIDAction(keys[0], services[0], resourceID); +// +// it("should perform the action without affecting the previous state", () => { +// //testState has already been frozen above +// responseCacheReducer(testState, action); +// }); +// +// it("should add the request to the cache", () => { +// const state = Object.create(null); +// const newState = responseCacheReducer(state, action); +// expect(newState[keys[0]].key).toBe(keys[0]); +// expect(newState[keys[0]].service).toEqual(services[0]); +// expect(newState[keys[0]].resourceID).toBe(resourceID); +// }); +// +// it("should set responsePending to true", () => { +// const state = Object.create(null); +// const newState = responseCacheReducer(state, action); +// expect(newState[keys[0]].responsePending).toBe(true); +// }); +// +// it("should remove any previous error message or resourceUUID for the request", () => { +// const newState = responseCacheReducer(errorState, action); +// expect(newState[keys[0]].resourceUUIDs.length).toBe(0); +// expect(newState[keys[0]].errorMessage).toBeUndefined(); +// }); +// }); +// +// describe("FIND_ALL", () => { +// const action = new ResponseCacheFindAllAction(keys[0], services[0], resourceID, paginationOptions, sortOptions); +// +// it("should perform the action without affecting the previous state", () => { +// //testState has already been frozen above +// responseCacheReducer(testState, action); +// }); +// +// it("should add the request to the cache", () => { +// const state = Object.create(null); +// const newState = responseCacheReducer(state, action); +// expect(newState[keys[0]].key).toBe(keys[0]); +// expect(newState[keys[0]].service).toEqual(services[0]); +// expect(newState[keys[0]].scopeID).toBe(resourceID); +// expect(newState[keys[0]].paginationOptions).toEqual(paginationOptions); +// expect(newState[keys[0]].sortOptions).toEqual(sortOptions); +// }); +// +// it("should set responsePending to true", () => { +// const state = Object.create(null); +// const newState = responseCacheReducer(state, action); +// expect(newState[keys[0]].responsePending).toBe(true); +// }); +// +// it("should remove any previous error message or resourceUUIDs for the request", () => { +// const newState = responseCacheReducer(errorState, action); +// expect(newState[keys[0]].resourceUUIDs.length).toBe(0); +// expect(newState[keys[0]].errorMessage).toBeUndefined(); +// }); +// }); +// +// describe("SUCCESS", () => { +// const successUUIDs = [uuids[0], uuids[2]]; +// const successTimeAdded = new Date().getTime(); +// const successMsToLive = 5; +// const action = new ResponseCacheSuccessAction(keys[0], successUUIDs, successTimeAdded, successMsToLive); +// +// it("should perform the action without affecting the previous state", () => { +// //testState has already been frozen above +// responseCacheReducer(testState, action); +// }); +// +// it("should add the response to the cached request", () => { +// const newState = responseCacheReducer(testState, action); +// expect(newState[keys[0]].resourceUUIDs).toBe(successUUIDs); +// expect(newState[keys[0]].timeAdded).toBe(successTimeAdded); +// expect(newState[keys[0]].msToLive).toBe(successMsToLive); +// }); +// +// it("should set responsePending to false", () => { +// const newState = responseCacheReducer(testState, action); +// expect(newState[keys[0]].responsePending).toBe(false); +// }); +// +// it("should remove any previous error message for the request", () => { +// const newState = responseCacheReducer(errorState, action); +// expect(newState[keys[0]].errorMessage).toBeUndefined(); +// }); +// }); +// +// describe("ERROR", () => { +// const errorMsg = 'errorMsg'; +// const action = new ResponseCacheErrorAction(keys[0], errorMsg); +// +// it("should perform the action without affecting the previous state", () => { +// //testState has already been frozen above +// responseCacheReducer(testState, action); +// }); +// +// it("should set an error message for the request", () => { +// const newState = responseCacheReducer(errorState, action); +// expect(newState[keys[0]].errorMessage).toBe(errorMsg); +// }); +// +// it("should set responsePending to false", () => { +// const newState = responseCacheReducer(testState, action); +// expect(newState[keys[0]].responsePending).toBe(false); +// }); +// }); +// +// describe("REMOVE", () => { +// it("should perform the action without affecting the previous state", () => { +// const action = new ResponseCacheRemoveAction(keys[0]); +// //testState has already been frozen above +// responseCacheReducer(testState, action); +// }); +// +// it("should remove the specified request from the cache", () => { +// const action = new ResponseCacheRemoveAction(keys[0]); +// const newState = responseCacheReducer(testState, action); +// expect(testState[keys[0]]).not.toBeUndefined(); +// expect(newState[keys[0]]).toBeUndefined(); +// }); +// +// it("shouldn't do anything when the specified key isn't cached", () => { +// const wrongKey = "this isn't cached"; +// const action = new ResponseCacheRemoveAction(wrongKey); +// const newState = responseCacheReducer(testState, action); +// expect(testState[wrongKey]).toBeUndefined(); +// expect(newState).toEqual(testState); +// }); +// }); +// +// describe("RESET_TIMESTAMPS", () => { +// const newTimeStamp = new Date().getTime(); +// const action = new ResetResponseCacheTimestampsAction(newTimeStamp); +// +// it("should perform the action without affecting the previous state", () => { +// //testState has already been frozen above +// responseCacheReducer(testState, action); +// }); +// +// it("should set the timestamp of all requests in the cache", () => { +// const newState = responseCacheReducer(testState, action); +// Object.keys(newState).forEach((key) => { +// expect(newState[key].timeAdded).toEqual(newTimeStamp); +// }); +// }); +// +// }); +// +// +// }); diff --git a/src/app/core/cache/response-cache.reducer.ts b/src/app/core/cache/response-cache.reducer.ts new file mode 100644 index 0000000000..7e0fa6f5eb --- /dev/null +++ b/src/app/core/cache/response-cache.reducer.ts @@ -0,0 +1,112 @@ +import { + ResponseCacheAction, ResponseCacheActionTypes, + ResponseCacheRemoveAction, ResetResponseCacheTimestampsAction, + ResponseCacheAddAction +} from "./response-cache.actions"; +import { CacheEntry } from "./cache-entry"; +import { hasValue } from "../../shared/empty.util"; +import { Response } from "./response-cache.models"; + +/** + * An entry in the ResponseCache + */ +export class ResponseCacheEntry implements CacheEntry { + key: string; + response: Response; + timeAdded: number; + msToLive: number; +} + +/** + * The ResponseCache State + */ +export interface ResponseCacheState { + [key: string]: ResponseCacheEntry +} + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState = Object.create(null); + +/** + * The ResponseCache Reducer + * + * @param state + * the current state + * @param action + * the action to perform on the state + * @return ResponseCacheState + * the new state + */ +export const responseCacheReducer = (state = initialState, action: ResponseCacheAction): ResponseCacheState => { + switch (action.type) { + + case ResponseCacheActionTypes.ADD: { + return addToCache(state, action); + } + + case ResponseCacheActionTypes.REMOVE: { + return removeFromCache(state, action); + } + + case ResponseCacheActionTypes.RESET_TIMESTAMPS: { + return resetResponseCacheTimestamps(state, action) + } + + default: { + return state; + } + } +}; + +function addToCache(state: ResponseCacheState, action: ResponseCacheAddAction): ResponseCacheState { + return Object.assign({}, state, { + [action.payload.key]: { + key: action.payload.key, + response: action.payload.response, + timeAdded: action.payload.timeAdded, + msToLive: action.payload.msToLive + } + }); +} + +/** + * Remove a request from the cache + * + * @param state + * the current state + * @param action + * an ResponseCacheRemoveAction + * @return ResponseCacheState + * the new state, with the request removed if it existed. + */ +function removeFromCache(state: ResponseCacheState, action: ResponseCacheRemoveAction): ResponseCacheState { + if (hasValue(state[action.payload])) { + let newCache = Object.assign({}, state); + delete newCache[action.payload]; + + return newCache; + } + else { + return state; + } +} + +/** + * Set the timeAdded timestamp of every cached request to the specified value + * + * @param state + * the current state + * @param action + * a ResetResponseCacheTimestampsAction + * @return ResponseCacheState + * the new state, with all timeAdded timestamps set to the specified value + */ +function resetResponseCacheTimestamps(state: ResponseCacheState, action: ResetResponseCacheTimestampsAction): ResponseCacheState { + let newState = Object.create(null); + Object.keys(state).forEach(key => { + newState[key] = Object.assign({}, state[key], { + timeAdded: action.payload + }); + }); + return newState; +} diff --git a/src/app/core/cache/response-cache.service.spec.ts b/src/app/core/cache/response-cache.service.spec.ts new file mode 100644 index 0000000000..ec9da670a1 --- /dev/null +++ b/src/app/core/cache/response-cache.service.spec.ts @@ -0,0 +1,146 @@ +import { ResponseCacheService } from "./response-cache.service"; +import { Store } from "@ngrx/store"; +import { ResponseCacheState, ResponseCacheEntry } from "./response-cache.reducer"; +import { OpaqueToken } from "@angular/core"; +import { Observable } from "rxjs"; + +// describe("ResponseCacheService", () => { +// let service: ResponseCacheService; +// let store: Store; +// +// const keys = ["125c17f89046283c5f0640722aac9feb", "a06c3006a41caec5d635af099b0c780c"]; +// const serviceTokens = [new OpaqueToken('service1'), new OpaqueToken('service2')]; +// const resourceID = "9978"; +// const paginationOptions = { "resultsPerPage": 10, "currentPage": 1 }; +// const sortOptions = { "field": "id", "direction": 0 }; +// const timestamp = new Date().getTime(); +// const validCacheEntry = (key) => { +// return { +// key: key, +// timeAdded: timestamp, +// msToLive: 24 * 60 * 60 * 1000 // a day +// } +// }; +// const invalidCacheEntry = (key) => { +// return { +// key: key, +// timeAdded: 0, +// msToLive: 0 +// } +// }; +// +// beforeEach(() => { +// store = new Store(undefined, undefined, undefined); +// spyOn(store, 'dispatch'); +// service = new ResponseCacheService(store); +// spyOn(window, 'Date').and.returnValue({ getTime: () => timestamp }); +// }); +// +// describe("findAll", () => { +// beforeEach(() => { +// spyOn(service, "get").and.callFake((key) => Observable.of({key: key})); +// }); +// describe("if the key isn't cached", () => { +// beforeEach(() => { +// spyOn(service, "has").and.returnValue(false); +// }); +// it("should dispatch a FIND_ALL action with the key, service, scopeID, paginationOptions and sortOptions", () => { +// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions); +// expect(store.dispatch).toHaveBeenCalledWith(new ResponseCacheFindAllAction(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions)) +// }); +// it("should return an observable of the newly cached request with the specified key", () => { +// let result: ResponseCacheEntry; +// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry); +// expect(result.key).toEqual(keys[0]); +// }); +// }); +// describe("if the key is already cached", () => { +// beforeEach(() => { +// spyOn(service, "has").and.returnValue(true); +// }); +// it("shouldn't dispatch anything", () => { +// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions); +// expect(store.dispatch).not.toHaveBeenCalled(); +// }); +// it("should return an observable of the existing cached request with the specified key", () => { +// let result: ResponseCacheEntry; +// service.findAll(keys[0], serviceTokens[0], resourceID, paginationOptions, sortOptions).take(1).subscribe(entry => result = entry); +// expect(result.key).toEqual(keys[0]); +// }); +// }); +// }); +// +// describe("findById", () => { +// beforeEach(() => { +// spyOn(service, "get").and.callFake((key) => Observable.of({key: key})); +// }); +// describe("if the key isn't cached", () => { +// beforeEach(() => { +// spyOn(service, "has").and.returnValue(false); +// }); +// it("should dispatch a FIND_BY_ID action with the key, service, and resourceID", () => { +// service.findById(keys[0], serviceTokens[0], resourceID); +// expect(store.dispatch).toHaveBeenCalledWith(new ResponseCacheFindByIDAction(keys[0], serviceTokens[0], resourceID)) +// }); +// it("should return an observable of the newly cached request with the specified key", () => { +// let result: ResponseCacheEntry; +// service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry); +// expect(result.key).toEqual(keys[0]); +// }); +// }); +// describe("if the key is already cached", () => { +// beforeEach(() => { +// spyOn(service, "has").and.returnValue(true); +// }); +// it("shouldn't dispatch anything", () => { +// service.findById(keys[0], serviceTokens[0], resourceID); +// expect(store.dispatch).not.toHaveBeenCalled(); +// }); +// it("should return an observable of the existing cached request with the specified key", () => { +// let result: ResponseCacheEntry; +// service.findById(keys[0], serviceTokens[0], resourceID).take(1).subscribe(entry => result = entry); +// expect(result.key).toEqual(keys[0]); +// }); +// }); +// }); +// +// describe("get", () => { +// it("should return an observable of the cached request with the specified key", () => { +// spyOn(store, "select").and.callFake((...args:Array) => { +// return Observable.of(validCacheEntry(args[args.length - 1])); +// }); +// +// let testObj: ResponseCacheEntry; +// service.get(keys[1]).take(1).subscribe(entry => testObj = entry); +// expect(testObj.key).toEqual(keys[1]); +// }); +// +// it("should not return a cached request that has exceeded its time to live", () => { +// spyOn(store, "select").and.callFake((...args:Array) => { +// return Observable.of(invalidCacheEntry(args[args.length - 1])); +// }); +// +// let getObsHasFired = false; +// const subscription = service.get(keys[1]).subscribe(entry => getObsHasFired = true); +// expect(getObsHasFired).toBe(false); +// subscription.unsubscribe(); +// }); +// }); +// +// describe("has", () => { +// it("should return true if the request with the supplied key is cached and still valid", () => { +// spyOn(store, 'select').and.returnValue(Observable.of(validCacheEntry(keys[1]))); +// expect(service.has(keys[1])).toBe(true); +// }); +// +// it("should return false if the request with the supplied key isn't cached", () => { +// spyOn(store, 'select').and.returnValue(Observable.of(undefined)); +// expect(service.has(keys[1])).toBe(false); +// }); +// +// it("should return false if the request with the supplied key is cached but has exceeded its time to live", () => { +// spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry(keys[1]))); +// expect(service.has(keys[1])).toBe(false); +// }); +// }); +// }); diff --git a/src/app/core/cache/response-cache.service.ts b/src/app/core/cache/response-cache.service.ts new file mode 100644 index 0000000000..14f206638a --- /dev/null +++ b/src/app/core/cache/response-cache.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { + ResponseCacheState, ResponseCacheEntry +} from "./response-cache.reducer"; +import { Observable } from "rxjs"; +import { hasNoValue } from "../../shared/empty.util"; +import { + ResponseCacheRemoveAction, + ResponseCacheAddAction +} from "./response-cache.actions"; +import { Response } from "./response-cache.models"; + +/** + * A service to interact with the response cache + */ +@Injectable() +export class ResponseCacheService { + constructor( + private store: Store + ) {} + + add(key: string, response: Response, msToLive: number): Observable { + if (!this.has(key)) { + // this.store.dispatch(new ResponseCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions)); + this.store.dispatch(new ResponseCacheAddAction(key, response, new Date().getTime(), msToLive)); + } + return this.get(key); + } + + /** + * Get an observable of the response with the specified key + * + * @param key + * the key of the response to get + * @return Observable + * an observable of the ResponseCacheEntry with the specified key + */ + get(key: string): Observable { + return this.store.select('core', 'cache', 'response', key) + .filter(entry => this.isValid(entry)) + .distinctUntilChanged() + } + + /** + * Check whether the response with the specified key is cached + * + * @param key + * the key of the response to check + * @return boolean + * true if the response with the specified key is cached, + * false otherwise + */ + has(key: string): boolean { + let result: boolean; + + this.store.select('core', 'cache', 'response', key) + .take(1) + .subscribe(entry => result = this.isValid(entry)); + + return result; + } + + /** + * Check whether a ResponseCacheEntry should still be cached + * + * @param entry + * the entry to check + * @return boolean + * false if the entry is null, undefined, or its time to + * live has been exceeded, true otherwise + */ + private isValid(entry: ResponseCacheEntry): boolean { + if (hasNoValue(entry)) { + return false; + } + else { + const timeOutdated = entry.timeAdded + entry.msToLive; + const isOutDated = new Date().getTime() > timeOutdated; + if (isOutDated) { + this.store.dispatch(new ResponseCacheRemoveAction(entry.key)); + } + return !isOutDated; + } + } + +} diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index b2d6c95ad5..ef9da245df 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,12 +1,11 @@ import { EffectsModule } from "@ngrx/effects"; -import { CollectionDataEffects } from "./data-services/collection-data.effects"; -import { ItemDataEffects } from "./data-services/item-data.effects"; -import { ObjectCacheEffects } from "./data-services/object-cache.effects"; -import { RequestCacheEffects } from "./data-services/request-cache.effects"; +import { ObjectCacheEffects } from "./data/object-cache.effects"; +import { RequestCacheEffects } from "./data/request-cache.effects"; +import { HrefIndexEffects } from "./index/href-index.effects"; +import { RequestEffects } from "./data/request.effects"; export const coreEffects = [ - EffectsModule.run(CollectionDataEffects), - EffectsModule.run(ItemDataEffects), - EffectsModule.run(RequestCacheEffects), + EffectsModule.run(RequestEffects), EffectsModule.run(ObjectCacheEffects), + EffectsModule.run(HrefIndexEffects), ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 5aa2b149a5..f1e24d15d8 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -5,9 +5,9 @@ import { isNotEmpty } from "../shared/empty.util"; import { FooterComponent } from "./footer/footer.component"; import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service"; import { ObjectCacheService } from "./cache/object-cache.service"; -import { RequestCacheService } from "./cache/request-cache.service"; -import { CollectionDataService } from "./data-services/collection-data.service"; -import { ItemDataService } from "./data-services/item-data.service"; +import { ResponseCacheService } from "./cache/response-cache.service"; +import { CollectionDataService } from "./data/collection-data.service"; +import { ItemDataService } from "./data/item-data.service"; const IMPORTS = [ CommonModule, @@ -27,7 +27,7 @@ const PROVIDERS = [ ItemDataService, DSpaceRESTv2Service, ObjectCacheService, - RequestCacheService + ResponseCacheService ]; @NgModule({ diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 71f25ee0b0..556866dbc4 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,12 +1,18 @@ import { combineReducers } from "@ngrx/store"; import { CacheState, cacheReducer } from "./cache/cache.reducers"; +import { IndexState, indexReducer } from "./index/index.reducers"; +import { DataState, dataReducer } from "./data/data.reducers"; export interface CoreState { - cache: CacheState + cache: CacheState, + index: IndexState, + data: DataState } export const reducers = { - cache: cacheReducer + cache: cacheReducer, + index: indexReducer, + data: dataReducer }; export function coreReducer(state: any, action: any) { diff --git a/src/app/core/data-services/collection-data.effects.ts b/src/app/core/data-services/collection-data.effects.ts deleted file mode 100644 index 9586940def..0000000000 --- a/src/app/core/data-services/collection-data.effects.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Inject, Injectable } from "@angular/core"; -import { DataEffects } from "./data.effects"; -import { Serializer } from "../serializer"; -import { Collection } from "../shared/collection.model"; -import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer"; -import { ObjectCacheService } from "../cache/object-cache.service"; -import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; -import { Actions, Effect } from "@ngrx/effects"; -import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions"; -import { CollectionDataService } from "./collection-data.service"; - -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; - -@Injectable() -export class CollectionDataEffects extends DataEffects { - constructor( - @Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig, - actions$: Actions, - restApi: DSpaceRESTv2Service, - cache: ObjectCacheService, - dataService: CollectionDataService - ) { - super(EnvConfig, actions$, restApi, cache, dataService); - } - - protected getFindAllEndpoint(action: RequestCacheFindAllAction): string { - return '/collections'; - } - - protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string { - return `/collections/${action.payload.resourceID}`; - } - - protected getSerializer(): Serializer { - return new DSpaceRESTv2Serializer(Collection); - } - - @Effect() findAll$ = this.findAll; - - @Effect() findById$ = this.findById; -} diff --git a/src/app/core/data-services/collection-data.service.ts b/src/app/core/data-services/collection-data.service.ts deleted file mode 100644 index cc850900db..0000000000 --- a/src/app/core/data-services/collection-data.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable, OpaqueToken } from "@angular/core"; -import { DataService } from "./data.service"; -import { Collection } from "../shared/collection.model"; -import { ObjectCacheService } from "../cache/object-cache.service"; -import { RequestCacheService } from "../cache/request-cache.service"; - -@Injectable() -export class CollectionDataService extends DataService { - serviceName = new OpaqueToken('CollectionDataService'); - - constructor( - protected objectCache: ObjectCacheService, - protected requestCache: RequestCacheService, - ) { - super(Collection); - } - -} diff --git a/src/app/core/data-services/data.effects.ts b/src/app/core/data-services/data.effects.ts deleted file mode 100644 index 107ad7eca3..0000000000 --- a/src/app/core/data-services/data.effects.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Inject } from "@angular/core"; -import { Actions } from "@ngrx/effects"; -import { Observable } from "rxjs"; -import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model"; -import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; -import { ObjectCacheService } from "../cache/object-cache.service"; -import { CacheableObject } from "../cache/object-cache.reducer"; -import { Serializer } from "../serializer"; -import { - RequestCacheActionTypes, RequestCacheFindAllAction, RequestCacheSuccessAction, - RequestCacheErrorAction, RequestCacheFindByIDAction -} from "../cache/request-cache.actions"; -import { DataService } from "./data.service"; -import { hasNoValue } from "../../shared/empty.util"; - -import { GlobalConfig } from '../../../config'; - -export abstract class DataEffects { - protected abstract getFindAllEndpoint(action: RequestCacheFindAllAction): string; - protected abstract getFindByIdEndpoint(action: RequestCacheFindByIDAction): string; - protected abstract getSerializer(): Serializer; - - constructor( - private EnvConfig: GlobalConfig, - private actions$: Actions, - private restApi: DSpaceRESTv2Service, - private objectCache: ObjectCacheService, - private dataService: DataService - ) { } - - // TODO, results of a findall aren't retrieved from cache yet - protected findAll = this.actions$ - .ofType(RequestCacheActionTypes.FIND_ALL) - .filter((action: RequestCacheFindAllAction) => action.payload.service === this.dataService.serviceName) - .flatMap((action: RequestCacheFindAllAction) => { - //TODO scope, pagination, sorting -> when we know how that works in rest - return this.restApi.get(this.getFindAllEndpoint(action)) - .map((data: DSpaceRESTV2Response) => this.getSerializer().deserializeArray(data)) - .do((ts: T[]) => { - ts.forEach((t) => { - if (hasNoValue(t) || hasNoValue(t.uuid)) { - throw new Error('The server returned an invalid object'); - } - this.objectCache.add(t, this.EnvConfig.cache.msToLive); - }); - }) - .map((ts: Array) => ts.map(t => t.uuid)) - .map((ids: Array) => new RequestCacheSuccessAction(action.payload.key, ids, new Date().getTime(), this.EnvConfig.cache.msToLive)) - .catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message))); - }); - - protected findById = this.actions$ - .ofType(RequestCacheActionTypes.FIND_BY_ID) - .filter((action: RequestCacheFindAllAction) => action.payload.service === this.dataService.serviceName) - .flatMap((action: RequestCacheFindByIDAction) => { - return this.restApi.get(this.getFindByIdEndpoint(action)) - .map((data: DSpaceRESTV2Response) => this.getSerializer().deserialize(data)) - .do((t: T) => { - if (hasNoValue(t) || hasNoValue(t.uuid)) { - throw new Error('The server returned an invalid object'); - } - this.objectCache.add(t, this.EnvConfig.cache.msToLive); - }) - .map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], new Date().getTime(), this.EnvConfig.cache.msToLive)) - .catch((error: Error) => Observable.of(new RequestCacheErrorAction(action.payload.key, error.message))); - }); - -} diff --git a/src/app/core/data-services/data.service.ts b/src/app/core/data-services/data.service.ts deleted file mode 100644 index ddbfa03eb4..0000000000 --- a/src/app/core/data-services/data.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { OpaqueToken } from "@angular/core"; -import { Observable } from "rxjs"; -import { ObjectCacheService } from "../cache/object-cache.service"; -import { RequestCacheService } from "../cache/request-cache.service"; -import { CacheableObject } from "../cache/object-cache.reducer"; -import { ParamHash } from "../shared/param-hash"; -import { isNotEmpty } from "../../shared/empty.util"; -import { GenericConstructor } from "../shared/generic-constructor"; -import { RemoteData } from "./remote-data"; - -export abstract class DataService { - abstract serviceName: OpaqueToken; - protected abstract objectCache: ObjectCacheService; - protected abstract requestCache: RequestCacheService; - - constructor(private modelType: GenericConstructor) { - - } - - findAll(scopeID?: string): RemoteData> { - const key = new ParamHash(this.serviceName, 'findAll', scopeID).toString(); - const requestCacheObs = this.requestCache.findAll(key, this.serviceName, scopeID); - return new RemoteData( - requestCacheObs.map(entry => entry.isLoading).distinctUntilChanged(), - requestCacheObs.map(entry => entry.errorMessage).distinctUntilChanged(), - requestCacheObs - .map(entry => entry.resourceUUIDs) - .flatMap((resourceUUIDs: Array) => { - // use those IDs to fetch the actual objects from the ObjectCache - return this.objectCache.getList(resourceUUIDs, this.modelType); - }).distinctUntilChanged() - ); - } - - findById(id: string): RemoteData { - const key = new ParamHash(this.serviceName, 'findById', id).toString(); - const requestCacheObs = this.requestCache.findById(key, this.serviceName, id); - return new RemoteData( - requestCacheObs.map(entry => entry.isLoading).distinctUntilChanged(), - requestCacheObs.map(entry => entry.errorMessage).distinctUntilChanged(), - requestCacheObs - .map(entry => entry.resourceUUIDs) - .flatMap((resourceUUIDs: Array) => { - if (isNotEmpty(resourceUUIDs)) { - return this.objectCache.get(resourceUUIDs[0], this.modelType); - } - else { - return Observable.of(undefined); - } - }).distinctUntilChanged() - ); - } - -} diff --git a/src/app/core/data-services/item-data.effects.ts b/src/app/core/data-services/item-data.effects.ts deleted file mode 100644 index 8c140c8398..0000000000 --- a/src/app/core/data-services/item-data.effects.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Inject, Injectable } from "@angular/core"; -import { DataEffects } from "./data.effects"; -import { Serializer } from "../serializer"; -import { Item } from "../shared/item.model"; -import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer"; -import { ObjectCacheService } from "../cache/object-cache.service"; -import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; -import { Actions, Effect } from "@ngrx/effects"; -import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions"; -import { ItemDataService } from "./item-data.service"; - -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; - -@Injectable() -export class ItemDataEffects extends DataEffects { - constructor( - @Inject(GLOBAL_CONFIG) EnvConfig: GlobalConfig, - actions$: Actions, - restApi: DSpaceRESTv2Service, - cache: ObjectCacheService, - dataService: ItemDataService - ) { - super(EnvConfig, actions$, restApi, cache, dataService); - } - - protected getFindAllEndpoint(action: RequestCacheFindAllAction): string { - return '/items'; - } - - protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string { - return `/items/${action.payload.resourceID}`; - } - - protected getSerializer(): Serializer { - return new DSpaceRESTv2Serializer(Item); - } - - @Effect() findAll$ = this.findAll; - - @Effect() findById$ = this.findById; -} diff --git a/src/app/core/data-services/item-data.service.ts b/src/app/core/data-services/item-data.service.ts deleted file mode 100644 index f3c8fd83af..0000000000 --- a/src/app/core/data-services/item-data.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable, OpaqueToken } from "@angular/core"; -import { DataService } from "./data.service"; -import { Item } from "../shared/item.model"; -import { ObjectCacheService } from "../cache/object-cache.service"; -import { RequestCacheService } from "../cache/request-cache.service"; - -@Injectable() -export class ItemDataService extends DataService { - serviceName = new OpaqueToken('ItemDataService'); - - constructor( - protected objectCache: ObjectCacheService, - protected requestCache: RequestCacheService, - ) { - super(Item); - } - -} diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts new file mode 100644 index 0000000000..5107ac1bf3 --- /dev/null +++ b/src/app/core/data/collection-data.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@angular/core"; +import { DataService } from "./data.service"; +import { Collection } from "../shared/collection.model"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { ResponseCacheService } from "../cache/response-cache.service"; +import { RemoteData } from "./remote-data"; +import { ItemDataService } from "./item-data.service"; +import { Store } from "@ngrx/store"; +import { RequestState } from "./request.reducer"; + +@Injectable() +export class CollectionDataService extends DataService { + protected endpoint = '/collections'; + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected store: Store, + protected ids: ItemDataService + ) { + super(Collection); + } + + // findAll(scopeID?: string): RemoteData> { + // let remoteData = super.findAll(scopeID); + // remoteData.payload = remoteData.payload.map(collections => { + // return collections.map(collection => { + // collection.items = collection.itemLinks.map(item => { + // return this.ids.findById(item.self); + // }); + // return collection + // }); + // }); + // return remoteData; + // } +} diff --git a/src/app/core/data/data.reducers.ts b/src/app/core/data/data.reducers.ts new file mode 100644 index 0000000000..af7d2697cc --- /dev/null +++ b/src/app/core/data/data.reducers.ts @@ -0,0 +1,14 @@ +import { combineReducers } from "@ngrx/store"; +import { RequestState, requestReducer } from "./request.reducer"; + +export interface DataState { + request: RequestState +} + +export const reducers = { + request: requestReducer +}; + +export function dataReducer(state: any, action: any) { + return combineReducers(reducers)(state, action); +} diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts new file mode 100644 index 0000000000..7789381eaa --- /dev/null +++ b/src/app/core/data/data.service.ts @@ -0,0 +1,93 @@ +import { Observable } from "rxjs"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { ResponseCacheService } from "../cache/response-cache.service"; +import { CacheableObject } from "../cache/object-cache.reducer"; +import { isNotEmpty, hasValue } from "../../shared/empty.util"; +import { GenericConstructor } from "../shared/generic-constructor"; +import { RemoteData } from "./remote-data"; +import { SuccessResponse, ErrorResponse } from "../cache/response-cache.models"; +import { FindAllRequest, FindByIDRequest } from "./request.models"; +import { RequestState, RequestEntry } from "./request.reducer"; +import { Store } from "@ngrx/store"; +import { RequestConfigureAction, RequestExecuteAction } from "./request.actions"; +import { ResponseCacheEntry } from "../cache/response-cache.reducer"; + +export abstract class DataService { + protected abstract objectCache: ObjectCacheService; + protected abstract responseCache: ResponseCacheService; + protected abstract store: Store; + protected abstract endpoint: string; + + constructor(private resourceType: GenericConstructor) { + + } + + protected getFindAllHref(scopeID?): string { + let result = this.endpoint; + if (hasValue(scopeID)) { + result += `?scope=${scopeID}` + } + return result; + } + + findAll(scopeID?: string): RemoteData> { + const href = this.getFindAllHref(scopeID); + const request = new FindAllRequest(href, this.resourceType, scopeID); + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(href)); + const requestObs = this.store.select('core', 'data', 'request', href); + const responseCacheObs = this.responseCache.get(href); + return new RemoteData( + requestObs.map((entry: RequestEntry) => entry.requestPending).distinctUntilChanged(), + requestObs.map((entry: RequestEntry) => entry.responsePending).distinctUntilChanged(), + responseCacheObs + .map((entry: ResponseCacheEntry) => entry.response.isSuccessful).distinctUntilChanged(), + responseCacheObs + .filter((entry: ResponseCacheEntry) => !entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => ( entry.response).errorMessage) + .distinctUntilChanged(), + responseCacheObs + .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => ( entry.response).resourceUUIDs) + .flatMap((resourceUUIDs: Array) => { + // use those IDs to fetch the actual objects from the ObjectCache + return this.objectCache.getList(resourceUUIDs, this.resourceType); + }).distinctUntilChanged() + ); + } + + protected getFindByIDHref(resourceID): string { + return `${this.endpoint}/${resourceID}`; + } + + findById(id: string): RemoteData { + const href = this.getFindByIDHref(id); + const request = new FindByIDRequest(href, this.resourceType, id); + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(href)); + const requestObs = this.store.select('core', 'data', 'request', href); + const responseCacheObs = this.responseCache.get(href); + return new RemoteData( + requestObs.map((entry: RequestEntry) => entry.requestPending).distinctUntilChanged(), + requestObs.map((entry: RequestEntry) => entry.responsePending).distinctUntilChanged(), + responseCacheObs + .map((entry: ResponseCacheEntry) => entry.response.isSuccessful).distinctUntilChanged(), + responseCacheObs + .filter((entry: ResponseCacheEntry) => !entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => ( entry.response).errorMessage) + .distinctUntilChanged(), + responseCacheObs + .filter((entry: ResponseCacheEntry) => entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => ( entry.response).resourceUUIDs) + .flatMap((resourceUUIDs: Array) => { + if (isNotEmpty(resourceUUIDs)) { + return this.objectCache.get(resourceUUIDs[0], this.resourceType); + } + else { + return Observable.of(undefined); + } + }).distinctUntilChanged() + ); + } + +} diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts new file mode 100644 index 0000000000..6c64e5a8d9 --- /dev/null +++ b/src/app/core/data/item-data.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@angular/core"; +import { DataService } from "./data.service"; +import { Item } from "../shared/item.model"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { ResponseCacheService } from "../cache/response-cache.service"; +import { Store } from "@ngrx/store"; +import { RequestState } from "./request.reducer"; + +@Injectable() +export class ItemDataService extends DataService { + protected endpoint = '/items'; + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected store: Store +) { + super(Item); + } + +} diff --git a/src/app/core/data-services/object-cache.effects.ts b/src/app/core/data/object-cache.effects.ts similarity index 81% rename from src/app/core/data-services/object-cache.effects.ts rename to src/app/core/data/object-cache.effects.ts index 26f13ea1b5..af5a0658a3 100644 --- a/src/app/core/data-services/object-cache.effects.ts +++ b/src/app/core/data/object-cache.effects.ts @@ -2,15 +2,12 @@ import { Injectable } from "@angular/core"; import { Actions, Effect } from "@ngrx/effects"; import { StoreActionTypes } from "../../store.actions"; import { ResetObjectCacheTimestampsAction } from "../cache/object-cache.actions"; -import { Store } from "@ngrx/store"; -import { ObjectCacheState } from "../cache/object-cache.reducer"; @Injectable() export class ObjectCacheEffects { constructor( - private actions$: Actions, - private store: Store + private actions$: Actions ) { } /** diff --git a/src/app/core/data-services/remote-data.ts b/src/app/core/data/remote-data.ts similarity index 74% rename from src/app/core/data-services/remote-data.ts rename to src/app/core/data/remote-data.ts index 1b9ff177ef..d1d2a7a3e2 100644 --- a/src/app/core/data-services/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -1,8 +1,6 @@ import { Observable } from "rxjs"; -import { hasValue } from "../../shared/empty.util"; export enum RemoteDataState { - //TODO RequestPending will never happen: implement it in the store & DataEffects. RequestPending, ResponsePending, Failed, @@ -10,12 +8,14 @@ export enum RemoteDataState { } /** - * A class to represent the state of + * A class to represent the state of a remote resource */ export class RemoteData { constructor( - private storeLoading: Observable, + private requestPending: Observable, + private responsePending: Observable, + private isSuccessFul: Observable, public errorMessage: Observable, public payload: Observable ) { @@ -23,13 +23,17 @@ export class RemoteData { get state(): Observable { return Observable.combineLatest( - this.storeLoading, - this.errorMessage.map(msg => hasValue(msg)), - (storeLoading, hasMsg) => { - if (storeLoading) { + this.requestPending, + this.responsePending, + this.isSuccessFul, + (requestPending, responsePending, isSuccessFul) => { + if (requestPending) { + return RemoteDataState.RequestPending + } + else if (responsePending) { return RemoteDataState.ResponsePending } - else if (hasMsg) { + else if (!isSuccessFul) { return RemoteDataState.Failed } else { diff --git a/src/app/core/data-services/request-cache.effects.ts b/src/app/core/data/request-cache.effects.ts similarity index 72% rename from src/app/core/data-services/request-cache.effects.ts rename to src/app/core/data/request-cache.effects.ts index b8dde51159..3c650d95f1 100644 --- a/src/app/core/data-services/request-cache.effects.ts +++ b/src/app/core/data/request-cache.effects.ts @@ -1,16 +1,15 @@ -import { Injectable } from "@angular/core"; +import { Injectable, Inject } from "@angular/core"; import { Actions, Effect } from "@ngrx/effects"; -import { ResetRequestCacheTimestampsAction } from "../cache/request-cache.actions"; -import { Store } from "@ngrx/store"; -import { RequestCacheState } from "../cache/request-cache.reducer"; import { ObjectCacheActionTypes } from "../cache/object-cache.actions"; +import { GlobalConfig, GLOBAL_CONFIG } from "../../../config"; +import { ResetResponseCacheTimestampsAction } from "../cache/response-cache.actions"; @Injectable() export class RequestCacheEffects { constructor( + @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, private actions$: Actions, - private store: Store ) { } /** @@ -31,6 +30,5 @@ export class RequestCacheEffects { */ @Effect() fixTimestampsOnRehydrate = this.actions$ .ofType(ObjectCacheActionTypes.RESET_TIMESTAMPS) - .map(() => new ResetRequestCacheTimestampsAction(new Date().getTime())); - + .map(() => new ResetResponseCacheTimestampsAction(new Date().getTime())); } diff --git a/src/app/core/data/request.actions.ts b/src/app/core/data/request.actions.ts new file mode 100644 index 0000000000..f9663aaf7f --- /dev/null +++ b/src/app/core/data/request.actions.ts @@ -0,0 +1,61 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../shared/ngrx/type"; +import { PaginationOptions } from "../shared/pagination-options.model"; +import { SortOptions } from "../shared/sort-options.model"; +import { CacheableObject } from "../cache/object-cache.reducer"; +import { Request } from "./request.models"; + +/** + * The list of RequestAction type definitions + */ +export const RequestActionTypes = { + CONFIGURE: type('dspace/core/data/request/CONFIGURE'), + EXECUTE: type('dspace/core/data/request/EXECUTE'), + COMPLETE: type('dspace/core/data/request/COMPLETE') +}; + +export class RequestConfigureAction implements Action { + type = RequestActionTypes.CONFIGURE; + payload: Request; + + constructor( + request: Request + ) { + this.payload = request; + } +} + +export class RequestExecuteAction implements Action { + type = RequestActionTypes.EXECUTE; + payload: string; + + constructor(key: string) { + this.payload = key + } +} + +/** + * An ngrx action to indicate a response was returned + */ +export class RequestCompleteAction implements Action { + type = RequestActionTypes.COMPLETE; + payload: string; + + /** + * Create a new RequestCompleteAction + * + * @param key + * the key under which this request is stored, + */ + constructor(key: string) { + this.payload = key; + } +} + +/** + * A type to encompass all RequestActions + */ +export type RequestAction + = RequestConfigureAction + | RequestExecuteAction + | RequestCompleteAction; diff --git a/src/app/core/data/request.effects.ts b/src/app/core/data/request.effects.ts new file mode 100644 index 0000000000..037feedb95 --- /dev/null +++ b/src/app/core/data/request.effects.ts @@ -0,0 +1,69 @@ +import { Injectable, Inject } from "@angular/core"; +import { Actions, Effect } from "@ngrx/effects"; +import { Store } from "@ngrx/store"; +import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model"; +import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer"; +import { CacheableObject } from "../cache/object-cache.reducer"; +import { Observable } from "rxjs"; +import { Response, SuccessResponse, ErrorResponse } from "../cache/response-cache.models"; +import { hasNoValue } from "../../shared/empty.util"; +import { GlobalConfig, GLOBAL_CONFIG } from "../../../config"; +import { RequestState, RequestEntry } from "./request.reducer"; +import { + RequestActionTypes, RequestConfigureAction, RequestExecuteAction, + RequestCompleteAction +} from "./request.actions"; +import { ResponseCacheService } from "../cache/response-cache.service"; + +@Injectable() +export class RequestEffects { + + constructor( + @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, + private actions$: Actions, + private restApi: DSpaceRESTv2Service, + private objectCache: ObjectCacheService, + private responseCache: ResponseCacheService, + private store: Store + ) { } + + @Effect() execute = this.actions$ + .ofType(RequestActionTypes.EXECUTE) + .flatMap((action: RequestExecuteAction) => { + return this.store.select('core', 'data', 'request', action.payload) + .take(1); + }) + .flatMap((entry: RequestEntry) => { + const [ifArray, ifNotArray] = this.restApi.get(entry.request.href) + .share() // share ensures restApi.get() doesn't get called twice when the partitions are used below + .partition((data: DSpaceRESTV2Response) => Array.isArray(data._embedded)); + + return Observable.merge( + + ifArray.map((data: DSpaceRESTV2Response) => { + return new DSpaceRESTv2Serializer(entry.request.resourceType).deserializeArray(data); + }).do((cos: CacheableObject[]) => cos.forEach((t) => this.addToObjectCache(t))) + .map((cos: Array): Array => cos.map(t => t.uuid)), + + ifNotArray.map((data: DSpaceRESTV2Response) => { + return new DSpaceRESTv2Serializer(entry.request.resourceType).deserialize(data); + }).do((co: CacheableObject) => this.addToObjectCache(co)) + .map((co: CacheableObject): Array => [co.uuid]) + + ).map((ids: Array) => new SuccessResponse(ids)) + .do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) + .map((response: Response) => new RequestCompleteAction(entry.request.href)) + .catch((error: Error) => Observable.of(new ErrorResponse(error.message)) + .do((response: Response) => this.responseCache.add(entry.request.href, response, this.EnvConfig.cache.msToLive)) + .map((response: Response) => new RequestCompleteAction(entry.request.href))); + }); + + protected addToObjectCache(co: CacheableObject): void { + if (hasNoValue(co) || hasNoValue(co.uuid)) { + throw new Error('The server returned an invalid object'); + } + this.objectCache.add(co, this.EnvConfig.cache.msToLive); + } +} diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts new file mode 100644 index 0000000000..6971323cc1 --- /dev/null +++ b/src/app/core/data/request.models.ts @@ -0,0 +1,32 @@ +import { SortOptions } from "../shared/sort-options.model"; +import { PaginationOptions } from "../shared/pagination-options.model"; +import { GenericConstructor } from "../shared/generic-constructor"; + +export class Request { + constructor( + public href: string, + public resourceType: GenericConstructor + ) {} +} + +export class FindByIDRequest extends Request { + constructor( + href: string, + resourceType: GenericConstructor, + public resourceID: string + ) { + super(href, resourceType); + } +} + +export class FindAllRequest extends Request { + constructor( + href: string, + resourceType: GenericConstructor, + public scopeID?: string, + public paginationOptions?: PaginationOptions, + public sortOptions?: SortOptions + ) { + super(href, resourceType); + } +} diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts new file mode 100644 index 0000000000..e20accc831 --- /dev/null +++ b/src/app/core/data/request.reducer.ts @@ -0,0 +1,81 @@ +import { CacheableObject } from "../cache/object-cache.reducer"; +import { + RequestActionTypes, RequestAction, RequestConfigureAction, + RequestExecuteAction, RequestCompleteAction +} from "./request.actions"; +import { Request } from "./request.models"; + +export class RequestEntry { + request: Request; + requestPending: boolean; + responsePending: boolean; + completed: boolean; +} + + +export interface RequestState { + [key: string]: RequestEntry +} + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState = Object.create(null); + +export const requestReducer = (state = initialState, action: RequestAction): RequestState => { + switch (action.type) { + + case RequestActionTypes.CONFIGURE: { + return configureRequest(state, action); + } + + case RequestActionTypes.EXECUTE: { + return executeRequest(state, action); + } + + case RequestActionTypes.COMPLETE: { + return completeRequest(state, action); + } + + default: { + return state; + } + } +}; + +function configureRequest(state: RequestState, action: RequestConfigureAction): RequestState { + return Object.assign({}, state, { + [action.payload.href]: { + request: action.payload, + requestPending: true, + responsePending: false, + completed: false + } + }); +} + +function executeRequest(state: RequestState, action: RequestExecuteAction): RequestState { + return Object.assign({}, state, { + [action.payload]: Object.assign({}, state[action.payload], { + requestPending: false, + responsePending: true + }) + }); +} + +/** + * Update a request with the response + * + * @param state + * the current state + * @param action + * a RequestCompleteAction + * @return RequestState + * the new state, with the response added to the request + */ +function completeRequest(state: RequestState, action: RequestCompleteAction): RequestState { + return Object.assign({}, state, { + [action.payload]: Object.assign({}, state[action.payload], { + responsePending: false, + completed: true + }) + }); +} diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts index b5fa5983d8..ec557df4ae 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts @@ -55,7 +55,8 @@ export class DSpaceRESTv2Serializer implements Serializer { if (Array.isArray(response._embedded)) { throw new Error('Expected a single model, use deserializeArray() instead'); } - return Deserialize(response._embedded, this.modelType); + let normalized = Object.assign({}, response._embedded, this.normalizeLinks(response._links)); + return Deserialize(normalized, this.modelType); } /** @@ -70,7 +71,26 @@ export class DSpaceRESTv2Serializer implements Serializer { if (!Array.isArray(response._embedded)) { throw new Error('Expected an Array, use deserialize() instead'); } - return > Deserialize(response._embedded, this.modelType); + let normalized = response._embedded.map((resource) => { + return Object.assign({}, resource, this.normalizeLinks(resource._links)); + }); + + return > Deserialize(normalized, this.modelType); + } + + private normalizeLinks(links:any): any { + let normalizedLinks = links; + for (let link in normalizedLinks) { + if (Array.isArray(normalizedLinks[link])) { + normalizedLinks[link] = normalizedLinks[link].map(linkedResource => { + return {'self': linkedResource.href }; + }); + } + else { + normalizedLinks[link] = normalizedLinks[link].href; + } + } + return normalizedLinks; } } diff --git a/src/app/core/index/href-index.actions.ts b/src/app/core/index/href-index.actions.ts new file mode 100644 index 0000000000..8c00f2d96c --- /dev/null +++ b/src/app/core/index/href-index.actions.ts @@ -0,0 +1,58 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../shared/ngrx/type"; + +/** + * The list of HrefIndexAction type definitions + */ +export const HrefIndexActionTypes = { + ADD: type('dspace/core/index/href/ADD'), + REMOVE_UUID: type('dspace/core/index/href/REMOVE_UUID') +}; + +/** + * An ngrx action to add an href to the index + */ +export class AddToHrefIndexAction implements Action { + type = HrefIndexActionTypes.ADD; + payload: { + href: string; + uuid: string; + }; + + /** + * Create a new AddToHrefIndexAction + * + * @param href + * the href to add + * @param uuid + * the uuid of the resource the href links to + */ + constructor(href: string, uuid: string) { + this.payload = { href, uuid }; + } +} + +/** + * An ngrx action to remove an href from the index + */ +export class RemoveUUIDFromHrefIndexAction implements Action { + type = HrefIndexActionTypes.REMOVE_UUID; + payload: string; + + /** + * Create a new RemoveUUIDFromHrefIndexAction + * + * @param uuid + * the uuid to remove all hrefs for + */ + constructor(uuid: string) { + this.payload = uuid; + } +} + +/** + * A type to encompass all HrefIndexActions + */ +export type HrefIndexAction + = AddToHrefIndexAction + | RemoveUUIDFromHrefIndexAction; diff --git a/src/app/core/index/href-index.effects.ts b/src/app/core/index/href-index.effects.ts new file mode 100644 index 0000000000..2e1c8ae8d1 --- /dev/null +++ b/src/app/core/index/href-index.effects.ts @@ -0,0 +1,32 @@ +import { Injectable } from "@angular/core"; +import { Effect, Actions } from "@ngrx/effects"; +import { + ObjectCacheActionTypes, AddToObjectCacheAction, + RemoveFromObjectCacheAction +} from "../cache/object-cache.actions"; +import { AddToHrefIndexAction, RemoveUUIDFromHrefIndexAction } from "./href-index.actions"; +import { hasValue } from "../../shared/empty.util"; + +@Injectable() +export class HrefIndexEffects { + + constructor( + private actions$: Actions + ) { } + + @Effect() add$ = this.actions$ + .ofType(ObjectCacheActionTypes.ADD) + .filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache.self)) + .map((action: AddToObjectCacheAction) => { + return new AddToHrefIndexAction( + action.payload.objectToCache.self, + action.payload.objectToCache.uuid + ); + }); + + @Effect() remove$ = this.actions$ + .ofType(ObjectCacheActionTypes.REMOVE) + .map((action: RemoveFromObjectCacheAction) => { + return new RemoveUUIDFromHrefIndexAction(action.payload); + }); +} diff --git a/src/app/core/index/href-index.reducer.ts b/src/app/core/index/href-index.reducer.ts new file mode 100644 index 0000000000..8cb46566df --- /dev/null +++ b/src/app/core/index/href-index.reducer.ts @@ -0,0 +1,43 @@ +import { + HrefIndexAction, HrefIndexActionTypes, AddToHrefIndexAction, + RemoveUUIDFromHrefIndexAction +} from "./href-index.actions"; +export interface HrefIndexState { + [href: string]: string +} + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState: HrefIndexState = Object.create(null); + +export const hrefIndexReducer = (state = initialState, action: HrefIndexAction): HrefIndexState => { + switch (action.type) { + + case HrefIndexActionTypes.ADD: { + return addToHrefIndex(state, action); + } + + case HrefIndexActionTypes.REMOVE_UUID: { + return removeUUIDFromHrefIndex(state, action) + } + + default: { + return state; + } + } +}; + +function addToHrefIndex(state: HrefIndexState, action: AddToHrefIndexAction): HrefIndexState { + return Object.assign({}, state, { + [action.payload.href]: action.payload.uuid + }); +} + +function removeUUIDFromHrefIndex(state: HrefIndexState, action: RemoveUUIDFromHrefIndexAction): HrefIndexState { + let newState = Object.create(null); + for (let href in state) { + if (state[href] !== action.payload) { + newState[href] = state[href]; + } + } + return newState; +} diff --git a/src/app/core/index/index.reducers.ts b/src/app/core/index/index.reducers.ts new file mode 100644 index 0000000000..e7e3d7218a --- /dev/null +++ b/src/app/core/index/index.reducers.ts @@ -0,0 +1,14 @@ +import { combineReducers } from "@ngrx/store"; +import { HrefIndexState, hrefIndexReducer } from "./href-index.reducer"; + +export interface IndexState { + href: HrefIndexState +} + +export const reducers = { + href: hrefIndexReducer +}; + +export function indexReducer(state: any, action: any) { + return combineReducers(reducers)(state, action); +} diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index 7048ded4a4..3198cbb557 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -1,6 +1,8 @@ -import { autoserialize, inheritSerialization } from "cerialize"; +import { autoserialize, inheritSerialization, autoserializeAs } from "cerialize"; import { DSpaceObject } from "./dspace-object.model"; import { Bitstream } from "./bitstream.model"; +import { Item } from "./item.model"; +import { RemoteData } from "../data/remote-data"; @inheritSerialization(DSpaceObject) export class Collection extends DSpaceObject { @@ -54,7 +56,7 @@ export class Collection extends DSpaceObject { /** * The Bitstream that represents the logo of this Collection */ - logo: Bitstream; + logo: RemoteData; /** * An array of Collections that are direct parents of this Collection @@ -66,4 +68,7 @@ export class Collection extends DSpaceObject { */ owner: Collection; + @autoserializeAs(RemoteData) + items: RemoteData; + } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 395886655f..0b855b0a73 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -8,6 +8,9 @@ import { CacheableObject } from "../cache/object-cache.reducer"; */ export abstract class DSpaceObject implements CacheableObject { + @autoserialize + self: string; + /** * The human-readable identifier of this DSpaceObject */ diff --git a/src/app/core/shared/param-hash.spec.ts b/src/app/core/shared/param-hash.spec.ts deleted file mode 100644 index f532c15235..0000000000 --- a/src/app/core/shared/param-hash.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ParamHash } from "./param-hash"; -describe("ParamHash", () => { - - it("should return a hash for a set of parameters", () => { - const hash = new ParamHash('azerty', true, 23).toString(); - - expect(hash).not.toBeNull(); - expect(hash).not.toBe(''); - }); - - it("should work with both simple and complex objects as parameters", () => { - const hash = new ParamHash('azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }).toString(); - - expect(hash).not.toBeNull(); - expect(hash).not.toBe(''); - }); - - it("should work with null or undefined as parameters", () => { - const hash1 = new ParamHash(undefined).toString(); - const hash2 = new ParamHash(null).toString(); - const hash3 = new ParamHash(undefined, null).toString(); - - expect(hash1).not.toBeNull(); - expect(hash1).not.toBe(''); - expect(hash2).not.toBeNull(); - expect(hash2).not.toBe(''); - expect(hash3).not.toBeNull(); - expect(hash3).not.toBe(''); - expect(hash1).not.toEqual(hash2); - expect(hash1).not.toEqual(hash3); - expect(hash2).not.toEqual(hash3); - }); - - it("should work if created without parameters", () => { - const hash1 = new ParamHash().toString(); - const hash2 = new ParamHash().toString(); - - expect(hash1).not.toBeNull(); - expect(hash1).not.toBe(''); - expect(hash1).toEqual(hash2); - }); - - it("should create the same hash if created with the same set of parameters in the same order", () => { - const params = ['azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }]; - const hash1 = new ParamHash(...params).toString(); - const hash2 = new ParamHash(...params).toString(); - - expect(hash1).toEqual(hash2); - }); - - it("should create a different hash if created with the same set of parameters in a different order", () => { - const params = ['azerty', true, 23, { "a": { "b": ['azerty', true] }, "c": 23 }]; - const hash1 = new ParamHash(...params).toString(); - const hash2 = new ParamHash(...params.reverse()).toString(); - - expect(hash1).not.toEqual(hash2); - }); -}); diff --git a/src/app/core/shared/param-hash.ts b/src/app/core/shared/param-hash.ts deleted file mode 100644 index 9d07819ce5..0000000000 --- a/src/app/core/shared/param-hash.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Md5 } from "ts-md5/dist/md5"; - -/** - * Creates a hash of a set of parameters - */ -export class ParamHash { - private params: Array; - - constructor(...params) { - this.params = params; - } - - /** - * Returns an md5 hash based on the - * params passed to the constructor - * - * If you hash the same set of params in the - * same order the hashes will be identical - * - * @return {string} - * an md5 hash - */ - toString(): string { - let hash = new Md5(); - this.params.forEach((param) => { - if (param === Object(param)) { - hash.appendStr(JSON.stringify(param)); - } - else { - hash.appendStr('' + param); - } - }); - return hash.end().toString(); - } -} diff --git a/src/app/core/shared/self-link.model.ts b/src/app/core/shared/self-link.model.ts new file mode 100644 index 0000000000..5adc78062a --- /dev/null +++ b/src/app/core/shared/self-link.model.ts @@ -0,0 +1,10 @@ +import { autoserialize } from "cerialize"; + +export class SelfLink { + + @autoserialize + self: string; + + @autoserialize + uuid: string; +} diff --git a/src/app/core/url-combiner/ui-url-combiner.ts b/src/app/core/url-combiner/ui-url-combiner.ts index 260d33d1ca..c5254fdd41 100644 --- a/src/app/core/url-combiner/ui-url-combiner.ts +++ b/src/app/core/url-combiner/ui-url-combiner.ts @@ -8,7 +8,7 @@ import { GlobalConfig } from "../../../config"; * TODO write tests once GlobalConfig becomes injectable */ export class UIURLCombiner extends URLCombiner{ - constructor(...parts:Array) { - super(GlobalConfig.ui.baseURL, GlobalConfig.ui.nameSpace, ...parts); + constructor(EnvConfig: GlobalConfig, ...parts: Array) { + super(EnvConfig.ui.baseUrl, EnvConfig.ui.nameSpace, ...parts); } } diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 06cac4108b..8563b38aad 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -1,3 +1,22 @@
- Home component +
+

Collections

+

Loading…

+

Failed: {{(remoteData.errorMessage | async)}}

+
    +
  • + {{collection?.name}}
    + {{collection?.shortDescription}} +
  • +
+
+
+

Items

+
    +
  • + {{item?.name}}
    + {{item?.findMetadata('dc.description.abstract')}} +
  • +
+
diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index 9cb0704a0a..3fc4b3ef4f 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -1,4 +1,11 @@ -import { Component, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { Component, ChangeDetectionStrategy, ViewEncapsulation, OnInit } from '@angular/core'; +import { Observable } from "rxjs"; +import { Collection } from "../core/shared/collection.model"; +import { Item } from "../core/shared/item.model"; +import { CollectionDataService } from "../core/data/collection-data.service"; +import { ItemDataService } from "../core/data/item-data.service"; +import { ObjectCacheService } from "../core/cache/object-cache.service"; +import { RemoteData } from "../core/data/remote-data"; @Component({ changeDetection: ChangeDetectionStrategy.Default, @@ -7,11 +14,16 @@ import { Component, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/ styleUrls: ['./home.component.css'], templateUrl: './home.component.html' }) -export class HomeComponent { - +export class HomeComponent implements OnInit { data: any = {}; + collections: RemoteData; + items: RemoteData; - constructor() { + constructor( + private cds: CollectionDataService, + private ids: ItemDataService, + private objectCache: ObjectCacheService + ) { this.universalInit(); } @@ -19,4 +31,13 @@ export class HomeComponent { } + ngOnInit(): void { + this.collections = this.cds.findAll(); + this.items = this.ids.findAll(); + this.cds.findById('5179').payload.subscribe(o => console.log('collection 1', o)); + this.cds.findById('6547').payload.subscribe(o => console.log('collection 2', o)); + this.ids.findById('8871').payload.subscribe(o => console.log('item 1', o)); + this.ids.findById('9978').payload.subscribe(o => console.log('item 2', o)); + } + } diff --git a/src/app/home/home.module.ts b/src/app/home/home.module.ts index 5fb6a55b8d..0d68ab9805 100644 --- a/src/app/home/home.module.ts +++ b/src/app/home/home.module.ts @@ -2,9 +2,11 @@ import { NgModule } from '@angular/core'; import { HomeComponent } from './home.component'; import { HomeRoutingModule } from './home-routing.module'; +import { CommonModule } from "@angular/common"; @NgModule({ imports: [ + CommonModule, HomeRoutingModule ], declarations: [