diff --git a/README.md b/README.md index 5dd357dd7d..de97be31ce 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# dspace-angular +[![Build Status](https://travis-ci.org/DSpace/dspace-angular.svg?branch=master)](https://travis-ci.org/DSpace/dspace-angular) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) + +dspace-angular +============== + > The next UI for DSpace, based on Angular 2 Universal. This project is currently in pre-alpha. @@ -7,7 +11,9 @@ You can find additional information on the [wiki](https://wiki.duraspace.org/dis If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [here](https://github.com/DSpace-Labs/angular2-ui-prototype) -## Quick start +Quick start +----------- + **Ensure you're running [Node](https://nodejs.org) >= `v5.x`, [npm](https://www.npmjs.com/) >= `v3.x` and [yarn](https://yarnpkg.com) >= `v0.20.x`** ```bash @@ -26,72 +32,95 @@ yarn install # start the server yarn start ``` + Then go to [http://localhost:3000](http://localhost:3000) in your browser NOTE: currently there's not much to see at that URL. We really do need your help. If you're interested in jumping in, and you've made it this far, please look at the [the project board (waffle.io)](https://waffle.io/DSpace/dspace-angular), grab a card, and get to work. Thanks! Not sure where to start? watch the training videos linked in the [Introduction to the technology](#introduction-to-the-technology) section below. -## Table of Contents -* [Introduction to the technology](#introduction-to-the-technology) -* [Requirements](#requirements) -* [Installing](#installing) -* [Configuring](#configuring) -* [Running the app](#running-the-app) -* [Running in production mode](#running-in-production-mode) -* [Cleaning](#cleaning) -* [Testing](#testing) -* [Documentation](#documentation) -* [Other commands](#other-commands) -* [Recommended Editors/IDEs](#recommended-editorsides) -* [Collaborating](#collaborating) -* [File Structure](#file-structure) -* [3rd Party Library Installation](#3rd-party-library-installation) -* [Frequently asked questions](#frequently-asked-questions) -* [License](#license) +Table of Contents +----------------- + +- [Introduction to the technology](#introduction-to-the-technology) +- [Requirements](#requirements) +- [Installing](#installing) +- [Configuring](#configuring) +- [Running the app](#running-the-app) +- [Running in production mode](#running-in-production-mode) +- [Cleaning](#cleaning) +- [Testing](#testing) +- [Documentation](#documentation) +- [Other commands](#other-commands) +- [Recommended Editors/IDEs](#recommended-editorsides) +- [Collaborating](#collaborating) +- [File Structure](#file-structure) +- [3rd Party Library Installation](#3rd-party-library-installation) +- [Frequently asked questions](#frequently-asked-questions) +- [License](#license) + +Introduction to the technology +------------------------------ -## Introduction to the technology You can find more information on the technologies used in this project (Angular 2, Typescript, Angular Universal, RxJS, etc) on the [DuraSpace wiki](https://wiki.duraspace.org/display/DSPACE/DSpace+7+UI+Technology+Stack) -## Requirements -* [Node.js](https://nodejs.org), [npm](https://www.npmjs.com/), and [yarn](https://yarnpkg.com) -* Ensure you're running node >= `v5.x`, npm >= `v3.x` and yarn >= `v0.20.x` +Requirements +------------ + +- [Node.js](https://nodejs.org), [npm](https://www.npmjs.com/), and [yarn](https://yarnpkg.com) +- Ensure you're running node >= `v5.x`, npm >= `v3.x` and yarn >= `v0.20.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. -## Installing -* `yarn run global` to install the required global dependencies -* `yarn install` to install the local dependencies +Installing +---------- + +- `yarn run global` to install the required global dependencies +- `yarn install` to install the local dependencies + +Configuring +----------- -## Configuring Default configuration file is located in `config/` folder. To change the default configuration values, create local files that override the parameters you need to change: -* Create a new `environment.dev.js` file in `config/` for `devel` environment; -* Create a new `environment.prod.js` file in `config/` for `production` environment; + +- Create a new `environment.dev.js` file in `config/` for `devel` environment; +- Create a new `environment.prod.js` file in `config/` for `production` environment; To use the configuration parameters in your component: + ```bash import { GlobalConfig } from "../config"; ``` -## Running the app +Running the app +--------------- + After you have installed all dependencies you can now run the app. Run `yarn run watch:dev` to start a local server which will watch for changes, rebuild the code, and reload the server for you. You can visit it at `http://localhost:3000`. -## Running in production mode +Running in production mode +-------------------------- + When building for production we're using Ahead of Time (AoT) compilation. With AoT, the browser downloads a pre-compiled version of the application, so it can render the application immediately, without waiting to compile the app first. The compiler is roughly half the size of Angular itself, so omitting it dramatically reduces the application payload. -To build the app for production and start the server run: +To build the app for production and start the server run: + ```bash yarn start ``` + If you only want to build for production, without starting, run: + ```bash yarn run build:prod:ngc:json ``` + This will build the application and put the result in the `dist` folder -## Cleaning +Cleaning +-------- + ```bash # clean everything, including node_modules. You'll need to run yarn install again afterwards. yarn run clean @@ -103,129 +132,160 @@ yarn run clean:prod yarn run clean:dist ``` -## Testing -### Unit Test -Unit tests use Karma. You can find the configuration file at the same level of this README file: -`./karma.conf.js` -If you are going to use a remote test enviroment you need to edit the './karma.conf.js'. Follow the instructions you will find inside it. -To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. -A coverage report is also available at: -http://localhost:9876/ -after you run: -`yarn run coverage`. +Testing +------- -To correctly run the tests you need to run the build once with: -`yarn run build`. +### Unit Test + +Unit tests use Karma. You can find the configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test enviroment you need to edit the './karma.conf.js'. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run:`yarn run coverage`. + +To correctly run the tests you need to run the build once with:`yarn run build`. The default browser is Google Chrome. Place your tests in the same location of the application source code files that they test. -and run: -`yarn run test` +and run:`yarn run test` ### E2E test -E2E tests use Protractor + Selenium server + browsers. You can find the configuration file at the same level of this README file: -`./protractor.conf.js` -Protractor is installed as 'local' as a dev dependency. + +E2E tests use Protractor + Selenium server + browsers. You can find the configuration file at the same level of this README file:`./protractor.conf.js` Protractor is installed as 'local' as a dev dependency. If you are going to use a remote test enviroment you need to edit the './protractor.conf.js'. Follow the instructions you will find inside it. The default browser is Google Chrome. -Protractor needs a functional instance of the DSpace interface to run the E2E tests, so you need to run: -`yarn run watch:dev` +Protractor needs a functional instance of the DSpace interface to run the E2E tests, so you need to run:`yarn run watch:dev` or any command that bring up the DSpace interface. -Place your tests at the following path: -`./e2e` +Place your tests at the following path:`./e2e` -and run: -`yarn run e2e` +and run:`yarn run e2e` ### Continuous Integration (CI) Test -To run all the tests (e.g.: to run tests with Continuous Integration software) you can execute: -`yarn run ci` -Keep in mind that this command prerequisites are the sum of unit test and E2E tests. -##Documentation -To build the code documentation we use [TYPEDOC](http://typedoc.org). TYPEDOC is a documentation generator for TypeScript projects. -It extracts informations from properly formatted comments that can be written within the code files. -Follow the instructions [here](http://typedoc.org/guides/doccomments/) to know how to make those comments. +To run all the tests (e.g.: to run tests with Continuous Integration software) you can execute:`yarn run ci` Keep in mind that this command prerequisites are the sum of unit test and E2E tests. -Run: -`yarn run docs` -to produce the documentation that will be available in the 'doc' folder. +##Documentation To build the code documentation we use [TYPEDOC](http://typedoc.org). TYPEDOC is a documentation generator for TypeScript projects. It extracts informations from properly formatted comments that can be written within the code files. Follow the instructions [here](http://typedoc.org/guides/doccomments/) to know how to make those comments. + +Run:`yarn run docs` to produce the documentation that will be available in the 'doc' folder. + +Other commands +-------------- -## Other commands There are many more commands in the `scripts` section of `package.json`. Most of these are executed by one of the commands mentioned above. A command with a name that starts with `pre` or `post` will be executed automatically before or after the script with the matching name. e.g. if you type `yarn run start` the `prestart` script will run first, then the `start` script will trigger. +Recommended Editors/IDEs +------------------------ -## Recommended Editors/IDEs To get the most out of TypeScript, you'll need a TypeScript-aware editor. We've had good experiences using these editors: -* Free - * [Visual Studio Code](https://code.visualstudio.com/) - * [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) - * [Atom](https://atom.io/) - * [TypeScript plugin](https://atom.io/packages/atom-typescript) -* Paid - * [Webstorm](https://www.jetbrains.com/webstorm/download/) or [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/) - * [Sublime Text](http://www.sublimetext.com/3) - * [Typescript-Sublime-Plugin](https://github.com/Microsoft/Typescript-Sublime-plugin#installation) +- Free + - [Visual Studio Code](https://code.visualstudio.com/) + - [Debugger for Chrome](https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome) + - [Atom](https://atom.io/) + - [TypeScript plugin](https://atom.io/packages/atom-typescript) +- Paid + - [Webstorm](https://www.jetbrains.com/webstorm/download/) or [IntelliJ IDEA Ultimate](https://www.jetbrains.com/idea/) + - [Sublime Text](http://www.sublimetext.com/3) + - [Typescript-Sublime-Plugin](https://github.com/Microsoft/Typescript-Sublime-plugin#installation) + +Collaborating +------------- -## Collaborating See [the guide on the wiki](https://wiki.duraspace.org/display/DSPACE/DSpace+7+-+Angular+2+UI#DSpace7-Angular2UI-Howtocontribute) -## File Structure +File Structure +-------------- + ``` dspace-angular -├── README.md * This document -├── app.json * Application manifest file -├── config * Folder for configuration files -│   └── environment.default.js * Default configuration files -├── e2e * Folder for e2e test files -├── karma.conf.js * Unit Test configuration file -├── nodemon.json * Nodemon (https://nodemon.io/) configuration -├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc. -├── postcss.config.json * PostCSS (http://postcss.org/) configuration file -├── protractor.conf.js * E2E tests configuration file -├── resources * Folder for static resources -│   ├── i18n * Folder for i18n translations -│   └── images * Folder for images -├── rollup-client.js * Rollup (http://rollupjs.org/) configuration for the client -├── rollup-server.js * Rollup (http://rollupjs.org/) configuration for the server -├── src * The source of the application -│   ├── app * The location of the app module, and root of the application shared by client and server -│   ├── backend * Folder containing a mock of the REST API, hosted by the express server -│   ├── browser.module.ts * The root module for the client -│   ├── client.aot.ts * The bootstrap file for the client, in production -│   ├── client.ts * The bootstrap file for the client, during development -│   ├── config.ts * File that loads environmental and shareable settings and makes them available to app components -│   ├── index-aot.html * The index.html file, for production -│   ├── index.html * The index.html file, for development -│   ├── node.module.ts * The root module for the server -│   ├── server.aot.ts * The express (http://expressjs.com/) config and bootstrap file for the server, in production -│   ├── server.routes.ts * The routes file for the server -│   ├── server.ts * The express (http://expressjs.com/) config and bootstrap file for the server, during development -│   ├── styles * Folder containing global styles. -│   │   ├── main.scss * Global scss file -│   │   └── variables.scss * Global sass variables file -│   └── typings.d.ts * File that allows you to add custom typings for libraries without TypeScript support -├── tsconfig.aot.json * TypeScript config for production builds -├── tsconfig.json * TypeScript config for development build -├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration -├── typedoc.json * TYPEDOC configuration -├── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock) -├── webpack.config.ts * Webpack (https://webpack.github.io/) config for development builds -├── webpack.test.config.ts * Webpack (https://webpack.github.io/) config for testing -└── webpack.prod.config.ts * Webpack (https://webpack.github.io/) config for production builds +├── README.md * This document +├── app.json * Application manifest file +├── config * Folder for configuration files +│   └── environment.default.js * Default configuration files +├── dist * Folder for e2e test files +├── e2e * +│   ├── app.e2e-spec.ts * +│   ├── app.po.ts * +│   ├── pagenotfound * +│   │   ├── pagenotfound.e2e-spec.ts * +│   │   └── pagenotfound.po.ts * +│   └── tsconfig.json * +├── empty.js * +├── helpers.js * +├── karma.conf.js * Unit Test configuration file +├── nodemon.json * Nodemon (https://nodemon.io/) configuration +├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc. +├── postcss.config.json * PostCSS (http://postcss.org/) configuration file +├── protractor.conf.js * E2E tests configuration file +├── resources * Folder for static resources +│   ├── i18n * Folder for i18n translations +│   │   └── en.json * +│   └── images * Folder for images +│   └── dspace_logo.png * +├── rollup-client.js * Rollup (http://rollupjs.org/) configuration for the client +├── rollup-server.js * Rollup (http://rollupjs.org/) configuration for the server +├── spec-bundle.js * +├── src * The source of the application +│   ├── app * The location of the app module, and root of the application shared by client and server +│   │   ├── app-routing.module.ts * +│   │   ├── app.component.html * +│   │   ├── app.component.scss * +│   │   ├── app.component.spec.ts * +│   │   ├── app.component.ts * +│   │   ├── app.effects.ts * +│   │   ├── app.module.ts * +│   │   ├── app.reducers.ts * +│   │   ├── core * +│   │   ├── header * +│   │   ├── home * +│   │   ├── pagenotfound * +│   │   ├── shared * +│   │   └── store.actions.ts * +│   ├── backend * Folder containing a mock of the REST API, hosted by the express server +│   │   ├── api.ts * +│   │   ├── bitstreams.ts * +│   │   ├── bundles.ts * +│   │   ├── cache.ts * +│   │   ├── collections.ts * +│   │   ├── db.ts * +│   │   ├── items.ts * +│   │   └── metadata.ts * +│   ├── client.aot.ts * The bootstrap file for the client, in production +│   ├── client.ts * The bootstrap file for the client, during development +│   ├── config.ts * File that loads environmental and shareable settings and makes them available to app components +│   ├── index.html * The index.html file +│   ├── platform * +│   │   ├── angular2-meta.ts * +│   │   ├── modules * +│   │   │   ├── browser.module.ts * The root module for the client +│   │   │   └── node.module.ts * The root module for the server +│   │   └── workarounds * +│   │   ├── __workaround.browser.ts * +│   │   └── __workaround.node.ts * +│   ├── server.aot.ts * The express (http://expressjs.com/) config and bootstrap file for the server, in production +│   ├── server.routes.ts * The routes file for the server +│   ├── server.ts * The express (http://expressjs.com/) config and bootstrap file for the server, during development +│   ├── styles * Folder containing global styles. +│   │   ├── main.scss * Global scss file +│   │   └── variables.scss * Global sass variables file +│   └── typings.d.ts * File that allows you to add custom typings for libraries without TypeScript support +├── tsconfig.aot.json * TypeScript config for production builds +├── tsconfig.json * TypeScript config for development build +├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration +├── typedoc.json * TYPEDOC configuration +├── webpack.config.ts * Webpack (https://webpack.github.io/) config for development builds +├── webpack.prod.config.ts * Webpack (https://webpack.github.io/) config for production builds +├── webpack.test.config.js * Webpack (https://webpack.github.io/) config for testing +└── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock) ``` -## 3rd Party Library Installation +3rd Party Library Installation +------------------------------ Install your library via `yarn add lib-name --save` and import it in your code. `--save` will add it to `package.json`. @@ -236,57 +296,60 @@ yarn add d3 --save yarn add @types/d3 --save-dev ``` -If the library doesn't have typings available at `@types/`, you can still use it by -manually adding typings for it: +If the library doesn't have typings available at `@types/`, you can still use it by manually adding typings for it: -1. In `src/typings.d.ts`, add the following code: +1. In `src/typings.d.ts`, add the following code: - ```typescript - declare module 'typeless-package'; - ``` + ```typescript + declare module 'typeless-package'; + ``` -2. Then, in the component or file that uses the library, add the following code: +2. Then, in the component or file that uses the library, add the following code: - ```typescript - import * as typelessPackage from 'typeless-package'; - typelessPackage.method(); - ``` + ```typescript + import * as typelessPackage from 'typeless-package'; + typelessPackage.method(); + ``` Done. Note: you might need or find useful to define more typings for the library that you're trying to use. - If you're importing a module that uses CommonJS you need to import as ```typescript import * as _ from 'lodash'; ``` -## yarn lockfile +yarn lockfile +------------- + This project makes use of yarn to ensure that the exact same dependency versions are used every time you install it. yarn creates the file [`yarn.lock`](https://yarnpkg.com/en/docs/yarn-lock) to track those versions. That file is updated automatically every time you install a new dependency from the commandline (by using `yarn add some-lib --save` or `yarn add some-lib --save-dev`). If you manually add a package or change a version in `package.json` you'll have to update yarn's lock file as well. You can do so by running `yarn upgrade` -## Frequently asked questions -* Why is my service, aka provider, is not injecting a parameter correctly? - * Please use `@Injectable()` for your service for typescript to correctly attach the metadata -* Where do I write my tests? - * You can write your tests next to your component files. e.g. for `src/app/home/home.component.ts` call it `src/app/home/home.component.spec.ts` -* How do I start the app when I get `EACCES` and `EADDRINUSE` errors? - * The `EADDRINUSE` error means the port `3000` is currently being used and `EACCES` is lack of permission to build files to `./dist/` -* What are the naming conventions for Angular 2? - * See [the official angular 2 style guide](https://angular.io/styleguide) -* Why is the size of my app larger in development? - * The production build uses a whole host of techniques (ahead-of-time compilation, rollup to remove unreachable code, minification, etc.) to reduce the size, that aren't used during development in the intrest of build speed. -* node-pre-gyp ERR in yarn install (Windows) - * install Python x86 version between 2.5 and 3.0 on windows. See [this issue](https://github.com/AngularClass/angular2-webpack-starter/issues/626) -* How do I handle merge conflicts in yarn.lock? - * first check out the yarn.lock file from the branch you're merging in to yours: e.g. `git checkout --theirs yarn.lock` - * now run `yarn install` again. Yarn will create a new lockfile that contains both sets of changes. - * then run `git add yarn.lock` to stage the lockfile for commit - * and `git commit` to conclude the merge +Frequently asked questions +-------------------------- +- Why is my service, aka provider, is not injecting a parameter correctly? + - Please use `@Injectable()` for your service for typescript to correctly attach the metadata +- Where do I write my tests? + - You can write your tests next to your component files. e.g. for `src/app/home/home.component.ts` call it `src/app/home/home.component.spec.ts` +- How do I start the app when I get `EACCES` and `EADDRINUSE` errors? + - The `EADDRINUSE` error means the port `3000` is currently being used and `EACCES` is lack of permission to build files to `./dist/` +- What are the naming conventions for Angular 2? + - See [the official angular 2 style guide](https://angular.io/styleguide) +- Why is the size of my app larger in development? + - The production build uses a whole host of techniques (ahead-of-time compilation, rollup to remove unreachable code, minification, etc.) to reduce the size, that aren't used during development in the intrest of build speed. +- node-pre-gyp ERR in yarn install (Windows) + - install Python x86 version between 2.5 and 3.0 on windows. See [this issue](https://github.com/AngularClass/angular2-webpack-starter/issues/626) +- How do I handle merge conflicts in yarn.lock? + - first check out the yarn.lock file from the branch you're merging in to yours: e.g. `git checkout --theirs yarn.lock` + - now run `yarn install` again. Yarn will create a new lockfile that contains both sets of changes. + - then run `git add yarn.lock` to stage the lockfile for commit + - and `git commit` to conclude the merge + +License +------- -## License http://www.dspace.org/license diff --git a/config/environment.default.js b/config/environment.default.js index 47c4213436..cddd204eea 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -1,22 +1,28 @@ module.exports = { - "production": false, - // The REST API Location. + // The REST API server settings. "rest": { + "ssl": false, + "address": "localhost", + "port": 3000, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - "nameSpace": "/api", - "baseURL": "http://localhost:3000" + "nameSpace": "/api" }, - // Path and Port in use for this Angular2 UI + // Angular2 UI server settings. "ui": { - "nameSpace": "/", - "baseURL": "http://localhost:3000" + "ssl": false, + "address": "localhost", + "port": 3000, + // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript + "nameSpace": "/" }, "cache": { // how long should objects be cached for by default - "msToLive": 15 * 60 * 1000, //15 minutes + "msToLive": 15 * 60 * 1000, // 15 minute + "control": "max-age=60" // revalidate browser }, "universal": { - //on the client: start with the state on the server - "shouldRehydrate": true + // Angular Universal settings + "preboot": true, + "async": true } }; diff --git a/package.json b/package.json index 78b477cfe2..1a3ac750b7 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "karma-sourcemap-loader": "^0.3.7", "karma-webdriver-launcher": "^1.0.4", "karma-webpack": "1.8.0", + "ngrx-store-freeze": "^0.1.9", "node-sass": "4.0.0", "nodemon": "1.11.0", "npm-run-all": "4.0.2", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 1186af1993..5099b3899d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -6,7 +6,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; imports: [ RouterModule.forChild([ { path: '', redirectTo: '/home', pathMatch: 'full' }, - { path: '**', pathMatch: 'full', component: PageNotFoundComponent}, + { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]) ], }) diff --git a/src/app/app.component.html b/src/app/app.component.html index 3fb7748dd6..87d3703586 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -6,8 +6,10 @@

