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/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 0e5cb17a96..c0fcb56954 100644
--- a/src/app/app.reducer.ts
+++ b/src/app/app.reducer.ts
@@ -12,6 +12,7 @@ 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 {
@@ -19,6 +20,7 @@ export interface AppState {
hostWindow: HostWindowState;
header: HeaderState;
forms: FormState;
+ notifications: NotificationsState;
searchSidebar: SearchSidebarState;
searchFilter: SearchFiltersState;
truncatable: TruncatablesState;
@@ -29,6 +31,7 @@ export const appReducers: ActionReducerMap = {
hostWindow: hostWindowReducer,
header: headerReducer,
forms: formReducer,
+ 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 e57f1b45c6..80770f1ae5 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -49,6 +49,7 @@ import { HALEndpointService } from './shared/hal-endpoint.service';
import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service';
import { FacetValueMapResponseParsingService } from './data/facet-value-map-response-parsing.service';
import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service';
+import { NotificationsService } from '../shared/notifications/notifications.service';
import { UploaderService } from '../shared/uploader/uploader.service';
const IMPORTS = [
@@ -103,6 +104,7 @@ const PROVIDERS = [
IntegrationResponseParsingService,
UploaderService,
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 d784df9df3..dff55ebb5c 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -25,11 +25,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';
@@ -52,6 +52,8 @@ import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dyn
import { DynamicFormsCoreModule } from '@ng-dynamic-forms/core';
import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap';
import { TextMaskModule } from 'angular2-text-mask';
+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';
@@ -66,6 +68,7 @@ import { SortablejsModule } from 'angular-sortablejs';
import { NumberPickerComponent } from './number-picker/number-picker.component';
import { DsDatePickerComponent } from './form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component';
import { DsDynamicLookupComponent } from './form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component';
+import { MockAdminGuard } from './mocks/mock-admin-guard.service';
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -141,11 +144,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/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;
+}