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 @@ + 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; +}