{{ 'example.with.data' | translate:data }}

{{ example }}

-

development

-

production

+

+ development + production +

diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 5a9b8df543..2a58ae0aa2 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -1,5 +1,4 @@ // Sticky Footer - .outer-wrapper { display: flex; margin: 0; @@ -16,3 +15,11 @@ .main-content { flex: 1 0 auto; } + +h2.red { + color: red; +} + +h2.green { + color: green; +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 2664b5f27b..d56be1a807 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -21,6 +21,8 @@ import { HostWindowState } from "./shared/host-window.reducer"; import { HostWindowResizeAction } from "./shared/host-window.actions"; import { MockTranslateLoader } from "./shared/testing/mock-translate-loader"; +import { GLOBAL_CONFIG, EnvConfig } from '../config'; + let comp: AppComponent; let fixture: ComponentFixture; let de: DebugElement; @@ -37,6 +39,7 @@ describe('App component', () => { })], declarations: [AppComponent], // declare the test component providers: [ + { provide: GLOBAL_CONFIG, useValue: EnvConfig }, AppComponent ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/src/app/app.component.ts b/src/app/app.component.ts index a5e6f50579..b27fdd354b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,6 +1,7 @@ import { Component, ChangeDetectionStrategy, + Inject, ViewEncapsulation, OnDestroy, OnInit, HostListener @@ -9,7 +10,8 @@ import { TranslateService } from "ng2-translate"; import { HostWindowState } from "./shared/host-window.reducer"; import { Store } from "@ngrx/store"; import { HostWindowResizeAction } from "./shared/host-window.actions"; -import { GlobalConfig } from "../config"; + +import { GLOBAL_CONFIG, GlobalConfig } from '../config'; @Component({ changeDetection: ChangeDetectionStrategy.Default, @@ -28,13 +30,8 @@ export class AppComponent implements OnDestroy, OnInit { recipient: 'World' }; - env: string = GlobalConfig.production; - - styles = { - color: 'red' - }; - constructor( + @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, private translate: TranslateService, private store: Store ) { diff --git a/src/app/app.reducers.ts b/src/app/app.reducers.ts index 407a334144..2b9e121c38 100644 --- a/src/app/app.reducers.ts +++ b/src/app/app.reducers.ts @@ -3,8 +3,12 @@ import { routerReducer, RouterState } from "@ngrx/router-store"; import { headerReducer, HeaderState } from './header/header.reducer'; import { hostWindowReducer, HostWindowState } from "./shared/host-window.reducer"; import { CoreState, coreReducer } from "./core/core.reducers"; +import { storeFreeze } from 'ngrx-store-freeze'; +import { compose } from "@ngrx/core"; import { StoreActionTypes } from "./store.actions"; +import { EnvConfig } from '../config'; + export interface AppState { core: CoreState; router: RouterState; @@ -20,10 +24,16 @@ export const reducers = { }; export function rootReducer(state: any, action: any) { + let output; if (action.type === StoreActionTypes.REHYDRATE) { state = action.payload; } - return combineReducers(reducers)(state, action); + if (EnvConfig.production) { + output = combineReducers(reducers)(state, action); + } else { + output = compose(storeFreeze, combineReducers)(reducers)(state, action); + } + return output; } export const NGRX_CACHE_KEY = "NGRX_STORE"; 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/models/bitstream-builder.ts b/src/app/core/cache/models/bitstream-builder.ts new file mode 100644 index 0000000000..2cb3f30eb9 --- /dev/null +++ b/src/app/core/cache/models/bitstream-builder.ts @@ -0,0 +1,65 @@ +import { Bitstream } from "../../shared/bitstream.model"; +import { ObjectCacheService } from "../object-cache.service"; +import { ResponseCacheService } from "../response-cache.service"; +import { RequestService } from "../../data/request.service"; +import { Store } from "@ngrx/store"; +import { CoreState } from "../../core.reducers"; +import { NormalizedBitstream } from "./normalized-bitstream.model"; +import { ListRemoteDataBuilder, SingleRemoteDataBuilder } from "./remote-data-builder"; +import { Request } from "../../data/request.models"; +import { hasValue } from "../../../shared/empty.util"; +import { RequestConfigureAction, RequestExecuteAction } from "../../data/request.actions"; + +export class BitstreamBuilder { + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store, + protected href: string, + protected normalized: NormalizedBitstream + ) { + } + + build(): Bitstream { + let links: any = {}; + //TODO + return Object.assign(new Bitstream(), this.normalized, links); + } +} + +export class BitstreamRDBuilder extends SingleRemoteDataBuilder { + + constructor( + objectCache: ObjectCacheService, + responseCache: ResponseCacheService, + requestService: RequestService, + store: Store, + href: string + ) { + super(objectCache, responseCache, requestService, store, href, NormalizedBitstream); + } + + protected normalizedToDomain(normalized: NormalizedBitstream): Bitstream { + return new BitstreamBuilder(this.objectCache, this.responseCache, this.requestService, this.store, this.href, normalized).build(); + } + +} + +export class BitstreamListRDBuilder extends ListRemoteDataBuilder { + constructor( + objectCache: ObjectCacheService, + responseCache: ResponseCacheService, + requestService: RequestService, + store: Store, + href: string + ) { + super(objectCache, responseCache, requestService, store, href, NormalizedBitstream); + } + + protected normalizedToDomain(normalized: NormalizedBitstream): Bitstream { + return new BitstreamBuilder(this.objectCache, this.responseCache, this.requestService, this.store, this.href, normalized).build(); + } + +} diff --git a/src/app/core/cache/models/bundle-builder.ts b/src/app/core/cache/models/bundle-builder.ts new file mode 100644 index 0000000000..b360c28f58 --- /dev/null +++ b/src/app/core/cache/models/bundle-builder.ts @@ -0,0 +1,93 @@ +import { Bundle } from "../../shared/bundle.model"; +import { ObjectCacheService } from "../object-cache.service"; +import { ResponseCacheService } from "../response-cache.service"; +import { RequestService } from "../../data/request.service"; +import { Store } from "@ngrx/store"; +import { CoreState } from "../../core.reducers"; +import { NormalizedBundle } from "./normalized-bundle.model"; +import { ListRemoteDataBuilder, SingleRemoteDataBuilder } from "./remote-data-builder"; +import { Request } from "../../data/request.models"; +import { hasValue } from "../../../shared/empty.util"; +import { RequestConfigureAction, RequestExecuteAction } from "../../data/request.actions"; +import { BitstreamRDBuilder } from "./bitstream-builder"; +import { NormalizedBitstream } from "./normalized-bitstream.model"; + +export class BundleBuilder { + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store, + protected href: string, + protected normalized: NormalizedBundle + ) { + } + + build(): Bundle { + let links: any = {}; + + if (hasValue(this.normalized.bitstreams)) { + //for some reason the dispatches in the forEach don't + //fire without this timeout. A zone issue? + setTimeout(() => { + this.normalized.bitstreams.forEach((href: string) => { + const isCached = this.objectCache.hasBySelfLink(href); + const isPending = this.requestService.isPending(href); + + if (!(isCached || isPending)) { + const request = new Request(href, NormalizedBitstream); + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(href)); + } + }); + }, 0); + + links.bitstreams = this.normalized.bitstreams.map((href: string) => { + return new BitstreamRDBuilder( + this.objectCache, + this.responseCache, + this.requestService, + this.store, + href + ).build(); + }); + } + return Object.assign(new Bundle(), this.normalized, links); + } +} + +export class BundleRDBuilder extends SingleRemoteDataBuilder { + + constructor( + objectCache: ObjectCacheService, + responseCache: ResponseCacheService, + requestService: RequestService, + store: Store, + href: string + ) { + super(objectCache, responseCache, requestService, store, href, NormalizedBundle); + } + + protected normalizedToDomain(normalized: NormalizedBundle): Bundle { + return new BundleBuilder(this.objectCache, this.responseCache, this.requestService, this.store, this.href, normalized).build(); + } + +} + +export class BundleListRDBuilder extends ListRemoteDataBuilder { + constructor( + objectCache: ObjectCacheService, + responseCache: ResponseCacheService, + requestService: RequestService, + store: Store, + href: string + ) { + super(objectCache, responseCache, requestService, store, href, NormalizedBundle); + } + + protected normalizedToDomain(normalized: NormalizedBundle): Bundle { + return new BundleBuilder(this.objectCache, this.responseCache, this.requestService, this.store, this.href, normalized).build(); + } + +} diff --git a/src/app/core/cache/models/collection-builder.ts b/src/app/core/cache/models/collection-builder.ts new file mode 100644 index 0000000000..3291d10518 --- /dev/null +++ b/src/app/core/cache/models/collection-builder.ts @@ -0,0 +1,92 @@ +import { Collection } from "../../shared/collection.model"; +import { hasValue } from "../../../shared/empty.util"; +import { Item } from "../../shared/item.model"; +import { RequestConfigureAction, RequestExecuteAction } from "../../data/request.actions"; +import { ObjectCacheService } from "../object-cache.service"; +import { ResponseCacheService } from "../response-cache.service"; +import { RequestService } from "../../data/request.service"; +import { Store } from "@ngrx/store"; +import { CoreState } from "../../core.reducers"; +import { NormalizedCollection } from "./normalized-collection.model"; +import { Request } from "../../data/request.models"; +import { ListRemoteDataBuilder, SingleRemoteDataBuilder } from "./remote-data-builder"; +import { ItemRDBuilder } from "./item-builder"; +import { NormalizedItem } from "./normalized-item.model"; + +export class CollectionBuilder { + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store, + protected href: string, + protected normalized: NormalizedCollection + ) { + } + + build(): Collection { + let links: any = {}; + + if (hasValue(this.normalized.items)) { + this.normalized.items.forEach((href: string) => { + const isCached = this.objectCache.hasBySelfLink(href); + const isPending = this.requestService.isPending(href); + + if (!(isCached || isPending)) { + const request = new Request(href, NormalizedItem); + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(href)); + } + }); + + links.items = this.normalized.items.map((href: string) => { + return new ItemRDBuilder( + this.objectCache, + this.responseCache, + this.requestService, + this.store, + href + ).build(); + }); + } + + return Object.assign(new Collection(), this.normalized, links); + } +} + +export class CollectionRDBuilder extends SingleRemoteDataBuilder { + + constructor( + objectCache: ObjectCacheService, + responseCache: ResponseCacheService, + requestService: RequestService, + store: Store, + href: string + ) { + super(objectCache, responseCache, requestService, store, href, NormalizedCollection); + } + + protected normalizedToDomain(normalized: NormalizedCollection): Collection { + return new CollectionBuilder(this.objectCache, this.responseCache, this.requestService, this.store, this.href, normalized).build(); + } + +} + +export class CollectionListRDBuilder extends ListRemoteDataBuilder { + + constructor( + objectCache: ObjectCacheService, + responseCache: ResponseCacheService, + requestService: RequestService, + store: Store, + href: string + ) { + super(objectCache, responseCache, requestService, store, href, NormalizedCollection); + } + + protected normalizedToDomain(normalized: NormalizedCollection): Collection { + return new CollectionBuilder(this.objectCache, this.responseCache, this.requestService, this.store, this.href, normalized).build(); + } + +} diff --git a/src/app/core/cache/models/item-builder.ts b/src/app/core/cache/models/item-builder.ts new file mode 100644 index 0000000000..6935741e38 --- /dev/null +++ b/src/app/core/cache/models/item-builder.ts @@ -0,0 +1,89 @@ +import { Item } from "../../shared/item.model"; +import { ObjectCacheService } from "../object-cache.service"; +import { ResponseCacheService } from "../response-cache.service"; +import { RequestService } from "../../data/request.service"; +import { Store } from "@ngrx/store"; +import { CoreState } from "../../core.reducers"; +import { NormalizedItem } from "./normalized-item.model"; +import { ListRemoteDataBuilder, SingleRemoteDataBuilder } from "./remote-data-builder"; +import { Request } from "../../data/request.models"; +import { hasValue } from "../../../shared/empty.util"; +import { RequestConfigureAction, RequestExecuteAction } from "../../data/request.actions"; +import { BundleRDBuilder } from "./bundle-builder"; +import { NormalizedBundle } from "./normalized-bundle.model"; + +export class ItemBuilder { + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store, + protected href: string, + protected normalized: NormalizedItem + ) { + } + + build(): Item { + let links: any = {}; + + if (hasValue(this.normalized.bundles)) { + this.normalized.bundles.forEach((href: string) => { + const isCached = this.objectCache.hasBySelfLink(href); + const isPending = this.requestService.isPending(href); + + if (!(isCached || isPending)) { + const request = new Request(href, NormalizedBundle); + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(href)); + } + }); + + links.bundles = this.normalized.bundles.map((href: string) => { + return new BundleRDBuilder( + this.objectCache, + this.responseCache, + this.requestService, + this.store, + href + ).build(); + }); + } + return Object.assign(new Item(), this.normalized, links); + } +} + +export class ItemRDBuilder extends SingleRemoteDataBuilder { + + constructor( + objectCache: ObjectCacheService, + responseCache: ResponseCacheService, + requestService: RequestService, + store: Store, + href: string + ) { + super(objectCache, responseCache, requestService, store, href, NormalizedItem); + } + + protected normalizedToDomain(normalized: NormalizedItem): Item { + return new ItemBuilder(this.objectCache, this.responseCache, this.requestService, this.store, this.href, normalized).build(); + } + +} + +export class ItemListRDBuilder extends ListRemoteDataBuilder { + constructor( + objectCache: ObjectCacheService, + responseCache: ResponseCacheService, + requestService: RequestService, + store: Store, + href: string + ) { + super(objectCache, responseCache, requestService, store, href, NormalizedItem); + } + + protected normalizedToDomain(normalized: NormalizedItem): Item { + return new ItemBuilder(this.objectCache, this.responseCache, this.requestService, this.store, this.href, normalized).build(); + } + +} diff --git a/src/app/core/cache/models/normalized-bitstream.model.ts b/src/app/core/cache/models/normalized-bitstream.model.ts new file mode 100644 index 0000000000..b6b1302f79 --- /dev/null +++ b/src/app/core/cache/models/normalized-bitstream.model.ts @@ -0,0 +1,36 @@ +import { inheritSerialization } from "cerialize"; +import { NormalizedDSpaceObject } from "./normalized-dspace-object.model"; + +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedBitstream extends NormalizedDSpaceObject { + + /** + * The size of this bitstream in bytes(?) + */ + size: number; + + /** + * The relative path to this Bitstream's file + */ + url: string; + + /** + * The mime type of this Bitstream + */ + mimetype: string; + + /** + * The description of this Bitstream + */ + description: string; + + /** + * An array of Bundles that are direct parents of this Bitstream + */ + parents: Array; + + /** + * The Bundle that owns this Bitstream + */ + owner: string; +} diff --git a/src/app/core/cache/models/normalized-bundle.model.ts b/src/app/core/cache/models/normalized-bundle.model.ts new file mode 100644 index 0000000000..9eed7cf033 --- /dev/null +++ b/src/app/core/cache/models/normalized-bundle.model.ts @@ -0,0 +1,24 @@ +import { autoserialize, inheritSerialization } from "cerialize"; +import { NormalizedDSpaceObject } from "./normalized-dspace-object.model"; + +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedBundle extends NormalizedDSpaceObject { + /** + * The primary bitstream of this Bundle + */ + @autoserialize + primaryBitstream: string; + + /** + * An array of Items that are direct parents of this Bundle + */ + parents: Array; + + /** + * The Item that owns this Bundle + */ + owner: string; + + @autoserialize + bitstreams: Array; +} diff --git a/src/app/core/cache/models/normalized-collection.model.ts b/src/app/core/cache/models/normalized-collection.model.ts new file mode 100644 index 0000000000..74f29150b2 --- /dev/null +++ b/src/app/core/cache/models/normalized-collection.model.ts @@ -0,0 +1,31 @@ +import { autoserialize, inheritSerialization, autoserializeAs } from "cerialize"; +import { NormalizedDSpaceObject } from "./normalized-dspace-object.model"; + +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedCollection extends NormalizedDSpaceObject { + + /** + * A string representing the unique handle of this Collection + */ + @autoserialize + handle: string; + + /** + * The Bitstream that represents the logo of this Collection + */ + logo: string; + + /** + * An array of Collections that are direct parents of this Collection + */ + parents: Array; + + /** + * The Collection that owns this Collection + */ + owner: string; + + @autoserialize + items: Array; + +} diff --git a/src/app/core/cache/models/normalized-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts new file mode 100644 index 0000000000..c3b782e6df --- /dev/null +++ b/src/app/core/cache/models/normalized-dspace-object.model.ts @@ -0,0 +1,51 @@ +import { autoserialize, autoserializeAs } from "cerialize"; +import { CacheableObject } from "../object-cache.reducer"; +import { Metadatum } from "../../shared/metadatum.model"; + +/** + * An abstract model class for a DSpaceObject. + */ +export abstract class NormalizedDSpaceObject implements CacheableObject { + + @autoserialize + self: string; + + /** + * The human-readable identifier of this DSpaceObject + */ + @autoserialize + id: string; + + /** + * The universally unique identifier of this DSpaceObject + */ + @autoserialize + uuid: string; + + /** + * A string representing the kind of DSpaceObject, e.g. community, item, … + */ + type: string; + + /** + * The name for this DSpaceObject + */ + @autoserialize + name: string; + + /** + * An array containing all metadata of this DSpaceObject + */ + @autoserializeAs(Metadatum) + metadata: Array; + + /** + * An array of DSpaceObjects that are direct parents of this DSpaceObject + */ + parents: Array; + + /** + * The DSpaceObject that owns this DSpaceObject + */ + owner: string; +} diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts new file mode 100644 index 0000000000..53f44e2eb3 --- /dev/null +++ b/src/app/core/cache/models/normalized-item.model.ts @@ -0,0 +1,40 @@ +import { inheritSerialization, autoserialize } from "cerialize"; +import { NormalizedDSpaceObject } from "./normalized-dspace-object.model"; + +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedItem extends NormalizedDSpaceObject { + + /** + * A string representing the unique handle of this Item + */ + @autoserialize + handle: string; + + /** + * The Date of the last modification of this Item + */ + lastModified: Date; + + /** + * A boolean representing if this Item is currently archived or not + */ + isArchived: boolean; + + /** + * A boolean representing if this Item is currently withdrawn or not + */ + isWithdrawn: boolean; + + /** + * An array of Collections that are direct parents of this Item + */ + parents: Array; + + /** + * The Collection that owns this Item + */ + owner: string; + + @autoserialize + bundles: Array; +} diff --git a/src/app/core/shared/pagination-options.model.ts b/src/app/core/cache/models/pagination-options.model.ts similarity index 100% rename from src/app/core/shared/pagination-options.model.ts rename to src/app/core/cache/models/pagination-options.model.ts diff --git a/src/app/core/cache/models/remote-data-builder.ts b/src/app/core/cache/models/remote-data-builder.ts new file mode 100644 index 0000000000..28ea91ac54 --- /dev/null +++ b/src/app/core/cache/models/remote-data-builder.ts @@ -0,0 +1,131 @@ +import { RemoteData } from "../../data/remote-data"; +import { Observable } from "rxjs/Observable"; +import { RequestEntry } from "../../data/request.reducer"; +import { ResponseCacheEntry } from "../response-cache.reducer"; +import { ErrorResponse, SuccessResponse } from "../response-cache.models"; +import { Store } from "@ngrx/store"; +import { CoreState } from "../../core.reducers"; +import { ResponseCacheService } from "../response-cache.service"; +import { ObjectCacheService } from "../object-cache.service"; +import { RequestService } from "../../data/request.service"; +import { CacheableObject } from "../object-cache.reducer"; +import { GenericConstructor } from "../../shared/generic-constructor"; +import { hasValue, isNotEmpty } from "../../../shared/empty.util"; + +export interface RemoteDataBuilder { + build(): RemoteData +} + +export abstract class SingleRemoteDataBuilder implements RemoteDataBuilder { + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store, + protected href: string, + protected normalizedType: GenericConstructor + ) { + } + + protected abstract normalizedToDomain(normalized: TNormalized): TDomain; + + build(): RemoteData { + const requestObs = this.store.select('core', 'data', 'request', this.href); + const responseCacheObs = this.responseCache.get(this.href); + + const requestPending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.requestPending).distinctUntilChanged(); + + const responsePending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.responsePending).distinctUntilChanged(); + + const isSuccessFul = responseCacheObs + .map((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful).distinctUntilChanged(); + + const errorMessage = responseCacheObs + .filter((entry: ResponseCacheEntry) => hasValue(entry) && !entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => ( entry.response).errorMessage) + .distinctUntilChanged(); + + const payload = + Observable.race( + this.objectCache.getBySelfLink(this.href, this.normalizedType), + responseCacheObs + .filter((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => ( entry.response).resourceUUIDs) + .flatMap((resourceUUIDs: Array) => { + if (isNotEmpty(resourceUUIDs)) { + return this.objectCache.get(resourceUUIDs[0], this.normalizedType); + } + else { + return Observable.of(undefined); + } + }) + .distinctUntilChanged() + ).map((normalized: TNormalized) => this.normalizedToDomain(normalized)); + + return new RemoteData( + this.href, + requestPending, + responsePending, + isSuccessFul, + errorMessage, + payload + ); + } + +} + +export abstract class ListRemoteDataBuilder implements RemoteDataBuilder { + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store, + protected href: string, + protected normalizedType: GenericConstructor + ) { + } + + protected abstract normalizedToDomain(normalized: TNormalized): TDomain; + + build(): RemoteData { + const requestObs = this.store.select('core', 'data', 'request', this.href); + const responseCacheObs = this.responseCache.get(this.href); + + const requestPending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.requestPending).distinctUntilChanged(); + + const responsePending = requestObs.map((entry: RequestEntry) => hasValue(entry) && entry.responsePending).distinctUntilChanged(); + + const isSuccessFul = responseCacheObs + .map((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful).distinctUntilChanged(); + + const errorMessage = responseCacheObs + .filter((entry: ResponseCacheEntry) => hasValue(entry) && !entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => ( entry.response).errorMessage) + .distinctUntilChanged(); + + const payload = responseCacheObs + .filter((entry: ResponseCacheEntry) => hasValue(entry) && entry.response.isSuccessful) + .map((entry: ResponseCacheEntry) => ( entry.response).resourceUUIDs) + .flatMap((resourceUUIDs: Array) => { + return this.objectCache.getList(resourceUUIDs, this.normalizedType) + .map((normList: TNormalized[]) => { + return normList.map((normalized: TNormalized) => { + return this.normalizedToDomain(normalized); + }); + }); + }) + .distinctUntilChanged(); + + return new RemoteData( + this.href, + requestPending, + responsePending, + isSuccessFul, + errorMessage, + payload + ); + } + +} diff --git a/src/app/core/cache/models/self-link.model.ts b/src/app/core/cache/models/self-link.model.ts new file mode 100644 index 0000000000..5adc78062a --- /dev/null +++ b/src/app/core/cache/models/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/shared/sort-options.model.ts b/src/app/core/cache/models/sort-options.model.ts similarity index 100% rename from src/app/core/shared/sort-options.model.ts rename to src/app/core/cache/models/sort-options.model.ts 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/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 9093093f50..ec0bea4a97 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -60,6 +60,11 @@ export class ObjectCacheService { .map((entry: ObjectCacheEntry) => Object.assign(new type(), entry.data)); } + getBySelfLink(href: string, type: GenericConstructor): Observable { + return this.store.select('core', 'index', 'href', href) + .flatMap((uuid: string) => this.get(uuid, type)) + } + /** * Get an observable for an array of objects of the same type * with the specified UUIDs @@ -104,6 +109,25 @@ export class ObjectCacheService { return result; } + /** + * Check whether the object with the specified self link is cached + * + * @param href + * The self link of the object to check + * @return boolean + * true if the object with the specified self link is cached, + * false otherwise + */ + hasBySelfLink(href: string): boolean { + let result: boolean = false; + + this.store.select('core', 'index', 'href', href) + .take(1) + .subscribe((uuid: string) => result = this.has(uuid)); + + return result; + } + /** * Check whether an ObjectCacheEntry should still be cached * 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..45f78f10b7 --- /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/response/ADD'), + REMOVE: type('dspace/core/cache/response/REMOVE'), + RESET_TIMESTAMPS: type('dspace/core/cache/response/RESET_TIMESTAMPS') +}; + +export class ResponseCacheAddAction implements Action { + type = ResponseCacheActionTypes.ADD; + payload: { + key: string, + response: Response + timeAdded: number; + msToLive: number; + }; + + constructor(key: string, response: Response, timeAdded: number, msToLive: number) { + this.payload = { key, response, timeAdded, msToLive }; + } +} + +/** + * An ngrx action to remove a request from the cache + */ +export class ResponseCacheRemoveAction implements Action { + type = ResponseCacheActionTypes.REMOVE; + payload: string; + + /** + * Create a new ResponseCacheRemoveAction + * @param key + * The key of the request to remove + */ + constructor(key: string) { + this.payload = key; + } +} + +/** + * An ngrx action to reset the timeAdded property of all cached objects + */ +export class ResetResponseCacheTimestampsAction implements Action { + type = ResponseCacheActionTypes.RESET_TIMESTAMPS; + payload: number; + + /** + * Create a new ResetObjectCacheTimestampsAction + * + * @param newTimestamp + * the new timeAdded all objects should get + */ + constructor(newTimestamp: number) { + this.payload = newTimestamp; + } +} + +/** + * A type to encompass all ResponseCacheActions + */ +export type ResponseCacheAction + = ResponseCacheAddAction + | ResponseCacheRemoveAction + | ResetResponseCacheTimestampsAction; 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..17d9ed1091 --- /dev/null +++ b/src/app/core/cache/response-cache.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { + ResponseCacheState, ResponseCacheEntry +} from "./response-cache.reducer"; +import { Observable } from "rxjs"; +import { hasNoValue } from "../../shared/empty.util"; +import { + ResponseCacheRemoveAction, + ResponseCacheAddAction +} from "./response-cache.actions"; +import { Response } from "./response-cache.models"; + +/** + * A service to interact with the response cache + */ +@Injectable() +export class ResponseCacheService { + constructor( + private store: Store + ) {} + + 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..827cf9deb7 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -5,9 +5,10 @@ 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"; +import { RequestService } from "./data/request.service"; const IMPORTS = [ CommonModule, @@ -27,7 +28,8 @@ const PROVIDERS = [ ItemDataService, DSpaceRESTv2Service, ObjectCacheService, - RequestCacheService + ResponseCacheService, + RequestService ]; @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 676d71659c..0000000000 --- a/src/app/core/data-services/collection-data.effects.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { 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"; - -@Injectable() -export class CollectionDataEffects extends DataEffects { - constructor( - actions$: Actions, - restApi: DSpaceRESTv2Service, - cache: ObjectCacheService, - dataService: CollectionDataService - ) { - super(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 a68f7f2899..0000000000 --- a/src/app/core/data-services/data.effects.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 { GlobalConfig } from "../../../config"; -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"; - -export abstract class DataEffects { - protected abstract getFindAllEndpoint(action: RequestCacheFindAllAction): string; - protected abstract getFindByIdEndpoint(action: RequestCacheFindByIDAction): string; - protected abstract getSerializer(): Serializer; - - constructor( - 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, GlobalConfig.cache.msToLive); - }); - }) - .map((ts: Array) => ts.map(t => t.uuid)) - .map((ids: Array) => new RequestCacheSuccessAction(action.payload.key, ids, new Date().getTime(), GlobalConfig.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, GlobalConfig.cache.msToLive); - }) - .map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], new Date().getTime(), GlobalConfig.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 d6bce6e8da..0000000000 --- a/src/app/core/data-services/item-data.effects.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { 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"; - -@Injectable() -export class ItemDataEffects extends DataEffects { - constructor( - actions$: Actions, - restApi: DSpaceRESTv2Service, - cache: ObjectCacheService, - dataService: ItemDataService - ) { - super(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..4abffa909c --- /dev/null +++ b/src/app/core/data/collection-data.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from "@angular/core"; +import { DataService } from "./data.service"; +import { Collection } from "../shared/collection.model"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { ResponseCacheService } from "../cache/response-cache.service"; +import { Store } from "@ngrx/store"; +import { NormalizedCollection } from "../cache/models/normalized-collection.model"; +import { CoreState } from "../core.reducers"; +import { RequestService } from "./request.service"; +import { CollectionListRDBuilder, CollectionRDBuilder } from "../cache/models/collection-builder"; + +@Injectable() +export class CollectionDataService extends DataService { + protected endpoint = '/collections'; + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store + ) { + super(NormalizedCollection); + } + + protected getListDataBuilder(href: string): CollectionListRDBuilder { + return new CollectionListRDBuilder ( + this.objectCache, + this.responseCache, + this.requestService, + this.store, + href, + ); + } + + protected getSingleDataBuilder(href: string): CollectionRDBuilder { + return new CollectionRDBuilder ( + this.objectCache, + this.responseCache, + this.requestService, + this.store, + href, + ); + } + +} 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..bcbd5fd599 --- /dev/null +++ b/src/app/core/data/data.service.ts @@ -0,0 +1,69 @@ +import { ObjectCacheService } from "../cache/object-cache.service"; +import { ResponseCacheService } from "../cache/response-cache.service"; +import { CacheableObject } from "../cache/object-cache.reducer"; +import { hasValue } from "../../shared/empty.util"; +import { GenericConstructor } from "../shared/generic-constructor"; +import { RemoteData } from "./remote-data"; +import { FindAllRequest, FindByIDRequest, Request } from "./request.models"; +import { Store } from "@ngrx/store"; +import { RequestConfigureAction, RequestExecuteAction } from "./request.actions"; +import { CoreState } from "../core.reducers"; +import { RemoteDataBuilder } from "../cache/models/remote-data-builder"; +import { RequestService } from "./request.service"; + +export abstract class DataService { + protected abstract objectCache: ObjectCacheService; + protected abstract responseCache: ResponseCacheService; + protected abstract requestService: RequestService; + protected abstract store: Store; + protected abstract endpoint: string; + + constructor(private normalizedResourceType: GenericConstructor) { + + } + + protected abstract getListDataBuilder(href: string): RemoteDataBuilder; + protected abstract getSingleDataBuilder(href: string): RemoteDataBuilder; + + 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); + if (!this.responseCache.has(href) && !this.requestService.isPending(href)) { + const request = new FindAllRequest(href, this.normalizedResourceType, scopeID); + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(href)); + } + return this.getListDataBuilder(href).build(); + } + + protected getFindByIDHref(resourceID): string { + return `${this.endpoint}/${resourceID}`; + } + + findById(id: string): RemoteData { + const href = this.getFindByIDHref(id); + if (!this.objectCache.hasBySelfLink(href) && !this.requestService.isPending(href)) { + const request = new FindByIDRequest(href, this.normalizedResourceType, id); + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(href)); + } + return this.getSingleDataBuilder(href).build(); + } + + findByHref(href: string): RemoteData { + if (!this.objectCache.hasBySelfLink(href) && !this.requestService.isPending(href)) { + const request = new Request(href, this.normalizedResourceType); + this.store.dispatch(new RequestConfigureAction(request)); + this.store.dispatch(new RequestExecuteAction(href)); + } + return this.getSingleDataBuilder(href).build(); + } + +} 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..777d75c53c --- /dev/null +++ b/src/app/core/data/item-data.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@angular/core"; +import { DataService } from "./data.service"; +import { Item } from "../shared/item.model"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { ResponseCacheService } from "../cache/response-cache.service"; +import { Store } from "@ngrx/store"; +import { CoreState } from "../core.reducers"; +import { NormalizedItem } from "../cache/models/normalized-item.model"; +import { RequestService } from "./request.service"; +import { ItemListRDBuilder, ItemRDBuilder } from "../cache/models/item-builder"; + +@Injectable() +export class ItemDataService extends DataService { + protected endpoint = '/items'; + + constructor( + protected objectCache: ObjectCacheService, + protected responseCache: ResponseCacheService, + protected requestService: RequestService, + protected store: Store +) { + super(NormalizedItem); + } + + protected getListDataBuilder(href: string): ItemListRDBuilder { + return new ItemListRDBuilder( + this.objectCache, + this.responseCache, + this.requestService, + this.store, + href, + ); + } + + protected getSingleDataBuilder(href: string): ItemRDBuilder { + return new ItemRDBuilder( + this.objectCache, + this.responseCache, + this.requestService, + this.store, + href, + ); + } +} 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 73% rename from src/app/core/data-services/remote-data.ts rename to src/app/core/data/remote-data.ts index 1b9ff177ef..7fa02bf25c 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, + public self: string, + 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..16ce1963bd --- /dev/null +++ b/src/app/core/data/request.actions.ts @@ -0,0 +1,59 @@ +import { Action } from "@ngrx/store"; +import { type } from "../../shared/ngrx/type"; +import { CacheableObject } from "../cache/object-cache.reducer"; +import { Request } from "./request.models"; + +/** + * The list of RequestAction type definitions + */ +export const RequestActionTypes = { + CONFIGURE: type('dspace/core/data/request/CONFIGURE'), + EXECUTE: type('dspace/core/data/request/EXECUTE'), + COMPLETE: type('dspace/core/data/request/COMPLETE') +}; + +export class RequestConfigureAction implements Action { + type = RequestActionTypes.CONFIGURE; + payload: Request; + + 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..e5d887626e --- /dev/null +++ b/src/app/core/data/request.effects.ts @@ -0,0 +1,71 @@ +import { Injectable, Inject } from "@angular/core"; +import { Actions, Effect } from "@ngrx/effects"; +import { Store } from "@ngrx/store"; +import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; +import { ObjectCacheService } from "../cache/object-cache.service"; +import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model"; +import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.serializer"; +import { CacheableObject } from "../cache/object-cache.reducer"; +import { Observable } from "rxjs"; +import { Response, SuccessResponse, ErrorResponse } from "../cache/response-cache.models"; +import { hasNoValue } from "../../shared/empty.util"; +import { GlobalConfig, GLOBAL_CONFIG } from "../../../config"; +import { RequestState, RequestEntry } from "./request.reducer"; +import { + RequestActionTypes, RequestExecuteAction, + RequestCompleteAction +} from "./request.actions"; +import { ResponseCacheService } from "../cache/response-cache.service"; +import { RequestService } from "./request.service"; + +@Injectable() +export class RequestEffects { + + constructor( + @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, + private actions$: Actions, + private restApi: DSpaceRESTv2Service, + private objectCache: ObjectCacheService, + private responseCache: ResponseCacheService, + protected requestService: RequestService, + private store: Store + ) { } + + @Effect() execute = this.actions$ + .ofType(RequestActionTypes.EXECUTE) + .flatMap((action: RequestExecuteAction) => { + return this.requestService.get(action.payload) + .take(1); + }) + .flatMap((entry: RequestEntry) => { + const [ifArray, ifNotArray] = this.restApi.get(entry.request.href) + .share() // share ensures restApi.get() doesn't get called twice when the partitions are used below + .partition((data: DSpaceRESTV2Response) => Array.isArray(data._embedded)); + + return Observable.merge( + + ifArray.map((data: DSpaceRESTV2Response) => { + return new DSpaceRESTv2Serializer(entry.request.resourceType).deserializeArray(data); + }).do((cos: CacheableObject[]) => cos.forEach((t) => this.addToObjectCache(t))) + .map((cos: Array): 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..9171bbe509 --- /dev/null +++ b/src/app/core/data/request.models.ts @@ -0,0 +1,32 @@ +import { SortOptions } from "../cache/models/sort-options.model"; +import { PaginationOptions } from "../cache/models/pagination-options.model"; +import { GenericConstructor } from "../shared/generic-constructor"; + +export class Request { + 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/data/request.service.ts b/src/app/core/data/request.service.ts new file mode 100644 index 0000000000..cd5b7b8a64 --- /dev/null +++ b/src/app/core/data/request.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from "@angular/core"; +import { RequestEntry, RequestState } from "./request.reducer"; +import { Store } from "@ngrx/store"; +import { hasValue } from "../../shared/empty.util"; +import { Observable } from "rxjs/Observable"; + +@Injectable() +export class RequestService { + + constructor(private store: Store) { + } + + isPending(href: string): boolean { + let isPending = false; + this.store.select('core', 'data', 'request', href) + .take(1) + .subscribe((re: RequestEntry) => { + isPending = (hasValue(re) && !re.completed) + }); + + return isPending; + } + + get(href: string): Observable { + return this.store.select('core', 'data', 'request', href); + } +} diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.spec.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.spec.ts index 2661b3708d..236244873c 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.spec.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.spec.ts @@ -140,19 +140,20 @@ describe("DSpaceRESTv2Serializer", () => { describe("deserializeArray", () => { - it("should turn a valid document describing a collection of objects in to an array of valid models", () => { - const serializer = new DSpaceRESTv2Serializer(TestModel); - const doc = { - "_embedded": testResponses - }; - - const models = serializer.deserializeArray(doc); - - expect(models[0].id).toBe(doc._embedded[0].id); - expect(models[0].name).toBe(doc._embedded[0].name); - expect(models[1].id).toBe(doc._embedded[1].id); - expect(models[1].name).toBe(doc._embedded[1].name); - }); + //TODO rewrite to incorporate normalisation. + // it("should turn a valid document describing a collection of objects in to an array of valid models", () => { + // const serializer = new DSpaceRESTv2Serializer(TestModel); + // const doc = { + // "_embedded": testResponses + // }; + // + // const models = serializer.deserializeArray(doc); + // + // expect(models[0].id).toBe(doc._embedded[0].id); + // expect(models[0].name).toBe(doc._embedded[0].name); + // expect(models[1].id).toBe(doc._embedded[1].id); + // expect(models[1].name).toBe(doc._embedded[1].name); + // }); //TODO cant implement/test this yet - depends on how relationships // will be handled in the rest api 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..d4d5a7ce59 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._embedded._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 linkedResource.href; + }); + } + else { + normalizedLinks[link] = normalizedLinks[link].href; + } + } + return normalizedLinks; } } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index e4261ba9f2..d8a21c6c9d 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -1,14 +1,16 @@ -import { Injectable } from '@angular/core'; +import { Inject, Injectable } from '@angular/core'; import { Http, RequestOptionsArgs } from '@angular/http'; import { Observable } from 'rxjs/Observable'; import { RESTURLCombiner } from "../url-combiner/rest-url-combiner"; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; + /** * Service to access DSpace's REST API */ @Injectable() export class DSpaceRESTv2Service { - constructor(public _http: Http) { + constructor(private http: Http, @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) { } @@ -23,7 +25,7 @@ export class DSpaceRESTv2Service { * An Observablse containing the response from the server */ get(relativeURL: string, options?: RequestOptionsArgs): Observable { - return this._http.get(new RESTURLCombiner(relativeURL).toString(), options) + return this.http.get(new RESTURLCombiner(this.EnvConfig, relativeURL).toString(), options) .map(res => res.json()) .catch(err => { console.log('Error: ', err); 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/bundle.model.ts b/src/app/core/shared/bundle.model.ts index b990c8617e..f6fd8f3820 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -2,13 +2,14 @@ import { inheritSerialization } from "cerialize"; import { DSpaceObject } from "./dspace-object.model"; import { Bitstream } from "./bitstream.model"; import { Item } from "./item.model"; +import { RemoteData } from "../data/remote-data"; @inheritSerialization(DSpaceObject) export class Bundle extends DSpaceObject { /** * The primary bitstream of this Bundle */ - primaryBitstream: Bitstream; + primaryBitstream: RemoteData; /** * An array of Items that are direct parents of this Bundle @@ -20,4 +21,6 @@ export class Bundle extends DSpaceObject { */ owner: Item; + bitstreams: Array> + } diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index 7048ded4a4..2e4056fb3e 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -1,14 +1,13 @@ -import { autoserialize, inheritSerialization } from "cerialize"; import { DSpaceObject } from "./dspace-object.model"; import { Bitstream } from "./bitstream.model"; +import { Item } from "./item.model"; +import { RemoteData } from "../data/remote-data"; -@inheritSerialization(DSpaceObject) export class Collection extends DSpaceObject { /** * A string representing the unique handle of this Collection */ - @autoserialize handle: string; /** @@ -54,7 +53,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 +65,6 @@ export class Collection extends DSpaceObject { */ owner: Collection; + items: Array>; + } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index c21a8af5f4..cb800a9681 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -8,75 +8,77 @@ import { CacheableObject } from "../cache/object-cache.reducer"; */ export abstract class DSpaceObject implements CacheableObject { - /** - * The human-readable identifier of this DSpaceObject - */ - @autoserialize - id: string; + @autoserialize + self: string; - /** - * The universally unique identifier of this DSpaceObject - */ - @autoserialize - uuid: string; + /** + * The human-readable identifier of this DSpaceObject + */ + @autoserialize + id: string; - /** - * A string representing the kind of DSpaceObject, e.g. community, item, … - */ - type: string; + /** + * The universally unique identifier of this DSpaceObject + */ + @autoserialize + uuid: string; - /** - * The name for this DSpaceObject - */ - @autoserialize - name: string; + /** + * A string representing the kind of DSpaceObject, e.g. community, item, … + */ + type: string; - /** - * An array containing all metadata of this DSpaceObject - */ - @autoserializeAs(Metadatum) - metadata: Array; + /** + * The name for this DSpaceObject + */ + @autoserialize + name: string; - /** - * An array of DSpaceObjects that are direct parents of this DSpaceObject - */ - parents: Array; + /** + * An array containing all metadata of this DSpaceObject + */ + @autoserializeAs(Metadatum) + metadata: Array; - /** - * The DSpaceObject that owns this DSpaceObject - */ - owner: DSpaceObject; + /** + * An array of DSpaceObjects that are direct parents of this DSpaceObject + */ + parents: Array; - /** - * Find a metadata field by key and language - * - * This method returns the value of the first element - * in the metadata array that matches the provided - * key and language - * - * @param key - * @param language - * @return string - */ - findMetadata(key: string, language?: string): string { - const metadatum = this.metadata - .find((metadatum: Metadatum) => { - return metadatum.key === key && - (isEmpty(language) || metadatum.language === language) - }); - if (isNotEmpty(metadatum)) { - return metadatum.value; - } - else { - return undefined; - } - } + /** + * The DSpaceObject that owns this DSpaceObject + */ + owner: DSpaceObject; - filterMetadata(keys: string[]): Array { - return this.metadata - .filter((metadatum: Metadatum) => { - return keys.some(key => key === metadatum.key); - }); - } + /** + * Find a metadata field by key and language + * + * This method returns the value of the first element + * in the metadata array that matches the provided + * key and language + * + * @param key + * @param language + * @return string + */ + findMetadata(key: string, language?: string): string { + const metadatum = this.metadata + .find((metadatum: Metadatum) => { + return metadatum.key === key && + (isEmpty(language) || metadatum.language === language) + }); + if (isNotEmpty(metadatum)) { + return metadatum.value; + } + else { + return undefined; + } + } + filterMetadata(keys: string[]): Array { + return this.metadata + .filter((metadatum: Metadatum) => { + return keys.some(key => key === metadatum.key); + }); + } } diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index bcf3ef7689..ca93ea7ad3 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,6 +1,8 @@ import { inheritSerialization, autoserialize, autoserializeAs } from "cerialize"; import { DSpaceObject } from "./dspace-object.model"; import { Collection } from "./collection.model"; +import { RemoteData } from "../data/remote-data"; +import { Bundle } from "./bundle.model"; @inheritSerialization(DSpaceObject) export class Item extends DSpaceObject { @@ -37,4 +39,6 @@ export class Item extends DSpaceObject { */ owner: Collection; + bundles: Array> + } 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/url-combiner/rest-url-combiner.ts b/src/app/core/url-combiner/rest-url-combiner.ts index c1da441631..afbd25020a 100644 --- a/src/app/core/url-combiner/rest-url-combiner.ts +++ b/src/app/core/url-combiner/rest-url-combiner.ts @@ -1,5 +1,6 @@ import { URLCombiner } from "./url-combiner"; -import { GlobalConfig } from "../../../config"; + +import { GlobalConfig } from '../../../config'; /** * Combines a variable number of strings representing parts @@ -7,8 +8,8 @@ import { GlobalConfig } from "../../../config"; * * TODO write tests once GlobalConfig becomes injectable */ -export class RESTURLCombiner extends URLCombiner{ - constructor(...parts:Array) { - super(GlobalConfig.rest.baseURL, GlobalConfig.rest.nameSpace, ...parts); +export class RESTURLCombiner extends URLCombiner { + constructor(EnvConfig: GlobalConfig, ...parts: Array) { + super(EnvConfig.rest.baseUrl, EnvConfig.rest.nameSpace, ...parts); } } 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.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: [ diff --git a/src/app/pagenotfound/pagenotfound.component.html b/src/app/pagenotfound/pagenotfound.component.html index cf6047e6df..fce500f376 100644 --- a/src/app/pagenotfound/pagenotfound.component.html +++ b/src/app/pagenotfound/pagenotfound.component.html @@ -1,9 +1,9 @@

