diff --git a/config/environment.default.js b/config/environment.default.js
index 9ec5c05a64..4f3aee5f0e 100644
--- a/config/environment.default.js
+++ b/config/environment.default.js
@@ -22,6 +22,17 @@ module.exports = {
// msToLive: 1000, // 15 minutes
control: 'max-age=60' // revalidate browser
},
+ // Notifications
+ notifications: {
+ rtl: false,
+ position: ['top', 'right'],
+ maxStack: 8,
+ // NOTE: after how many seconds notification is closed automatically. If set to zero notifications are not closed automatically
+ timeOut: 5000, // 5 second
+ clickToClose: true,
+ // NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale'
+ animate: 'scale'
+ },
// Angular Universal settings
universal: {
preboot: true,
diff --git a/docs/Configuration.md b/docs/Configuration.md
index 712b1523a0..b2ec81533d 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -1,5 +1,60 @@
# Configuration
+Default configuration file is located in `config/` folder. All configuration options should be listed in the default configuration file `config/environment.default.js`. Please do not change this file directly! 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;
+
+Some few configuration options can be overridden by setting environment variables. These and the variable names are listed below.
+
+## Nodejs server
+When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itsself to, and if ssl should be enabled not. By default it listens on `localhost:3000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`.
+
+To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above):
+```
+module.exports = {
+ // Angular Universal server settings.
+ ui: {
+ ssl: false,
+ host: 'localhost',
+ port: 3000,
+ nameSpace: '/'
+ }
+};
+```
+
+Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
+```
+ DSPACE_SSL=true
+ DSPACE_HOST=localhost
+ DSPACE_PORT=3000
+ DSPACE_NAMESPACE=/
+```
+
+## DSpace's REST endpoint
+dspace-angular connects to your DSpace installation by using its REST endpoint. To do so, you have to define the ip address, port and if ssl should be enabled. You can do this in a configuration file (see above) by adding the following options:
+
+```
+module.exports = {
+ // The REST API server settings.
+ rest: {
+ ssl: true,
+ host: 'dspace7.4science.it',
+ port: 443,
+ // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
+ nameSpace: '/dspace-spring-rest/api'
+ }
+};
+```
+
+Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
+```
+ DSPACE_REST_SSL=true
+ DSPACE_REST_HOST=localhost
+ DSPACE_REST_PORT=3000
+ DSPACE_REST_NAMESPACE=/
+```
+
## Supporting analytics services other than Google Analytics
This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics.
diff --git a/src/app/+home-page/home-page.component.ts b/src/app/+home-page/home-page.component.ts
index ad25ec0155..902a0e820d 100644
--- a/src/app/+home-page/home-page.component.ts
+++ b/src/app/+home-page/home-page.component.ts
@@ -6,5 +6,4 @@ import { Component } from '@angular/core';
templateUrl: './home-page.component.html'
})
export class HomePageComponent {
-
}
diff --git a/src/app/+home-page/home-page.module.ts b/src/app/+home-page/home-page.module.ts
index 0a513260cd..c0c082b36c 100644
--- a/src/app/+home-page/home-page.module.ts
+++ b/src/app/+home-page/home-page.module.ts
@@ -16,7 +16,7 @@ import { TopLevelCommunityListComponent } from './top-level-community-list/top-l
declarations: [
HomePageComponent,
TopLevelCommunityListComponent,
- HomeNewsComponent
+ HomeNewsComponent,
]
})
export class HomePageModule {
diff --git a/src/app/app.component.html b/src/app/app.component.html
index fd1ad55d44..d806bb8323 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -1,11 +1,17 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index c1c84d6dbc..a367aaed40 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,11 +1,4 @@
-import {
- ChangeDetectionStrategy,
- Component,
- HostListener,
- Inject,
- OnInit,
- ViewEncapsulation
-} from '@angular/core';
+import { ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, ViewEncapsulation } from '@angular/core';
import { Store } from '@ngrx/store';
diff --git a/src/app/app.effects.ts b/src/app/app.effects.ts
index 7fc42da80d..6a53d7b619 100644
--- a/src/app/app.effects.ts
+++ b/src/app/app.effects.ts
@@ -1,8 +1,10 @@
import { HeaderEffects } from './header/header.effects';
import { StoreEffects } from './store.effects';
+import { NotificationsEffects } from './shared/notifications/notifications.effects';
export const appEffects = [
StoreEffects,
- HeaderEffects
+ HeaderEffects,
+ NotificationsEffects
];
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index d2b0d72b78..786ee4ebbf 100755
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -1,7 +1,6 @@
import { APP_BASE_HREF, CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
-import { BrowserTransferStateModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
@@ -29,6 +28,8 @@ import { HeaderComponent } from './header/header.component';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer';
+import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component';
+import { NotificationComponent } from './shared/notifications/notification/notification.component';
export function getConfig() {
return ENV_CONFIG;
@@ -85,7 +86,9 @@ if (!ENV_CONFIG.production) {
AppComponent,
HeaderComponent,
FooterComponent,
- PageNotFoundComponent
+ PageNotFoundComponent,
+ NotificationComponent,
+ NotificationsBoardComponent
],
exports: [AppComponent]
})
diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts
index 117710e3c0..2bf5ad30d4 100644
--- a/src/app/app.reducer.ts
+++ b/src/app/app.reducer.ts
@@ -1,32 +1,35 @@
-import { ActionReducerMap } from '@ngrx/store';
-import * as fromRouter from '@ngrx/router-store';
-
-import { headerReducer, HeaderState } from './header/header.reducer';
-import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer';
-import {
- SearchSidebarState,
- sidebarReducer
-} from './+search-page/search-sidebar/search-sidebar.reducer';
-import {
- filterReducer,
- SearchFiltersState
-} from './+search-page/search-filters/search-filter/search-filter.reducer';
-import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer';
-
-export interface AppState {
- router: fromRouter.RouterReducerState;
- hostWindow: HostWindowState;
- header: HeaderState;
- searchSidebar: SearchSidebarState;
- searchFilter: SearchFiltersState;
- truncatable: TruncatablesState;
-}
-
-export const appReducers: ActionReducerMap = {
- router: fromRouter.routerReducer,
- hostWindow: hostWindowReducer,
- header: headerReducer,
- searchSidebar: sidebarReducer,
- searchFilter: filterReducer,
- truncatable: truncatableReducer
-};
+import { ActionReducerMap } from '@ngrx/store';
+import * as fromRouter from '@ngrx/router-store';
+
+import { headerReducer, HeaderState } from './header/header.reducer';
+import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer';
+import {
+ SearchSidebarState,
+ sidebarReducer
+} from './+search-page/search-sidebar/search-sidebar.reducer';
+import {
+ filterReducer,
+ SearchFiltersState
+} from './+search-page/search-filters/search-filter/search-filter.reducer';
+import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers';
+import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer';
+
+export interface AppState {
+ router: fromRouter.RouterReducerState;
+ hostWindow: HostWindowState;
+ header: HeaderState;
+ notifications: NotificationsState;
+ searchSidebar: SearchSidebarState;
+ searchFilter: SearchFiltersState;
+ truncatable: TruncatablesState;
+}
+
+export const appReducers: ActionReducerMap = {
+ router: fromRouter.routerReducer,
+ hostWindow: hostWindowReducer,
+ header: headerReducer,
+ notifications: notificationsReducer,
+ searchSidebar: sidebarReducer,
+ searchFilter: filterReducer,
+ truncatable: truncatableReducer
+};
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index 4e99bcdeeb..2891d8cb77 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -49,6 +49,7 @@ import { RegistryMetadataschemasResponseParsingService } from './data/registry-m
import { MetadataschemaParsingService } from './data/metadataschema-parsing.service';
import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service';
import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service';
+import { NotificationsService } from '../shared/notifications/notifications.service';
const IMPORTS = [
CommonModule,
@@ -99,6 +100,7 @@ const PROVIDERS = [
SubmissionFormsConfigService,
SubmissionSectionsConfigService,
UUIDService,
+ NotificationsService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory }
];
diff --git a/src/app/shared/animations/fade.ts b/src/app/shared/animations/fade.ts
index 09a0be66ba..187a482746 100644
--- a/src/app/shared/animations/fade.ts
+++ b/src/app/shared/animations/fade.ts
@@ -1,14 +1,24 @@
-import { animate, style, transition, trigger } from '@angular/animations';
+import { animate, state, style, transition, trigger } from '@angular/animations';
+export const fadeInState = state('fadeIn', style({opacity: 1}));
+export const fadeInEnter = transition('* => fadeIn', [
+ style({ opacity: 0 }),
+ animate(300, style({ opacity: 1 }))
+]);
const fadeEnter = transition(':enter', [
style({ opacity: 0 }),
animate(300, style({ opacity: 1 }))
]);
-const fadeLeave = transition(':leave', [
+export const fadeOutState = state('fadeOut', style({opacity: 0}));
+export const fadeOutLeave = transition('fadeIn => fadeOut', [
style({ opacity: 1 }),
animate(400, style({ opacity: 0 }))
]);
+const fadeLeave = transition(':leave', [
+ style({ opacity: 0 }),
+ animate(300, style({ opacity: 1 }))
+]);
export const fadeIn = trigger('fadeIn', [
fadeEnter
diff --git a/src/app/shared/animations/fromBottom.ts b/src/app/shared/animations/fromBottom.ts
new file mode 100644
index 0000000000..e2c6f44728
--- /dev/null
+++ b/src/app/shared/animations/fromBottom.ts
@@ -0,0 +1,26 @@
+import { animate, state, style, transition, trigger } from '@angular/animations';
+
+export const fromBottomInState = state('fromBottomIn', style({opacity: 1, transform: 'translateY(0)'}));
+export const fromBottomEnter = transition('* => fromBottomIn', [
+ style({opacity: 0, transform: 'translateY(5%)'}),
+ animate('400ms ease-in-out')
+]);
+
+export const fromBottomOutState = state('fromBottomOut', style({opacity: 0, transform: 'translateY(-5%)'}));
+export const fromBottomLeave = transition('fromBottomIn => fromBottomOut', [
+ style({opacity: 1, transform: 'translateY(0)'}),
+ animate('300ms ease-in-out')
+]);
+
+export const fromBottomIn = trigger('fromBottomIn', [
+ fromBottomEnter
+]);
+
+export const fromBottomOut = trigger('fromBottomOut', [
+ fromBottomLeave
+]);
+
+export const fromBottomInOut = trigger('fromBottomInOut', [
+ fromBottomEnter,
+ fromBottomLeave
+]);
diff --git a/src/app/shared/animations/fromLeft.ts b/src/app/shared/animations/fromLeft.ts
new file mode 100644
index 0000000000..07fe5bcde5
--- /dev/null
+++ b/src/app/shared/animations/fromLeft.ts
@@ -0,0 +1,26 @@
+import { animate, state, style, transition, trigger } from '@angular/animations';
+
+export const fromLeftInState = state('fromLeftIn', style({opacity: 1, transform: 'translateX(0)'}));
+export const fromLeftEnter = transition('* => fromLeftIn', [
+ style({opacity: 0, transform: 'translateX(-5%)'}),
+ animate('400ms ease-in-out')
+]);
+
+export const fromLeftOutState = state('fromLeftOut', style({opacity: 0, transform: 'translateX(5%)'}));
+export const fromLeftLeave = transition('fromLeftIn => fromLeftOut', [
+ style({opacity: 1, transform: 'translateX(0)'}),
+ animate('300ms ease-in-out')
+]);
+
+export const fromLeftIn = trigger('fromLeftIn', [
+ fromLeftEnter
+]);
+
+export const fromLeftOut = trigger('fromLeftOut', [
+ fromLeftLeave
+]);
+
+export const fromLeftInOut = trigger('fromLeftInOut', [
+ fromLeftEnter,
+ fromLeftLeave
+]);
diff --git a/src/app/shared/animations/fromRight.ts b/src/app/shared/animations/fromRight.ts
new file mode 100644
index 0000000000..10b36d12ec
--- /dev/null
+++ b/src/app/shared/animations/fromRight.ts
@@ -0,0 +1,26 @@
+import { animate, state, style, transition, trigger } from '@angular/animations';
+
+export const fromRightInState = state('fromRightIn', style({opacity: 1, transform: 'translateX(0)'}));
+export const fromRightEnter = transition('* => fromRightIn', [
+ style({opacity: 0, transform: 'translateX(5%)'}),
+ animate('400ms ease-in-out')
+]);
+
+export const fromRightOutState = state('fromRightOut', style({opacity: 0, transform: 'translateX(-5%)'}));
+export const fromRightLeave = transition('fromRightIn => fromRightOut', [
+ style({opacity: 1, transform: 'translateX(0)'}),
+ animate('300ms ease-in-out')
+]);
+
+export const fromRightIn = trigger('fromRightIn', [
+ fromRightEnter
+]);
+
+export const fromRightOut = trigger('fromRightOut', [
+ fromRightLeave
+]);
+
+export const fromRightInOut = trigger('fromRightInOut', [
+ fromRightEnter,
+ fromRightLeave
+]);
diff --git a/src/app/shared/animations/fromTop.ts b/src/app/shared/animations/fromTop.ts
new file mode 100644
index 0000000000..a33beed163
--- /dev/null
+++ b/src/app/shared/animations/fromTop.ts
@@ -0,0 +1,26 @@
+import { animate, state, style, transition, trigger } from '@angular/animations';
+
+export const fromTopInState = state('fromTopIn', style({opacity: 1, transform: 'translateY(0)'}));
+export const fromTopEnter = transition('* => fromTopIn', [
+ style({opacity: 0, transform: 'translateY(-5%)'}),
+ animate('400ms ease-in-out')
+]);
+
+export const fromTopOutState = state('fromTopOut', style({opacity: 0, transform: 'translateY(5%)'}));
+export const fromTopLeave = transition('fromTopIn => fromTopOut', [
+ style({opacity: 1, transform: 'translateY(0)'}),
+ animate('300ms ease-in-out')
+]);
+
+export const fromTopIn = trigger('fromTopIn', [
+ fromTopEnter
+]);
+
+export const fromTopOut = trigger('fromTopOut', [
+ fromTopLeave
+]);
+
+export const fromTopInOut = trigger('fromTopInOut', [
+ fromTopEnter,
+ fromTopLeave
+]);
diff --git a/src/app/shared/animations/rotate.ts b/src/app/shared/animations/rotate.ts
new file mode 100644
index 0000000000..00f8b01452
--- /dev/null
+++ b/src/app/shared/animations/rotate.ts
@@ -0,0 +1,26 @@
+import { animate, state, style, transition, trigger } from '@angular/animations';
+
+export const rotateInState = state('rotateIn', style({opacity: 1, transform: 'rotate(0deg)'}));
+export const rotateEnter = transition('* => rotateIn', [
+ style({opacity: 0, transform: 'rotate(5deg)'}),
+ animate('400ms ease-in-out')
+]);
+
+export const rotateOutState = state('rotateOut', style({opacity: 0, transform: 'rotate(5deg)'}));
+export const rotateLeave = transition('rotateIn => rotateOut', [
+ style({opacity: 1, transform: 'rotate(0deg)'}),
+ animate('400ms ease-in-out')
+]);
+
+export const rotateIn = trigger('rotateIn', [
+ rotateEnter
+]);
+
+export const rotateOut = trigger('rotateOut', [
+ rotateLeave
+]);
+
+export const rotateInOut = trigger('rotateInOut', [
+ rotateEnter,
+ rotateLeave
+]);
diff --git a/src/app/shared/animations/scale.ts b/src/app/shared/animations/scale.ts
new file mode 100644
index 0000000000..ca749ceeef
--- /dev/null
+++ b/src/app/shared/animations/scale.ts
@@ -0,0 +1,26 @@
+import { animate, state, style, transition, trigger } from '@angular/animations';
+
+export const scaleInState = state('scaleIn', style({opacity: 1, transform: 'scale(1)'}));
+export const scaleEnter = transition('* => scaleIn', [
+ style({opacity: 0, transform: 'scale(0)'}),
+ animate('400ms ease-in-out')
+]);
+
+export const scaleOutState = state('scaleOut', style({opacity: 0, transform: 'scale(0)'}));
+export const scaleLeave = transition('scaleIn => scaleOut', [
+ style({opacity: 1, transform: 'scale(1)'}),
+ animate('400ms ease-in-out')
+]);
+
+export const scaleIn = trigger('scaleIn', [
+ scaleEnter
+]);
+
+export const scaleOut = trigger('scaleOut', [
+ scaleLeave
+]);
+
+export const scaleInOut = trigger('scaleInOut', [
+ scaleEnter,
+ scaleLeave
+]);
diff --git a/src/app/shared/mocks/mock-admin-guard.service.ts b/src/app/shared/mocks/mock-admin-guard.service.ts
new file mode 100644
index 0000000000..fad2412cdc
--- /dev/null
+++ b/src/app/shared/mocks/mock-admin-guard.service.ts
@@ -0,0 +1,23 @@
+import { Injectable } from '@angular/core';
+import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild } from '@angular/router';
+
+import { hasValue } from '../empty.util';
+
+@Injectable()
+export class MockAdminGuard implements CanActivate, CanActivateChild {
+
+ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
+ // if being run in browser, enforce 'isAdmin' requirement
+ if (typeof window === 'object' && hasValue(window.localStorage)) {
+ if (window.localStorage.getItem('isAdmin') === 'true') {
+ return true;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
+ return this.canActivate(route, state);
+ }
+}
diff --git a/src/app/shared/notifications/models/notification-animations-type.ts b/src/app/shared/notifications/models/notification-animations-type.ts
new file mode 100644
index 0000000000..6654dccfe3
--- /dev/null
+++ b/src/app/shared/notifications/models/notification-animations-type.ts
@@ -0,0 +1,16 @@
+// 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale'
+
+export enum NotificationAnimationsType {
+ Fade = 'fade',
+ FromTop = 'fromTop',
+ FromRight = 'fromRight',
+ FromBottom = 'fromBottom',
+ FromLeft = 'fromLeft',
+ Rotate = 'rotate',
+ Scale = 'scale'
+}
+
+export enum NotificationAnimationsStatus {
+ In = 'In',
+ Out = 'Out'
+}
diff --git a/src/app/shared/notifications/models/notification-options.model.ts b/src/app/shared/notifications/models/notification-options.model.ts
new file mode 100644
index 0000000000..807ca1e963
--- /dev/null
+++ b/src/app/shared/notifications/models/notification-options.model.ts
@@ -0,0 +1,22 @@
+import { NotificationAnimationsType } from './notification-animations-type';
+
+export interface INotificationOptions {
+ timeOut: number;
+ clickToClose: boolean;
+ animate: NotificationAnimationsType;
+}
+
+export class NotificationOptions implements INotificationOptions {
+ public timeOut: number;
+ public clickToClose: boolean;
+ public animate: any;
+
+ constructor(timeOut = 5000,
+ clickToClose = true,
+ animate = NotificationAnimationsType.Scale) {
+
+ this.timeOut = timeOut;
+ this.clickToClose = clickToClose;
+ this.animate = animate;
+ }
+}
diff --git a/src/app/shared/notifications/models/notification-type.ts b/src/app/shared/notifications/models/notification-type.ts
new file mode 100644
index 0000000000..8ef5d790b5
--- /dev/null
+++ b/src/app/shared/notifications/models/notification-type.ts
@@ -0,0 +1,7 @@
+export enum NotificationType {
+ Success = 'alert-success',
+ Error = 'alert-danger',
+ Info = 'alert-info',
+ Warning = 'alert-warning',
+ // Bare = 'bare'
+}
diff --git a/src/app/shared/notifications/models/notification.model.ts b/src/app/shared/notifications/models/notification.model.ts
new file mode 100644
index 0000000000..3c7c54e156
--- /dev/null
+++ b/src/app/shared/notifications/models/notification.model.ts
@@ -0,0 +1,38 @@
+import { INotificationOptions, NotificationOptions } from './notification-options.model';
+import { NotificationType } from './notification-type';
+import { isEmpty } from '../../empty.util';
+import { Observable } from 'rxjs/Observable';
+
+export interface INotification {
+ id: string;
+ type: NotificationType;
+ title?: Observable | string;
+ content?: Observable | string;
+ options?: INotificationOptions;
+ html?: boolean;
+}
+
+export class Notification implements INotification {
+ public id: string;
+ public type: NotificationType;
+ public title: Observable | string;
+ public content: Observable | string;
+ public options: INotificationOptions;
+ public html: boolean;
+
+ constructor(id: string,
+ type: NotificationType,
+ title?: Observable | string,
+ content?: Observable | string,
+ options?: NotificationOptions,
+ html?: boolean) {
+
+ this.id = id;
+ this.type = type;
+ this.title = title;
+ this.content = content;
+ this.options = isEmpty(options) ? new NotificationOptions() : options;
+ this.html = html;
+ }
+
+}
diff --git a/src/app/shared/notifications/notification/notification.component.html b/src/app/shared/notifications/notification/notification.component.html
new file mode 100644
index 0000000000..561e10263c
--- /dev/null
+++ b/src/app/shared/notifications/notification/notification.component.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+ ×
+
+
+
+
+
+
+
+
+
+
+
+
+ {{(title | async)}}
+
+
+
+
+
+
+
+
+
+ {{(content | async)}}
+
+
+
+
+
+
+
+
+
diff --git a/src/app/shared/notifications/notification/notification.component.scss b/src/app/shared/notifications/notification/notification.component.scss
new file mode 100644
index 0000000000..c433cd1e4d
--- /dev/null
+++ b/src/app/shared/notifications/notification/notification.component.scss
@@ -0,0 +1,28 @@
+@import '../../../../styles/variables.scss';
+
+.close {
+ outline: none !important
+}
+
+.notification-icon {
+ min-width: 3rem;
+}
+
+.notification-progress-loader {
+ top: -1px;
+ left: 0;
+ height: 1px;
+}
+
+.alert-success .notification-progress-loader span {
+ background: darken(adjust-hue(map-get($theme-colors, success), -10), 10%);
+}
+.alert-danger .notification-progress-loader span {
+ background: darken(adjust-hue(map-get($theme-colors, danger), -10), 10%);
+}
+.alert-info .notification-progress-loader span {
+ background: darken(adjust-hue(map-get($theme-colors, info), -10), 10%);
+}
+.alert-warning .notification-progress-loader span {
+ background: darken(adjust-hue(map-get($theme-colors, warning), -10), 10%);
+}
diff --git a/src/app/shared/notifications/notification/notification.component.spec.ts b/src/app/shared/notifications/notification/notification.component.spec.ts
new file mode 100644
index 0000000000..600615fc39
--- /dev/null
+++ b/src/app/shared/notifications/notification/notification.component.spec.ts
@@ -0,0 +1,119 @@
+import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
+import { BrowserModule, By } from '@angular/platform-browser';
+import { ChangeDetectorRef, DebugElement } from '@angular/core';
+
+import { NotificationComponent } from './notification.component';
+import { NotificationsService } from '../notifications.service';
+import { NotificationType } from '../models/notification-type';
+import { notificationsReducer } from '../notifications.reducers';
+import { Store, StoreModule } from '@ngrx/store';
+import { NotificationOptions } from '../models/notification-options.model';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { Router } from '@angular/router';
+import { NotificationsServiceStub } from '../../testing/notifications-service-stub';
+import { AppState } from '../../../app.reducer';
+import { Observable } from 'rxjs/Observable';
+import { SearchPageComponent } from '../../../+search-page/search-page.component';
+import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
+import { GlobalConfig } from '../../../../config/global-config.interface';
+import { Notification } from '../models/notification.model';
+
+describe('NotificationComponent', () => {
+
+ let comp: NotificationComponent;
+ let fixture: ComponentFixture;
+ let deTitle: DebugElement;
+ let elTitle: HTMLElement;
+ let deContent: DebugElement;
+ let elContent: HTMLElement;
+ let elType: HTMLElement;
+
+ beforeEach(async(() => {
+ const store: Store = jasmine.createSpyObj('store', {
+ /* tslint:disable:no-empty */
+ notifications: []
+ });
+ const envConfig: GlobalConfig = {
+ notifications: {
+ rtl: false,
+ position: ['top', 'right'],
+ maxStack: 8,
+ timeOut: 5000,
+ clickToClose: true,
+ animate: 'scale'
+ }as INotificationBoardOptions,
+ } as any;
+ const service = new NotificationsService(envConfig, store);
+
+ TestBed.configureTestingModule({
+ imports: [
+ BrowserModule,
+ BrowserAnimationsModule,
+ StoreModule.forRoot({notificationsReducer})],
+ declarations: [NotificationComponent], // declare the test component
+ providers: [
+ { provide: NotificationsService, useValue: service },
+ ChangeDetectorRef]
+ }).compileComponents(); // compile template and css
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NotificationComponent);
+ comp = fixture.componentInstance;
+ comp.notification = {
+ id: '1',
+ type: NotificationType.Info,
+ title: 'Notif. title',
+ content: 'Notif. content',
+ options: new NotificationOptions()
+ };
+
+ fixture.detectChanges();
+
+ deTitle = fixture.debugElement.query(By.css('.notification-title'));
+ elTitle = deTitle.nativeElement;
+ deContent = fixture.debugElement.query(By.css('.notification-content'));
+ elContent = deContent.nativeElement;
+ elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
+ });
+
+ it('should create component', () => {
+ expect(comp).toBeTruthy();
+ });
+
+ it('should set Title', () => {
+ fixture.detectChanges();
+ expect(elTitle.textContent).toBe(comp.notification.title as string);
+ });
+
+ it('should set Content', () => {
+ fixture.detectChanges();
+ expect(elContent.textContent).toBe(comp.notification.content as string);
+ });
+
+ it('should set type', () => {
+ fixture.detectChanges();
+ expect(elType).toBeDefined();
+ });
+
+ it('shuld has html content', () => {
+ fixture = TestBed.createComponent(NotificationComponent);
+ comp = fixture.componentInstance;
+ const htmlContent = `test `
+ comp.notification = {
+ id: '1',
+ type: NotificationType.Info,
+ title: 'Notif. title',
+ content: htmlContent,
+ options: new NotificationOptions(),
+ html: true
+ };
+
+ fixture.detectChanges();
+
+ deContent = fixture.debugElement.query(By.css('.notification-html'));
+ elContent = deContent.nativeElement;
+ expect(elContent.innerHTML).toEqual(htmlContent);
+ })
+
+});
diff --git a/src/app/shared/notifications/notification/notification.component.ts b/src/app/shared/notifications/notification/notification.component.ts
new file mode 100644
index 0000000000..d80ec87750
--- /dev/null
+++ b/src/app/shared/notifications/notification/notification.component.ts
@@ -0,0 +1,155 @@
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ Input,
+ NgZone,
+ OnDestroy,
+ OnInit,
+ TemplateRef,
+ ViewEncapsulation
+} from '@angular/core';
+import { trigger } from '@angular/animations';
+import { DomSanitizer } from '@angular/platform-browser';
+import { NotificationsService } from '../notifications.service';
+import { INotification } from '../models/notification.model';
+import { scaleEnter, scaleInState, scaleLeave, scaleOutState } from '../../animations/scale';
+import { rotateEnter, rotateInState, rotateLeave, rotateOutState } from '../../animations/rotate';
+import { fromBottomEnter, fromBottomInState, fromBottomLeave, fromBottomOutState } from '../../animations/fromBottom';
+import { fromRightEnter, fromRightInState, fromRightLeave, fromRightOutState } from '../../animations/fromRight';
+import { fromLeftEnter, fromLeftInState, fromLeftLeave, fromLeftOutState } from '../../animations/fromLeft';
+import { fromTopEnter, fromTopInState, fromTopLeave, fromTopOutState } from '../../animations/fromTop';
+import { fadeInEnter, fadeInState, fadeOutLeave, fadeOutState } from '../../animations/fade';
+import { NotificationAnimationsStatus } from '../models/notification-animations-type';
+import { Observable } from 'rxjs/Observable';
+import { isNotEmpty } from '../../empty.util';
+
+@Component({
+ selector: 'ds-notification',
+ encapsulation: ViewEncapsulation.None,
+ animations: [
+ trigger('enterLeave', [
+ fadeInEnter, fadeInState, fadeOutLeave, fadeOutState,
+ fromBottomEnter, fromBottomInState, fromBottomLeave, fromBottomOutState,
+ fromRightEnter, fromRightInState, fromRightLeave, fromRightOutState,
+ fromLeftEnter, fromLeftInState, fromLeftLeave, fromLeftOutState,
+ fromTopEnter, fromTopInState, fromTopLeave, fromTopOutState,
+ rotateInState, rotateEnter, rotateOutState, rotateLeave,
+ scaleInState, scaleEnter, scaleOutState, scaleLeave
+ ])
+ ],
+ templateUrl: './notification.component.html',
+ styleUrls: ['./notification.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+
+export class NotificationComponent implements OnInit, OnDestroy {
+
+ @Input() public notification: INotification;
+
+ // Progress bar variables
+ public title: Observable;
+ public content: Observable;
+ public html: any;
+ public showProgressBar = false;
+ public titleIsTemplate = false;
+ public contentIsTemplate = false;
+ public htmlIsTemplate = false;
+
+ public progressWidth = 0;
+
+ private stopTime = false;
+ private timer: any;
+ private steps: number;
+ private speed: number;
+ private count = 0;
+ private start: any;
+ private diff: any;
+ public animate: string;
+
+ constructor(private notificationService: NotificationsService,
+ private domSanitizer: DomSanitizer,
+ private cdr: ChangeDetectorRef,
+ private zone: NgZone) {
+ }
+
+ ngOnInit(): void {
+ this.animate = this.notification.options.animate + NotificationAnimationsStatus.In;
+
+ if (this.notification.options.timeOut !== 0) {
+ this.startTimeOut();
+ this.showProgressBar = true;
+ }
+ this.html = this.notification.html;
+ this.contentType(this.notification.title, 'title');
+ this.contentType(this.notification.content, 'content');
+ }
+
+ private startTimeOut(): void {
+ this.steps = this.notification.options.timeOut / 10;
+ this.speed = this.notification.options.timeOut / this.steps;
+ this.start = new Date().getTime();
+ this.zone.runOutsideAngular(() => this.timer = setTimeout(this.instance, this.speed));
+ }
+
+ ngOnDestroy(): void {
+ clearTimeout(this.timer);
+ }
+
+ private instance = () => {
+ this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
+
+ if (this.count++ === this.steps) {
+ this.remove();
+ // this.item.timeoutEnd!.emit();
+ } else if (!this.stopTime) {
+ if (this.showProgressBar) {
+ this.progressWidth += 100 / this.steps;
+ }
+
+ this.timer = setTimeout(this.instance, (this.speed - this.diff));
+ }
+ this.zone.run(() => this.cdr.detectChanges());
+ };
+
+ private remove() {
+ if (this.animate) {
+ this.setAnimationOut();
+ setTimeout(() => {
+ this.notificationService.remove(this.notification);
+ }, 1000);
+ } else {
+ this.notificationService.remove(this.notification);
+ }
+ }
+
+ private contentType(item: any, key: string) {
+ if (item instanceof TemplateRef) {
+ this[key] = item;
+ } else if (key === 'title' || (key === 'content' && !this.html)) {
+ let value = null;
+ if (isNotEmpty(item)) {
+ if (typeof item === 'string') {
+ value = Observable.of(item);
+ } else if (item instanceof Observable) {
+ value = item;
+ } else if (typeof item === 'object' && isNotEmpty(item.value)) {
+ // when notifications state is transferred from SSR to CSR,
+ // Observables Object loses the instance type and become simply object,
+ // so converts it again to Observable
+ value = Observable.of(item.value);
+ }
+ }
+ this[key] = value
+ } else {
+ this[key] = this.domSanitizer.bypassSecurityTrustHtml(item);
+ }
+
+ this[key + 'IsTemplate'] = item instanceof TemplateRef;
+ }
+
+ private setAnimationOut() {
+ this.animate = this.notification.options.animate + NotificationAnimationsStatus.Out;
+ this.cdr.detectChanges();
+ }
+}
diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.html b/src/app/shared/notifications/notifications-board/notifications-board.component.html
new file mode 100644
index 0000000000..d660444632
--- /dev/null
+++ b/src/app/shared/notifications/notifications-board/notifications-board.component.html
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.scss b/src/app/shared/notifications/notifications-board/notifications-board.component.scss
new file mode 100644
index 0000000000..0dd1584c4e
--- /dev/null
+++ b/src/app/shared/notifications/notifications-board/notifications-board.component.scss
@@ -0,0 +1,45 @@
+@import '../../../../styles/variables';
+@import '../../../../styles/mixins';
+
+.notifications-wrapper {
+ width: 300px;
+ z-index: 1051;
+}
+
+.notifications-wrapper.left {
+ left: 20px;
+}
+
+.notifications-wrapper.top {
+ top: 20px;
+}
+
+.notifications-wrapper.right {
+ right: 20px;
+}
+
+.notifications-wrapper.bottom {
+ bottom: 20px;
+}
+
+.notifications-wrapper.center {
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.notifications-wrapper.middle {
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.notifications-wrapper.middle.center {
+ transform: translate(-50%, -50%);
+}
+
+@media screen and (max-width: map-get($grid-breakpoints, sm)) {
+ .notifications-wrapper {
+ width: auto;
+ left: 20px;
+ right: 20px;
+ }
+}
diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts b/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts
new file mode 100644
index 0000000000..558ca9a067
--- /dev/null
+++ b/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts
@@ -0,0 +1,68 @@
+import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
+import { BrowserModule } from '@angular/platform-browser';
+import { ChangeDetectorRef } from '@angular/core';
+
+import { NotificationsService } from '../notifications.service';
+import { notificationsReducer } from '../notifications.reducers';
+import { Store, StoreModule } from '@ngrx/store';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { NotificationsBoardComponent } from './notifications-board.component';
+import { AppState } from '../../../app.reducer';
+import { NotificationComponent } from '../notification/notification.component';
+import { Notification } from '../models/notification.model';
+import { NotificationType } from '../models/notification-type';
+import { uniqueId } from 'lodash';
+import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
+import { NotificationsServiceStub } from '../../testing/notifications-service-stub';
+
+describe('NotificationsBoardComponent', () => {
+ let comp: NotificationsBoardComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ BrowserModule,
+ BrowserAnimationsModule,
+ StoreModule.forRoot({notificationsReducer})],
+ declarations: [NotificationsBoardComponent, NotificationComponent], // declare the test component
+ providers: [
+ { provide: NotificationsService, useClass: NotificationsServiceStub },
+ ChangeDetectorRef]
+ }).compileComponents(); // compile template and css
+ }));
+
+ beforeEach(inject([NotificationsService, Store], (service: NotificationsService, store: Store) => {
+ store
+ .subscribe((state) => {
+ const notifications = [
+ new Notification(uniqueId(), NotificationType.Success, 'title1', 'content1'),
+ new Notification(uniqueId(), NotificationType.Info, 'title2', 'content2')
+ ];
+ state.notifications = notifications;
+ });
+
+ fixture = TestBed.createComponent(NotificationsBoardComponent);
+ comp = fixture.componentInstance;
+ comp.options = {
+ rtl: false,
+ position: ['top', 'right'],
+ maxStack: 5,
+ timeOut: 5000,
+ clickToClose: true,
+ animate: 'scale'
+ } as INotificationBoardOptions;
+
+ fixture.detectChanges();
+ }));
+
+ it('should create component', () => {
+ expect(comp).toBeTruthy();
+ });
+
+ it('should have two notifications', () => {
+ expect(comp.notifications.length).toBe(2);
+ });
+
+})
+;
diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.ts b/src/app/shared/notifications/notifications-board/notifications-board.component.ts
new file mode 100644
index 0000000000..59c9f04dbc
--- /dev/null
+++ b/src/app/shared/notifications/notifications-board/notifications-board.component.ts
@@ -0,0 +1,138 @@
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ Input,
+ OnDestroy,
+ OnInit,
+ ViewEncapsulation
+} from '@angular/core';
+
+import { Store } from '@ngrx/store';
+import { Subscription } from 'rxjs/Subscription';
+import { difference } from 'lodash';
+
+import { NotificationsService } from '../notifications.service';
+import { AppState } from '../../../app.reducer';
+import { notificationsStateSelector } from '../selectors';
+import { INotification } from '../models/notification.model';
+import { NotificationsState } from '../notifications.reducers';
+import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
+
+@Component({
+ selector: 'ds-notifications-board',
+ encapsulation: ViewEncapsulation.None,
+ templateUrl: './notifications-board.component.html',
+ styleUrls: ['./notifications-board.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class NotificationsBoardComponent implements OnInit, OnDestroy {
+
+ @Input()
+ set options(opt: INotificationBoardOptions) {
+ this.attachChanges(opt);
+ }
+
+ public notifications: INotification[] = [];
+ public position: ['top' | 'bottom' | 'middle', 'right' | 'left' | 'center'] = ['bottom', 'right'];
+
+ // Received values
+ private maxStack = 8;
+ private sub: Subscription;
+
+ // Sent values
+ public rtl = false;
+ public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight';
+
+ constructor(private service: NotificationsService,
+ private store: Store,
+ private cdr: ChangeDetectorRef) {
+ }
+
+ ngOnInit(): void {
+ this.sub = this.store.select(notificationsStateSelector)
+ .subscribe((state: NotificationsState) => {
+ if (state.length === 0) {
+ this.notifications = [];
+ } else if (state.length > this.notifications.length) {
+ // Add
+ const newElem = difference(state, this.notifications);
+ newElem.forEach((notification) => {
+ this.add(notification);
+ });
+ } else {
+ // Remove
+ const delElem = difference(this.notifications, state);
+ delElem.forEach((notification) => {
+ this.notifications = this.notifications.filter((item: INotification) => item.id !== notification.id);
+
+ });
+ }
+ this.cdr.detectChanges();
+ });
+ }
+
+ // Add the new notification to the notification array
+ add(item: INotification): void {
+ const toBlock: boolean = this.block(item);
+ if (!toBlock) {
+ if (this.notifications.length >= this.maxStack) {
+ this.notifications.splice(this.notifications.length - 1, 1);
+ }
+ this.notifications.splice(0, 0, item);
+ } else {
+ // Remove the notification from the store
+ // This notification was in the store, but not in this.notifications
+ // because it was a blocked duplicate
+ this.service.remove(item);
+ }
+ }
+
+ private block(item: INotification): boolean {
+ const toCheck = item.html ? this.checkHtml : this.checkStandard;
+ this.notifications.forEach((notification) => {
+ if (toCheck(notification, item)) {
+ return true;
+ }
+ });
+
+ if (this.notifications.length > 0) {
+ this.notifications.forEach((notification) => {
+ if (toCheck(notification, item)) {
+ return true;
+ }
+ });
+ }
+
+ let comp: INotification;
+ if (this.notifications.length > 0) {
+ comp = this.notifications[0];
+ } else {
+ return false;
+ }
+ return toCheck(comp, item);
+ }
+
+ private checkStandard(checker: INotification, item: INotification): boolean {
+ return checker.type === item.type && checker.title === item.title && checker.content === item.content;
+ }
+
+ private checkHtml(checker: INotification, item: INotification): boolean {
+ return checker.html ? checker.type === item.type && checker.title === item.title && checker.content === item.content && checker.html === item.html : false;
+ }
+
+ // Attach all the changes received in the options object
+ private attachChanges(options: any): void {
+ Object.keys(options).forEach((a) => {
+ if (this.hasOwnProperty(a)) {
+ (this as any)[a] = options[a];
+ }
+ });
+ }
+
+ ngOnDestroy(): void {
+ if (this.sub) {
+ this.sub.unsubscribe();
+ }
+ }
+}
diff --git a/src/app/shared/notifications/notifications.actions.ts b/src/app/shared/notifications/notifications.actions.ts
new file mode 100644
index 0000000000..4678ce412f
--- /dev/null
+++ b/src/app/shared/notifications/notifications.actions.ts
@@ -0,0 +1,61 @@
+import { Action } from '@ngrx/store';
+import { type } from '../../shared/ngrx/type';
+import { INotification } from './models/notification.model';
+
+export const NotificationsActionTypes = {
+ NEW_NOTIFICATION: type('dspace/notifications/NEW_NOTIFICATION'),
+ REMOVE_ALL_NOTIFICATIONS: type('dspace/notifications/REMOVE_ALL_NOTIFICATIONS'),
+ REMOVE_NOTIFICATION: type('dspace/notifications/REMOVE_NOTIFICATION'),
+};
+
+/* tslint:disable:max-classes-per-file */
+
+/**
+ * New notification.
+ * @class NewNotificationAction
+ * @implements {Action}
+ */
+export class NewNotificationAction implements Action {
+ public type: string = NotificationsActionTypes.NEW_NOTIFICATION;
+ payload: INotification;
+
+ constructor(notification: INotification) {
+ this.payload = notification;
+ }
+}
+
+/**
+ * Remove all notifications.
+ * @class RemoveAllNotificationsAction
+ * @implements {Action}
+ */
+export class RemoveAllNotificationsAction implements Action {
+ public type: string = NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS;
+
+ constructor(public payload?: any) { }
+}
+
+/**
+ * Remove a notification.
+ * @class RemoveNotificationAction
+ * @implements {Action}
+ */
+export class RemoveNotificationAction implements Action {
+ public type: string = NotificationsActionTypes.REMOVE_NOTIFICATION;
+ payload: any;
+
+ constructor(notificationId: any) {
+ this.payload = notificationId;
+ }
+}
+
+/* tslint:enable:max-classes-per-file */
+
+/**
+ * Actions type.
+ * @type {NotificationsActions}
+ */
+export type NotificationsActions
+ = NewNotificationAction
+ | RemoveAllNotificationsAction
+ | RemoveNotificationAction;
diff --git a/src/app/shared/notifications/notifications.effects.ts b/src/app/shared/notifications/notifications.effects.ts
new file mode 100644
index 0000000000..f2627f1806
--- /dev/null
+++ b/src/app/shared/notifications/notifications.effects.ts
@@ -0,0 +1,32 @@
+import { Injectable } from '@angular/core';
+import { Actions } from '@ngrx/effects';
+import { Store } from '@ngrx/store';
+import { AppState } from '../../app.reducer';
+
+@Injectable()
+export class NotificationsEffects {
+
+ /**
+ * Authenticate user.
+ * @method authenticate
+ */
+ /* @Effect()
+ public timer: Observable = this.actions$
+ .ofType(NotificationsActionTypes.NEW_NOTIFICATION_WITH_TIMER)
+ // .debounceTime((action: any) => action.payload.options.timeOut)
+ .debounceTime(3000)
+ .map(() => new RemoveNotificationAction());
+ .switchMap((action: NewNotificationWithTimerAction) => Observable
+ .timer(30000)
+ .mapTo(() => new RemoveNotificationAction())
+ );*/
+
+ /**
+ * @constructor
+ * @param {Actions} actions$
+ * @param {Store} store
+ */
+ constructor(private actions$: Actions,
+ private store: Store) {
+ }
+}
diff --git a/src/app/shared/notifications/notifications.reducers.spec.ts b/src/app/shared/notifications/notifications.reducers.spec.ts
new file mode 100644
index 0000000000..b54072925a
--- /dev/null
+++ b/src/app/shared/notifications/notifications.reducers.spec.ts
@@ -0,0 +1,140 @@
+import { notificationsReducer } from './notifications.reducers';
+import { NewNotificationAction, RemoveAllNotificationsAction, RemoveNotificationAction } from './notifications.actions';
+import { NotificationsService } from './notifications.service';
+import { fakeAsync, flush, inject, TestBed, tick } from '@angular/core/testing';
+import { NotificationsBoardComponent } from './notifications-board/notifications-board.component';
+import { StoreModule } from '@ngrx/store';
+import { NotificationComponent } from './notification/notification.component';
+import { NotificationOptions } from './models/notification-options.model';
+import { NotificationAnimationsType } from './models/notification-animations-type';
+import { NotificationType } from './models/notification-type';
+import { Notification } from './models/notification.model';
+import { uniqueId } from 'lodash';
+import { ChangeDetectorRef } from '@angular/core';
+
+describe('Notifications reducer', () => {
+
+ let notification1;
+ let notification2;
+ let notification3;
+ let notificationHtml;
+ let options;
+ let html;
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ declarations: [NotificationComponent, NotificationsBoardComponent],
+ providers: [NotificationsService],
+ imports: [
+ ChangeDetectorRef,
+ StoreModule.forRoot({notificationsReducer}),
+ ]
+ });
+
+ options = new NotificationOptions(
+ 0,
+ true,
+ NotificationAnimationsType.Rotate);
+ notification1 = new Notification(uniqueId(), NotificationType.Success, 'title1', 'content1', options, null);
+ notification2 = new Notification(uniqueId(), NotificationType.Info, 'title2', 'content2', options, null);
+ notification3 = new Notification(uniqueId(), NotificationType.Warning, 'title3', 'content3', options, null);
+ html = 'I\'m a mock test
';
+ notificationHtml = new Notification(uniqueId(), NotificationType.Error, null, null, options, html);
+ });
+
+ it('should add 4 notifications and verify fields and length', () => {
+ const state1 = notificationsReducer(undefined, new NewNotificationAction(notification1));
+ const n1 = state1[0];
+ expect(n1.title).toBe('title1');
+ expect(n1.content).toBe('content1');
+ expect(n1.type).toBe(NotificationType.Success);
+ expect(n1.options).toBe(options);
+ expect(n1.html).toBeNull();
+ expect(state1.length).toEqual(1);
+
+ const state2 = notificationsReducer(state1, new NewNotificationAction(notification2));
+ const n2 = state2[1];
+ expect(n2.title).toBe('title2');
+ expect(n2.content).toBe('content2');
+ expect(n2.type).toBe(NotificationType.Info);
+ expect(n2.options).toBe(options);
+ expect(n2.html).toBeNull();
+ expect(state2.length).toEqual(2);
+
+ const state3 = notificationsReducer(state2, new NewNotificationAction(notification3));
+ const n3 = state3[2];
+ expect(n3.title).toBe('title3');
+ expect(n3.content).toBe('content3');
+ expect(n3.type).toBe(NotificationType.Warning);
+ expect(n3.options).toBe(options);
+ expect(n3.html).toBeNull();
+ expect(state3.length).toEqual(3);
+
+ const state4 = notificationsReducer(state3, new NewNotificationAction(notificationHtml));
+ const n4 = state4[3];
+ expect(n4.title).toBeNull();
+ expect(n4.content).toBeNull();
+ expect(n4.type).toBe(NotificationType.Error);
+ expect(n4.options).toBe(options);
+ expect(n4.html).toBe(html);
+ expect(state4.length).toEqual(4);
+ });
+
+ it('should add 2 notifications and remove only the first', () => {
+ const state1 = notificationsReducer(undefined, new NewNotificationAction(notification1));
+ expect(state1.length).toEqual(1);
+
+ const state2 = notificationsReducer(state1, new NewNotificationAction(notification2));
+ expect(state2.length).toEqual(2);
+
+ const state3 = notificationsReducer(state2, new RemoveNotificationAction(notification1.id));
+ expect(state3.length).toEqual(1);
+
+ });
+
+ it('should add 2 notifications and later remove all', () => {
+ const state1 = notificationsReducer(undefined, new NewNotificationAction(notification1));
+ expect(state1.length).toEqual(1);
+
+ const state2 = notificationsReducer(state1, new NewNotificationAction(notification2));
+ expect(state2.length).toEqual(2);
+
+ const state3 = notificationsReducer(state2, new RemoveAllNotificationsAction());
+ expect(state3.length).toEqual(0);
+ });
+
+ it('should create 2 notifications and check they close after different timeout', fakeAsync(() => {
+ inject([ChangeDetectorRef], (cdr: ChangeDetectorRef) => {
+ const optionsWithTimeout = new NotificationOptions(
+ 1000,
+ true,
+ NotificationAnimationsType.Rotate);
+ // Timeout 1000ms
+ const notification = new Notification(uniqueId(), NotificationType.Success, 'title', 'content', optionsWithTimeout, null);
+ const state = notificationsReducer(undefined, new NewNotificationAction(notification));
+ expect(state.length).toEqual(1);
+
+ // Timeout default 5000ms
+ const notificationBis = new Notification(uniqueId(), NotificationType.Success, 'title', 'content');
+ const stateBis = notificationsReducer(state, new NewNotificationAction(notification));
+ expect(stateBis.length).toEqual(2);
+
+ tick(1000);
+ cdr.detectChanges();
+
+ const action = new NewNotificationAction(notification);
+ action.type = 'NothingToDo, return only the state';
+
+ const lastState = notificationsReducer(stateBis, action);
+ expect(lastState.length).toEqual(1);
+
+ flush();
+ cdr.detectChanges();
+
+ const finalState = notificationsReducer(lastState, action);
+ expect(finalState.length).toEqual(0);
+ });
+
+ }));
+
+});
diff --git a/src/app/shared/notifications/notifications.reducers.ts b/src/app/shared/notifications/notifications.reducers.ts
new file mode 100644
index 0000000000..2dfd8f239a
--- /dev/null
+++ b/src/app/shared/notifications/notifications.reducers.ts
@@ -0,0 +1,43 @@
+import { NotificationsActions, NotificationsActionTypes, RemoveNotificationAction } from './notifications.actions';
+import { INotification } from './models/notification.model';
+
+/**
+ * The auth state.
+ * @interface State
+ */
+export interface NotificationsState extends Array {
+
+}
+
+/**
+ * The initial state.
+ */
+const initialState: NotificationsState = [];
+
+/**
+ * The reducer function.
+ * @function reducer
+ * @param {State} state Current state
+ * @param {NotificationsActions} action Incoming action
+ */
+export function notificationsReducer(state: any = initialState, action: NotificationsActions): NotificationsState {
+
+ switch (action.type) {
+ case NotificationsActionTypes.NEW_NOTIFICATION:
+ return [...state, action.payload];
+
+ case NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS:
+ return [];
+
+ case NotificationsActionTypes.REMOVE_NOTIFICATION:
+ return removeNotification(state, action as RemoveNotificationAction);
+
+ default:
+ return state;
+ }
+}
+
+const removeNotification = (state: NotificationsState, action: RemoveNotificationAction): NotificationsState => {
+ const newState = state.filter((item: INotification) => item.id !== action.payload);
+ return newState;
+};
diff --git a/src/app/shared/notifications/notifications.service.spec.ts b/src/app/shared/notifications/notifications.service.spec.ts
new file mode 100644
index 0000000000..e5af2860a1
--- /dev/null
+++ b/src/app/shared/notifications/notifications.service.spec.ts
@@ -0,0 +1,80 @@
+import { TestBed } from '@angular/core/testing';
+import { NotificationsService } from './notifications.service';
+import { NotificationsBoardComponent } from './notifications-board/notifications-board.component';
+import { NotificationComponent } from './notification/notification.component';
+import { Store, StoreModule } from '@ngrx/store';
+import { notificationsReducer } from './notifications.reducers';
+import { Observable } from 'rxjs/Observable';
+import 'rxjs/add/observable/of';
+import { NewNotificationAction, RemoveAllNotificationsAction, RemoveNotificationAction } from './notifications.actions';
+import { Notification } from './models/notification.model';
+import { NotificationType } from './models/notification-type';
+import { GlobalConfig } from '../../../config/global-config.interface';
+
+describe('NotificationsService test', () => {
+ const store: Store = jasmine.createSpyObj('store', {
+ dispatch: {},
+ select: Observable.of(true)
+ });
+ let service;
+ let envConfig: GlobalConfig;
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ declarations: [NotificationComponent, NotificationsBoardComponent],
+ providers: [NotificationsService],
+ imports: [
+ StoreModule.forRoot({notificationsReducer}),
+ ]
+ });
+
+ envConfig = {
+ notifications: {
+ rtl: false,
+ position: ['top', 'right'],
+ maxStack: 8,
+ timeOut: 5000,
+ clickToClose: true,
+ animate: 'scale'
+ },
+ } as any;
+
+ service = new NotificationsService(envConfig, store);
+ });
+
+ it('Success method should dispatch NewNotificationAction with proper parameter', () => {
+ const notification = service.success('Title', Observable.of('Content'));
+ expect(notification.type).toBe(NotificationType.Success);
+ expect(store.dispatch).toHaveBeenCalledWith(new NewNotificationAction(notification));
+ });
+
+ it('Warning method should dispatch NewNotificationAction with proper parameter', () => {
+ const notification = service.warning('Title', Observable.of('Content'));
+ expect(notification.type).toBe(NotificationType.Warning);
+ expect(store.dispatch).toHaveBeenCalledWith(new NewNotificationAction(notification));
+ });
+
+ it('Info method should dispatch NewNotificationAction with proper parameter', () => {
+ const notification = service.info('Title', Observable.of('Content'));
+ expect(notification.type).toBe(NotificationType.Info);
+ expect(store.dispatch).toHaveBeenCalledWith(new NewNotificationAction(notification));
+ });
+
+ it('Error method should dispatch NewNotificationAction with proper parameter', () => {
+ const notification = service.error('Title', Observable.of('Content'));
+ expect(notification.type).toBe(NotificationType.Error);
+ expect(store.dispatch).toHaveBeenCalledWith(new NewNotificationAction(notification));
+ });
+
+ it('Remove method should dispatch RemoveNotificationAction with proper id', () => {
+ const notification = new Notification('1234', NotificationType.Info, 'title...', 'description');
+ service.remove(notification);
+ expect(store.dispatch).toHaveBeenCalledWith(new RemoveNotificationAction(notification.id));
+ });
+
+ it('RemoveAll method should dispatch RemoveAllNotificationsAction', () => {
+ service.removeAll();
+ expect(store.dispatch).toHaveBeenCalledWith(new RemoveAllNotificationsAction());
+ });
+
+});
diff --git a/src/app/shared/notifications/notifications.service.ts b/src/app/shared/notifications/notifications.service.ts
new file mode 100644
index 0000000000..92b6f58aed
--- /dev/null
+++ b/src/app/shared/notifications/notifications.service.ts
@@ -0,0 +1,77 @@
+import { Inject, Injectable } from '@angular/core';
+import { INotification, Notification } from './models/notification.model';
+import { NotificationType } from './models/notification-type';
+import { NotificationOptions } from './models/notification-options.model';
+import { uniqueId } from 'lodash';
+import { Store } from '@ngrx/store';
+import { NewNotificationAction, RemoveAllNotificationsAction, RemoveNotificationAction } from './notifications.actions';
+import { Observable } from 'rxjs/Observable';
+import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
+
+@Injectable()
+export class NotificationsService {
+
+ constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig,
+ private store: Store) {
+ }
+
+ private add(notification: Notification) {
+ let notificationAction;
+ notificationAction = new NewNotificationAction(notification);
+ this.store.dispatch(notificationAction);
+ }
+
+ success(title: any = Observable.of(''),
+ content: any = Observable.of(''),
+ options: NotificationOptions = this.getDefaultOptions(),
+ html: boolean = false): INotification {
+ const notification = new Notification(uniqueId(), NotificationType.Success, title, content, options, html);
+ this.add(notification);
+ return notification;
+ }
+
+ error(title: any = Observable.of(''),
+ content: any = Observable.of(''),
+ options: NotificationOptions = this.getDefaultOptions(),
+ html: boolean = false): INotification {
+ const notification = new Notification(uniqueId(), NotificationType.Error, title, content, options, html);
+ this.add(notification);
+ return notification;
+ }
+
+ info(title: any = Observable.of(''),
+ content: any = Observable.of(''),
+ options: NotificationOptions = this.getDefaultOptions(),
+ html: boolean = false): INotification {
+ const notification = new Notification(uniqueId(), NotificationType.Info, title, content, options, html);
+ this.add(notification);
+ return notification;
+ }
+
+ warning(title: any = Observable.of(''),
+ content: any = Observable.of(''),
+ options: NotificationOptions = this.getDefaultOptions(),
+ html: boolean = false): INotification {
+ const notification = new Notification(uniqueId(), NotificationType.Warning, title, content, options, html);
+ this.add(notification);
+ return notification;
+ }
+
+ remove(notification: INotification) {
+ const actionRemove = new RemoveNotificationAction(notification.id);
+ this.store.dispatch(actionRemove);
+ }
+
+ removeAll() {
+ const actionRemoveAll = new RemoveAllNotificationsAction();
+ this.store.dispatch(actionRemoveAll);
+ }
+
+ private getDefaultOptions(): NotificationOptions {
+ return new NotificationOptions(
+ this.config.notifications.timeOut,
+ this.config.notifications.clickToClose,
+ this.config.notifications.animate
+ );
+ }
+}
diff --git a/src/app/shared/notifications/selectors.ts b/src/app/shared/notifications/selectors.ts
new file mode 100644
index 0000000000..a0b9487f42
--- /dev/null
+++ b/src/app/shared/notifications/selectors.ts
@@ -0,0 +1,9 @@
+/**
+ * Returns the user state.
+ * @function getUserState
+ * @param {AppState} state Top level state.
+ * @return {AuthState}
+ */
+import { AppState } from '../../app.reducer';
+
+export const notificationsStateSelector = (state: AppState) => state.notifications;
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index c78c218fa9..79ddd8680f 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -20,11 +20,11 @@ import { SearchResultListElementComponent } from './object-list/search-result-li
import { WrapperListElementComponent } from './object-list/wrapper-list-element/wrapper-list-element.component';
import { ObjectListComponent } from './object-list/object-list.component';
-import { CollectionGridElementComponent} from './object-grid/collection-grid-element/collection-grid-element.component'
-import { CommunityGridElementComponent} from './object-grid/community-grid-element/community-grid-element.component'
-import { ItemGridElementComponent} from './object-grid/item-grid-element/item-grid-element.component'
-import { AbstractListableElementComponent} from './object-collection/shared/object-collection-element/abstract-listable-element.component'
-import { WrapperGridElementComponent} from './object-grid/wrapper-grid-element/wrapper-grid-element.component'
+import { CollectionGridElementComponent } from './object-grid/collection-grid-element/collection-grid-element.component';
+import { CommunityGridElementComponent } from './object-grid/community-grid-element/community-grid-element.component';
+import { ItemGridElementComponent } from './object-grid/item-grid-element/item-grid-element.component';
+import { AbstractListableElementComponent } from './object-collection/shared/object-collection-element/abstract-listable-element.component';
+import { WrapperGridElementComponent } from './object-grid/wrapper-grid-element/wrapper-grid-element.component';
import { ObjectGridComponent } from './object-grid/object-grid.component';
import { ObjectCollectionComponent } from './object-collection/object-collection.component';
import { ComcolPageContentComponent } from './comcol-page-content/comcol-page-content.component';
@@ -40,11 +40,14 @@ import { SearchResultGridElementComponent } from './object-grid/search-result-gr
import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component';
import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component';
import { VarDirective } from './utils/var.directive';
+import { NotificationComponent } from './notifications/notification/notification.component';
+import { NotificationsBoardComponent } from './notifications/notifications-board/notifications-board.component';
import { DragClickDirective } from './utils/drag-click.directive';
import { TruncatePipe } from './utils/truncate.pipe';
import { TruncatableComponent } from './truncatable/truncatable.component';
import { TruncatableService } from './truncatable/truncatable.service';
import { TruncatablePartComponent } from './truncatable/truncatable-part/truncatable-part.component';
+import { MockAdminGuard } from './mocks/mock-admin-guard.service';
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -97,11 +100,12 @@ const ENTRY_COMPONENTS = [
ItemGridElementComponent,
CollectionGridElementComponent,
CommunityGridElementComponent,
- SearchResultGridElementComponent
+ SearchResultGridElementComponent,
];
const PROVIDERS = [
- TruncatableService
+ TruncatableService,
+ MockAdminGuard
];
const DIRECTIVES = [
diff --git a/src/app/shared/testing/notifications-service-stub.ts b/src/app/shared/testing/notifications-service-stub.ts
new file mode 100644
index 0000000000..5629a05a96
--- /dev/null
+++ b/src/app/shared/testing/notifications-service-stub.ts
@@ -0,0 +1,46 @@
+import { Observable } from 'rxjs/Observable';
+import { INotification } from '../notifications/models/notification.model';
+import { NotificationOptions } from '../notifications/models/notification-options.model';
+
+export class NotificationsServiceStub {
+
+ success(title: any = Observable.of(''),
+ content: any = Observable.of(''),
+ options: NotificationOptions = this.getDefaultOptions(),
+ html?: any): INotification {
+ return
+ }
+
+ error(title: any = Observable.of(''),
+ content: any = Observable.of(''),
+ options: NotificationOptions = this.getDefaultOptions(),
+ html?: any): INotification {
+ return
+ }
+
+ info(title: any = Observable.of(''),
+ content: any = Observable.of(''),
+ options: NotificationOptions = this.getDefaultOptions(),
+ html?: any): INotification {
+ return
+ }
+
+ warning(title: any = Observable.of(''),
+ content: any = Observable.of(''),
+ options: NotificationOptions = this.getDefaultOptions(),
+ html?: any): INotification {
+ return
+ }
+
+ remove(notification: INotification) {
+ return
+ }
+
+ removeAll() {
+ return
+ }
+
+ private getDefaultOptions(): NotificationOptions {
+ return new NotificationOptions();
+ }
+}
diff --git a/src/config.ts b/src/config.ts
index f07cab37dc..c5da7ab629 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -4,6 +4,7 @@ import { InjectionToken } from '@angular/core';
import { Config } from './config/config.interface';
import { ServerConfig } from './config/server-config.interface';
import { GlobalConfig } from './config/global-config.interface';
+import { hasValue } from './app/shared/empty.util';
const GLOBAL_CONFIG: InjectionToken = new InjectionToken('config');
@@ -55,6 +56,41 @@ if (envConfigFile) {
}
}
+// allow to override a few important options by environment variables
+function createServerConfig(host?: string, port?: string, nameSpace?: string, ssl?: string): ServerConfig {
+ const result = { host, nameSpace } as any;
+
+ if (hasValue(port)) {
+ result.port = Number(port);
+ }
+
+ if (hasValue(ssl)) {
+ result.ssl = ssl.trim().match(/^(true|1|yes)$/i) ? true : false;
+ }
+
+ return result;
+}
+
+const processEnv = {
+ ui: createServerConfig(
+ process.env.DSPACE_HOST,
+ process.env.DSPACE_PORT,
+ process.env.DSPACE_NAMESPACE,
+ process.env.DSPACE_SSL),
+ rest: createServerConfig(
+ process.env.DSPACE_REST_HOST,
+ process.env.DSPACE_REST_PORT,
+ process.env.DSPACE_REST_NAMESPACE,
+ process.env.DSPACE_REST_SSL)
+} as GlobalConfig;
+
+// merge the environment variables with our configuration.
+try {
+ merge(processEnv)
+} catch (e) {
+ console.warn('Unable to merge environment variable into the configuration')
+}
+
buildBaseUrls();
// set config for whether running in production
diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts
index e85f67b4ab..7c05b78fa5 100644
--- a/src/config/global-config.interface.ts
+++ b/src/config/global-config.interface.ts
@@ -2,12 +2,14 @@ import { Config } from './config.interface';
import { ServerConfig } from './server-config.interface';
import { CacheConfig } from './cache-config.interface';
import { UniversalConfig } from './universal-config.interface';
+import { INotificationBoardOptions } from './notifications-config.interfaces';
export interface GlobalConfig extends Config {
ui: ServerConfig;
rest: ServerConfig;
production: boolean;
cache: CacheConfig;
+ notifications: INotificationBoardOptions;
universal: UniversalConfig;
gaTrackingId: string;
logDirectory: string;
diff --git a/src/config/notifications-config.interfaces.ts b/src/config/notifications-config.interfaces.ts
new file mode 100644
index 0000000000..49cdf277c3
--- /dev/null
+++ b/src/config/notifications-config.interfaces.ts
@@ -0,0 +1,11 @@
+import { Config } from './config.interface';
+import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type';
+
+export interface INotificationBoardOptions extends Config {
+ rtl: boolean;
+ position: ['top' | 'bottom' | 'middle', 'right' | 'left' | 'center'];
+ maxStack: number;
+ timeOut: number;
+ clickToClose: boolean;
+ animate: NotificationAnimationsType;
+}
diff --git a/src/server.ts b/src/server.ts
index 91229a7c78..040d13311f 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -21,8 +21,6 @@ import { ENV_CONFIG } from './config';
export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
const app = express();
- const port = ENV_CONFIG.ui.port ? ENV_CONFIG.ui.port : 80;
-
if (ENV_CONFIG.production) {
enableProdMode();
app.use(compression());
@@ -90,7 +88,7 @@ export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
https.createServer({
key: keys.serviceKey,
cert: keys.certificate
- }, app).listen(port, ENV_CONFIG.ui.host, () => {
+ }, app).listen(ENV_CONFIG.ui.port, ENV_CONFIG.ui.host, () => {
serverStarted();
});
}
@@ -127,7 +125,7 @@ export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
});
}
} else {
- app.listen(port, ENV_CONFIG.ui.host, () => {
+ app.listen(ENV_CONFIG.ui.port, ENV_CONFIG.ui.host, () => {
serverStarted();
});
}}