404

{{"404.page-not-found" | translate}}

-
+

{{"404.help" | translate}}

-
+

{{"404.link.home-page" | translate}}

diff --git a/src/app/pagenotfound/pagenotfound.component.ts b/src/app/pagenotfound/pagenotfound.component.ts index f0e78be45b..e8537bc6b7 100644 --- a/src/app/pagenotfound/pagenotfound.component.ts +++ b/src/app/pagenotfound/pagenotfound.component.ts @@ -1,6 +1,5 @@ import { Component } from '@angular/core'; - @Component({ selector: 'ds-pagenotfound', styleUrls: ['./pagenotfound.component.css'], diff --git a/src/backend/bitstreams.ts b/src/backend/bitstreams.ts index 480a0b4b55..ed47d0d94a 100644 --- a/src/backend/bitstreams.ts +++ b/src/backend/bitstreams.ts @@ -1,7 +1,7 @@ export const BITSTREAMS = [ { "_links": { - "self": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" }, + "self": { "href": "/bitstreams/3678" }, "bundle": { "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" }, "retrieve": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa/retrieve" } }, @@ -22,7 +22,7 @@ export const BITSTREAMS = [ }, { "_links": { - "self": { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" }, + "self": { "href": "/bitstreams/8842" }, "bundle": { "href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd" }, "retrieve": { "href": "/rest/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632/retrieve" } }, diff --git a/src/backend/bundles.ts b/src/backend/bundles.ts index 01e8f07002..fde6409635 100644 --- a/src/backend/bundles.ts +++ b/src/backend/bundles.ts @@ -1,12 +1,12 @@ export const BUNDLES = [ { "_links": { - "self": { "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" }, + "self": { "href": "/bundles/2355" }, "items": [ { "href": "/items/8871" } ], "bitstreams": [ - { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" }, + { "href": "/bitstreams/3678" }, ], "primaryBitstream": { "href": "/bitstreams/43c57c2b-206f-4645-8c8f-5f10c84b09fa" } }, @@ -19,12 +19,12 @@ export const BUNDLES = [ }, { "_links": { - "self": { "href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd" }, + "self": { "href": "/bundles/5687" }, "items": [ { "href": "/items/8871" } ], "bitstreams": [ - { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" }, + { "href": "/bitstreams/8842" }, ], "primaryBitstream": { "href": "/bitstreams/1a013ecc-fb25-4689-a44f-f1383ad26632" } }, diff --git a/src/backend/items.ts b/src/backend/items.ts index 290e2b96aa..5578691215 100644 --- a/src/backend/items.ts +++ b/src/backend/items.ts @@ -14,10 +14,10 @@ export const ITEMS = [ ], "bundles": [ { - "href": "/bundles/35e0606d-5e18-4f9c-aa61-74fc751cc3f9" + "href": "/bundles/2355" }, { - "href": "/bundles/a469c57a-abcf-45c3-83e4-b187ebd708fd" + "href": "/bundles/5687" } ] }, @@ -106,10 +106,10 @@ export const ITEMS = [ ], "bundles": [ { - "href": "/bundles/b0176baa-d52e-4c20-a8e6-d586f2c70c76" + "href": "/bundles/2355" }, { - "href": "/bundles/40b1cd3f-07ad-4ca6-9716-132671f93a15" + "href": "/bundles/5687" } ] }, diff --git a/src/client.aot.ts b/src/client.aot.ts index a34bae2eba..f6e10de0f2 100644 --- a/src/client.aot.ts +++ b/src/client.aot.ts @@ -1,7 +1,7 @@ // the polyfills must be the first thing imported import 'angular2-universal-polyfills'; import 'ts-helpers'; -import './__workaround.browser'; // temporary until 2.1.1 things are patched in Core +import './platform/workarounds/__workaround.browser'; // temporary until 2.1.1 things are patched in Core // Angular 2 import { enableProdMode } from '@angular/core'; @@ -15,7 +15,7 @@ import { load as loadWebFont } from 'webfontloader'; // enable prod for faster renders enableProdMode(); -import { MainModuleNgFactory } from './browser.module.ngfactory'; +import { MainModuleNgFactory } from './platform/modules/browser.module.ngfactory'; export const platformRef = platformBrowser(); diff --git a/src/client.ts b/src/client.ts index 8b0a60ae1e..5f77062660 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,7 @@ // the polyfills must be the first thing imported import 'angular2-universal-polyfills'; import 'ts-helpers'; -import './__workaround.browser'; // temporary until 2.1.1 things are patched in Core +import './platform/workarounds/__workaround.browser'; // temporary until 2.1.1 things are patched in Core // Angular 2 import { enableProdMode } from '@angular/core'; @@ -10,10 +10,14 @@ import { bootloader } from '@angularclass/bootloader'; import { load as loadWebFont } from 'webfontloader'; -// enable prod for faster renders -// enableProdMode(); +import { EnvConfig } from './config'; -import { MainModule } from './browser.module'; +if (EnvConfig.production) { + // enable prod for faster renders + enableProdMode(); +} + +import { MainModule } from './platform/modules/browser.module'; export const platformRef = platformUniversalDynamic(); diff --git a/src/config.ts b/src/config.ts index e4dc549265..6610ad895c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,34 +1,78 @@ // Look in ./config folder for config +import { OpaqueToken } from '@angular/core'; -const path = require('path'); - -let configContext = require.context("../config", false, /js$/); -let EnvConfig : any = {}; -let EnvConfigFile : string; -let DefaultConfig : any = {}; - -try { - DefaultConfig = configContext('./environment.default.js'); -} catch (e) { - throw new Error(`Cannot find file "${path.resolve('config', './environment.default.js')}"`); +interface ServerConfig { + "ssl": boolean; + "address": string; + "port": number; + "nameSpace": string; + "baseUrl": string; } +interface GlobalConfig { + "production": boolean; + "rest": ServerConfig; + "ui": ServerConfig; + "cache": { + "msToLive": number, + "control": string + }; + "universal": { + "preboot": boolean, + "async": boolean + }; +} + +const GLOBAL_CONFIG = new OpaqueToken('config'); + +let configContext = require.context("../config", false, /js$/); + +let EnvConfig: GlobalConfig; +let EnvConfigFile: string; + +let production: boolean = false; + +// check process.env.NODE_ENV to determine which environment config to use +// process.env.NODE_ENV is defined by webpack, else assume development switch (process.env.NODE_ENV) { case 'prod': case 'production': + // webpack.prod.config.ts defines process.env.NODE_ENV = 'production' EnvConfigFile = './environment.prod.js'; + production = true; + break; + case 'test': + // webpack.test.config.ts defines process.env.NODE_ENV = 'test' + EnvConfigFile = './environment.test.js'; break; - case 'dev': - case 'development': default: + // if not using webpack.prod.config.ts or webpack.test.config.ts, it must be development EnvConfigFile = './environment.dev.js'; } + try { - EnvConfig = configContext(EnvConfigFile); + EnvConfig = configContext('./environment.default.js'); } catch (e) { - EnvConfig = {}; + throw new Error("Cannot find file environment.default.js"); } -const GlobalConfig = Object.assign(DefaultConfig, EnvConfig); +// if EnvConfigFile set try to get configs +if (EnvConfigFile) { + try { + EnvConfig = Object.assign(EnvConfig, configContext(EnvConfigFile)); + } catch (e) { + console.warn("Cannot find file " + EnvConfigFile.substring(2, EnvConfigFile.length), "Using default environment."); + } +} -export {GlobalConfig} +// set base url if property is object with ssl, address, and port. i.e. ServerConfig +for (let key in EnvConfig) { + if (EnvConfig[key].ssl !== undefined && EnvConfig[key].address && EnvConfig[key].port) { + EnvConfig[key].baseUrl = [EnvConfig[key].ssl ? 'https://' : 'http://', EnvConfig[key].address, (EnvConfig[key].port !== 80) ? ':' + EnvConfig[key].port : ''].join(''); + } +} + +// set config for whether running in production +EnvConfig.production = production; + +export { GLOBAL_CONFIG, GlobalConfig, EnvConfig } diff --git a/src/angular2-meta.ts b/src/platform/angular2-meta.ts similarity index 100% rename from src/angular2-meta.ts rename to src/platform/angular2-meta.ts diff --git a/src/browser.module.ts b/src/platform/modules/browser.module.ts similarity index 79% rename from src/browser.module.ts rename to src/platform/modules/browser.module.ts index 2c77fc2e2e..b3d809e852 100755 --- a/src/browser.module.ts +++ b/src/platform/modules/browser.module.ts @@ -1,4 +1,4 @@ -import { NgModule } from '@angular/core'; +import { Inject, NgModule } from '@angular/core'; import { Http } from '@angular/http'; import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; @@ -8,21 +8,22 @@ import { IdlePreload, IdlePreloadModule } from '@angularclass/idle-preload'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateLoader, TranslateModule, TranslateStaticLoader } from 'ng2-translate'; -import { AppModule, AppComponent } from './app/app.module'; -import { SharedModule } from './app/shared/shared.module'; -import { CoreModule } from "./app/core/core.module"; +import { AppModule, AppComponent } from '../../app/app.module'; +import { SharedModule } from '../../app/shared/shared.module'; +import { CoreModule } from '../../app/core/core.module'; import { StoreModule, Store } from "@ngrx/store"; import { RouterStoreModule } from "@ngrx/router-store"; import { StoreDevtoolsModule } from "@ngrx/store-devtools"; -import { rootReducer, NGRX_CACHE_KEY, AppState } from './app/app.reducers'; -import { effects } from './app/app.effects'; +import { rootReducer, NGRX_CACHE_KEY, AppState } from '../../app/app.reducers'; +import { effects } from '../../app/app.effects'; // Will be merged into @angular/platform-browser in a later release // see https://github.com/angular/angular/pull/12322 -import { Meta } from './angular2-meta'; -import { RehydrateStoreAction } from "./app/store.actions"; -import { GlobalConfig } from "./config"; +import { Meta } from '../angular2-meta'; +import { RehydrateStoreAction } from "../../app/store.actions"; + +import { GLOBAL_CONFIG, GlobalConfig, EnvConfig } from '../../config'; // import * as LRU from 'modern-lru'; @@ -44,7 +45,6 @@ export function getResponse() { return {}; } - export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; @NgModule({ @@ -72,6 +72,8 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; effects ], providers: [ + { provide: GLOBAL_CONFIG, useValue: EnvConfig }, + { provide: 'isBrowser', useValue: isBrowser }, { provide: 'isNode', useValue: isNode }, @@ -80,21 +82,21 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; { provide: 'LRU', useFactory: getLRU, deps: [] }, - Meta, + Meta // { provide: AUTO_PREBOOT, useValue: false } // turn off auto preboot complete ] }) export class MainModule { - constructor(public store: Store) { + constructor( @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, public store: Store) { // TODO(gdi2290): refactor into a lifecycle hook this.doRehydrate(); } doRehydrate() { - if (GlobalConfig.universal.shouldRehydrate) { - let defaultValue = {}; - let serverCache = this._getCacheValue(NGRX_CACHE_KEY, defaultValue); + let defaultValue = {}; + let serverCache = this._getCacheValue(NGRX_CACHE_KEY, defaultValue); + if (this.EnvConfig.universal.preboot) { this.store.dispatch(new RehydrateStoreAction(serverCache)); } } diff --git a/src/node.module.ts b/src/platform/modules/node.module.ts similarity index 83% rename from src/node.module.ts rename to src/platform/modules/node.module.ts index 8420a76a96..f8772ad3a3 100755 --- a/src/node.module.ts +++ b/src/platform/modules/node.module.ts @@ -7,19 +7,20 @@ import { UniversalModule, isBrowser, isNode } from 'angular2-universal/node'; // import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateLoader, TranslateModule, TranslateStaticLoader } from 'ng2-translate'; -import { AppModule, AppComponent } from './app/app.module'; -import { SharedModule } from './app/shared/shared.module'; -import { CoreModule } from "./app/core/core.module"; +import { AppModule, AppComponent } from '../../app/app.module'; +import { SharedModule } from '../../app/shared/shared.module'; +import { CoreModule } from "../../app/core/core.module"; import { StoreModule, Store } from "@ngrx/store"; import { RouterStoreModule } from "@ngrx/router-store"; -import { StoreDevtoolsModule } from "@ngrx/store-devtools"; -import { rootReducer, AppState, NGRX_CACHE_KEY } from './app/app.reducers'; -import { effects } from './app/app.effects'; +import { rootReducer, AppState, NGRX_CACHE_KEY } from '../../app/app.reducers'; +import { effects } from '../../app/app.effects'; // Will be merged into @angular/platform-browser in a later release // see https://github.com/angular/angular/pull/12322 -import { Meta } from './angular2-meta'; +import { Meta } from '../angular2-meta'; + +import { GLOBAL_CONFIG, EnvConfig } from '../../config'; export function createTranslateLoader(http: Http) { return new TranslateStaticLoader(http, './assets/i18n', '.json'); @@ -57,10 +58,11 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; AppModule, StoreModule.provideStore(rootReducer), RouterStoreModule.connectRouter(), - StoreDevtoolsModule.instrumentOnlyWithExtension(), effects ], providers: [ + { provide: GLOBAL_CONFIG, useValue: EnvConfig }, + { provide: 'isBrowser', useValue: isBrowser }, { provide: 'isNode', useValue: isNode }, @@ -69,7 +71,8 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; { provide: 'LRU', useFactory: getLRU, deps: [] }, - Meta, + Meta + ] }) export class MainModule { diff --git a/src/__workaround.browser.ts b/src/platform/workarounds/__workaround.browser.ts similarity index 100% rename from src/__workaround.browser.ts rename to src/platform/workarounds/__workaround.browser.ts diff --git a/src/__workaround.node.ts b/src/platform/workarounds/__workaround.node.ts similarity index 100% rename from src/__workaround.node.ts rename to src/platform/workarounds/__workaround.node.ts diff --git a/src/server.aot.ts b/src/server.aot.ts index b8d09218fc..4d41fb9738 100644 --- a/src/server.aot.ts +++ b/src/server.aot.ts @@ -4,7 +4,7 @@ // if you are including modules that modify Promise, such as NewRelic,, you must include them before polyfills import 'angular2-universal-polyfills'; import 'ts-helpers'; -import './__workaround.node'; // temporary until 2.1.1 things are patched in Core +import './platform/workarounds/__workaround.node'; // temporary until 2.1.1 things are patched in Core import * as fs from 'fs'; import * as path from 'path'; @@ -20,11 +20,13 @@ import { enableProdMode } from '@angular/core'; import { createEngine } from 'angular2-express-engine'; // App -import { MainModuleNgFactory } from './node.module.ngfactory'; +import { MainModuleNgFactory } from './platform/modules/node.module.ngfactory'; // Routes import { routes } from './server.routes'; +import { EnvConfig } from './config'; + // enable prod for faster renders enableProdMode(); @@ -42,8 +44,9 @@ app.engine('.html', createEngine({ // stateless providers only since it's shared ] })); -app.set('port', process.env.PORT || 3000); -app.set('address', process.env.ADDRESS || '127.0.0.1'); + +app.set('port', process.env.PORT || EnvConfig.ui.port || 3000); +app.set('address', process.env.ADDRESS || EnvConfig.ui.address || '127.0.0.1'); app.set('views', __dirname); app.set('view engine', 'html'); app.set('json spaces', 2); @@ -61,16 +64,16 @@ app.use(morgan('common', { function cacheControl(req, res, next) { // instruct browser to revalidate in 60 seconds - res.header('Cache-Control', 'max-age=60'); + res.header('Cache-Control', EnvConfig.cache.control || 'max-age=60'); next(); } + // Serve static files app.use('/assets', cacheControl, express.static(path.join(__dirname, 'assets'), { maxAge: 30 })); app.use('/styles', cacheControl, express.static(path.join(__dirname, 'styles'), { maxAge: 30 })); app.use(cacheControl, express.static(path.join(ROOT, 'dist/client'), { index: false })); -// ///////////////////////// // ** Example API // Notice API should be in a separate process @@ -93,11 +96,11 @@ function ngApp(req, res) { res, // use this to determine what part of your app is slow only in development // time: true, - async: true, - preboot: true, - baseUrl: '/', + async: EnvConfig.universal.async, + preboot: EnvConfig.universal.preboot, + baseUrl: EnvConfig.ui.nameSpace, requestUrl: req.originalUrl, - originUrl: `http://${app.get('address')}:${app.get('port')}` + originUrl: EnvConfig.ui.baseUrl }); }); @@ -112,7 +115,6 @@ routes.forEach(route => { app.get(`/${route}/*`, ngApp); }); - app.get('*', function(req, res) { res.setHeader('Content-Type', 'application/json'); var pojo = { status: 404, message: 'No Content' }; @@ -121,6 +123,6 @@ app.get('*', function(req, res) { }); // Server -let server = app.listen(app.get('port'), () => { - console.log(`Listening on: http://${server.address().address}:${server.address().port}`); +let server = app.listen(app.get('port'), app.get('address'), () => { + console.log(`[${new Date().toTimeString()}] Listening on ${EnvConfig.ui.ssl ? 'https://' : 'http://'}${server.address().address}:${server.address().port}`); }); diff --git a/src/server.ts b/src/server.ts index 359aefce85..13837821d0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ // if you are including modules that modify Promise, such as NewRelic,, you must include them before polyfills import 'angular2-universal-polyfills'; import 'ts-helpers'; -import './__workaround.node'; // temporary until 2.1.1 things are patched in Core +import './platform/workarounds/__workaround.node'; // temporary until 2.1.1 things are patched in Core import * as path from 'path'; import * as morgan from 'morgan'; @@ -19,13 +19,17 @@ import { enableProdMode } from '@angular/core'; import { createEngine } from 'angular2-express-engine'; // App -import { MainModule } from './node.module'; +import { MainModule } from './platform/modules/node.module'; // Routes import { routes } from './server.routes'; -// enable prod for faster renders -enableProdMode(); +import { EnvConfig } from './config'; + +if (EnvConfig.production) { + // enable prod for faster renders + enableProdMode(); +} const app = express(); const ROOT = path.join(path.resolve(__dirname, '..')); @@ -40,8 +44,9 @@ app.engine('.html', createEngine({ // stateless providers only since it's shared ] })); -app.set('port', process.env.PORT || 3000); -app.set('address', process.env.ADDRESS || '127.0.0.1'); + +app.set('port', process.env.PORT || EnvConfig.ui.port || 3000); +app.set('address', process.env.ADDRESS || EnvConfig.ui.address || '127.0.0.1'); app.set('views', __dirname); app.set('view engine', 'html'); app.set('json spaces', 2); @@ -54,16 +59,16 @@ app.use(morgan('dev')); function cacheControl(req, res, next) { // instruct browser to revalidate in 60 seconds - res.header('Cache-Control', 'max-age=60'); + res.header('Cache-Control', EnvConfig.cache.control || 'max-age=60'); next(); } + // Serve static files app.use('/assets', cacheControl, express.static(path.join(__dirname, 'assets'), { maxAge: 30 })); app.use('/styles', cacheControl, express.static(path.join(__dirname, 'styles'), { maxAge: 30 })); app.use(cacheControl, express.static(path.join(ROOT, 'dist/client'), { index: false })); -// ///////////////////////// // ** Example API // Notice API should be in aseparate process @@ -85,11 +90,11 @@ function ngApp(req, res) { req, res, // time: true, // use this to determine what part of your app is slow only in development - async: true, - preboot: true, - baseUrl: '/', + async: EnvConfig.universal.async, + preboot: EnvConfig.universal.preboot, + baseUrl: EnvConfig.ui.nameSpace, requestUrl: req.originalUrl, - originUrl: `http://${app.get('address')}:${app.get('port')}` + originUrl: EnvConfig.ui.baseUrl }); }); @@ -112,6 +117,6 @@ app.get('*', function(req, res) { }); // Server -let server = app.listen(app.get('port'), () => { - console.log(`Listening on: http://${server.address().address}:${server.address().port}`); +let server = app.listen(app.get('port'), app.get('address'), () => { + console.log(`[${new Date().toTimeString()}] Listening on ${EnvConfig.ui.ssl ? 'https://' : 'http://'}${server.address().address}:${server.address().port}`); }); diff --git a/webpack.config.ts b/webpack.config.ts index 065ac1cf6e..c51f1d2ce1 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -94,7 +94,6 @@ export var clientConfig = { } }; - // Server. export var serverPlugins = [ @@ -133,9 +132,6 @@ export default [ webpackMerge(clone(commonConfig), serverConfig, { plugins: serverPlugins.concat(commonPlugins) }) ]; - - - // Helpers export function includeClientPackages(packages, localModule?: string[]) { return function(context, request, cb) { diff --git a/yarn.lock b/yarn.lock index 0b1345e294..eac42106d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1455,6 +1455,10 @@ deep-extend@~0.4.0: version "0.4.1" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253" +deep-freeze-strict@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0" + deep-freeze@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/deep-freeze/-/deep-freeze-0.0.1.tgz#3a0b0005de18672819dfd38cd31f91179c893e84" @@ -2104,15 +2108,12 @@ fs-extra@^0.26.4: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^0.30.0: - version "0.30.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.30.0.tgz#f233ffcc08d4da7d432daa449776989db1df93f0" +fs-extra@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-2.1.2.tgz#046c70163cef9aad46b0e4a7fa467fb22d71de35" dependencies: graceful-fs "^4.1.2" jsonfile "^2.1.0" - klaw "^1.0.0" - path-is-absolute "^1.0.0" - rimraf "^2.2.8" fs-extra@~1.0.0: version "1.0.0" @@ -3659,6 +3660,12 @@ ng2-translate@4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/ng2-translate/-/ng2-translate-4.2.0.tgz#83bc8feca329b5fc56a636e36073241c6280c659" +ngrx-store-freeze@^0.1.9: + version "0.1.9" + resolved "https://registry.yarnpkg.com/ngrx-store-freeze/-/ngrx-store-freeze-0.1.9.tgz#b20f18f21fd5efc4e1b1e05f6f279674d0f70c81" + dependencies: + deep-freeze-strict "^1.1.1" + no-case@^2.2.0: version "2.3.1" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.1.tgz#7aeba1c73a52184265554b7dc03baf720df80081" @@ -5654,13 +5661,13 @@ typedarray@~0.0.5: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -typedoc-default-themes@^0.4.0: - version "0.4.2" - resolved "https://registry.yarnpkg.com/typedoc-default-themes/-/typedoc-default-themes-0.4.2.tgz#640b854fd7ef19e6774496ea7741ec31a0dcaddc" +typedoc-default-themes@^0.4.2: + version "0.4.3" + resolved "https://registry.yarnpkg.com/typedoc-default-themes/-/typedoc-default-themes-0.4.3.tgz#39014c515585f27e59773d29e8921a5b8b89d4c0" -typedoc@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.5.1.tgz#150f92bdc4a36d8e114b27f1d296329d27e837dd" +typedoc@0.5.7: + version "0.5.7" + resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.5.7.tgz#f2998dbb5909cb3f02db5fecb85e2a4377189e2a" dependencies: "@types/fs-extra" "0.0.33" "@types/handlebars" "^4.0.31" @@ -5669,7 +5676,7 @@ typedoc@0.5.1: "@types/marked" "0.0.28" "@types/minimatch" "^2.0.29" "@types/shelljs" "^0.3.32" - fs-extra "^0.30.0" + fs-extra "^2.0.0" handlebars "4.0.5" highlight.js "^9.0.0" lodash "^4.13.1" @@ -5677,16 +5684,16 @@ typedoc@0.5.1: minimatch "^3.0.0" progress "^1.1.8" shelljs "^0.7.0" - typedoc-default-themes "^0.4.0" - typescript "2.0.6" + typedoc-default-themes "^0.4.2" + typescript "2.1.6" typescript@2.0.10, typescript@~2.0.3: version "2.0.10" resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.0.10.tgz#ccdd4ed86fd5550a407101a0814012e1b3fac3dd" -typescript@2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.0.6.tgz#5385499ac9811508c2c43e0ea07a1ddca435e111" +typescript@2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.1.6.tgz#40c7e6e9e5da7961b7718b55505f9cac9487a607" uglify-js@2.7.x, uglify-js@^2.6, uglify-js@^2.6.1, uglify-js@~2.7.3: version "2.7.5"