Merge branch 'master' into w2p-50632_Replace-mock-registry-service-with-implementation

Conflicts:
	src/app/core/core.module.ts
This commit is contained in:
Kristof De Langhe
2018-05-22 13:41:40 +02:00
43 changed files with 1571 additions and 69 deletions

View File

@@ -22,6 +22,17 @@ module.exports = {
// msToLive: 1000, // 15 minutes // msToLive: 1000, // 15 minutes
control: 'max-age=60' // revalidate browser 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 // Angular Universal settings
universal: { universal: {
preboot: true, preboot: true,

View File

@@ -1,5 +1,60 @@
# Configuration # Configuration
Default configuration file is located in `config/` folder. All configuration options should be listed in the default configuration file `config/environment.default.js`. Please do not change this file directly! To change the default configuration values, create local files that override the parameters you need to change:
- Create a new `environment.dev.js` file in `config/` for `devel` environment;
- Create a new `environment.prod.js` file in `config/` for `production` environment;
Some few configuration options can be overridden by setting environment variables. These and the variable names are listed below.
## Nodejs server
When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itsself to, and if ssl should be enabled not. By default it listens on `localhost:3000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`.
To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above):
```
module.exports = {
// Angular Universal server settings.
ui: {
ssl: false,
host: 'localhost',
port: 3000,
nameSpace: '/'
}
};
```
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
```
DSPACE_SSL=true
DSPACE_HOST=localhost
DSPACE_PORT=3000
DSPACE_NAMESPACE=/
```
## DSpace's REST endpoint
dspace-angular connects to your DSpace installation by using its REST endpoint. To do so, you have to define the ip address, port and if ssl should be enabled. You can do this in a configuration file (see above) by adding the following options:
```
module.exports = {
// The REST API server settings.
rest: {
ssl: true,
host: 'dspace7.4science.it',
port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/dspace-spring-rest/api'
}
};
```
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
```
DSPACE_REST_SSL=true
DSPACE_REST_HOST=localhost
DSPACE_REST_PORT=3000
DSPACE_REST_NAMESPACE=/
```
## Supporting analytics services other than Google Analytics ## Supporting analytics services other than Google Analytics
This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics. This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics.

View File

@@ -6,5 +6,4 @@ import { Component } from '@angular/core';
templateUrl: './home-page.component.html' templateUrl: './home-page.component.html'
}) })
export class HomePageComponent { export class HomePageComponent {
} }

View File

@@ -16,7 +16,7 @@ import { TopLevelCommunityListComponent } from './top-level-community-list/top-l
declarations: [ declarations: [
HomePageComponent, HomePageComponent,
TopLevelCommunityListComponent, TopLevelCommunityListComponent,
HomeNewsComponent HomeNewsComponent,
] ]
}) })
export class HomePageModule { export class HomePageModule {

View File

@@ -1,11 +1,17 @@
<div class="outer-wrapper"> <div class="outer-wrapper">
<div class="inner-wrapper"> <div class="inner-wrapper">
<ds-header></ds-header> <ds-header></ds-header>
<main class="main-content"> <ds-notifications-board
<router-outlet></router-outlet> [options]="config.notifications">
</main> </ds-notifications-board>
<ds-footer></ds-footer> <main class="main-content">
</div> <router-outlet></router-outlet>
</div> </main>
<ds-footer></ds-footer>
</div>
</div>

View File

@@ -1,11 +1,4 @@
import { import { ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, ViewEncapsulation } from '@angular/core';
ChangeDetectionStrategy,
Component,
HostListener,
Inject,
OnInit,
ViewEncapsulation
} from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';

View File

@@ -1,8 +1,10 @@
import { HeaderEffects } from './header/header.effects'; import { HeaderEffects } from './header/header.effects';
import { StoreEffects } from './store.effects'; import { StoreEffects } from './store.effects';
import { NotificationsEffects } from './shared/notifications/notifications.effects';
export const appEffects = [ export const appEffects = [
StoreEffects, StoreEffects,
HeaderEffects HeaderEffects,
NotificationsEffects
]; ];

View File

@@ -1,7 +1,6 @@
import { APP_BASE_HREF, CommonModule } from '@angular/common'; import { APP_BASE_HREF, CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { BrowserTransferStateModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
@@ -29,6 +28,8 @@ import { HeaderComponent } from './header/header.component';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer'; 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() { export function getConfig() {
return ENV_CONFIG; return ENV_CONFIG;
@@ -85,7 +86,9 @@ if (!ENV_CONFIG.production) {
AppComponent, AppComponent,
HeaderComponent, HeaderComponent,
FooterComponent, FooterComponent,
PageNotFoundComponent PageNotFoundComponent,
NotificationComponent,
NotificationsBoardComponent
], ],
exports: [AppComponent] exports: [AppComponent]
}) })

View File

@@ -1,32 +1,35 @@
import { ActionReducerMap } from '@ngrx/store'; import { ActionReducerMap } from '@ngrx/store';
import * as fromRouter from '@ngrx/router-store'; import * as fromRouter from '@ngrx/router-store';
import { headerReducer, HeaderState } from './header/header.reducer'; import { headerReducer, HeaderState } from './header/header.reducer';
import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer'; import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer';
import { import {
SearchSidebarState, SearchSidebarState,
sidebarReducer sidebarReducer
} from './+search-page/search-sidebar/search-sidebar.reducer'; } from './+search-page/search-sidebar/search-sidebar.reducer';
import { import {
filterReducer, filterReducer,
SearchFiltersState SearchFiltersState
} from './+search-page/search-filters/search-filter/search-filter.reducer'; } from './+search-page/search-filters/search-filter/search-filter.reducer';
import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers';
import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer';
export interface AppState {
router: fromRouter.RouterReducerState; export interface AppState {
hostWindow: HostWindowState; router: fromRouter.RouterReducerState;
header: HeaderState; hostWindow: HostWindowState;
searchSidebar: SearchSidebarState; header: HeaderState;
searchFilter: SearchFiltersState; notifications: NotificationsState;
truncatable: TruncatablesState; searchSidebar: SearchSidebarState;
} searchFilter: SearchFiltersState;
truncatable: TruncatablesState;
export const appReducers: ActionReducerMap<AppState> = { }
router: fromRouter.routerReducer,
hostWindow: hostWindowReducer, export const appReducers: ActionReducerMap<AppState> = {
header: headerReducer, router: fromRouter.routerReducer,
searchSidebar: sidebarReducer, hostWindow: hostWindowReducer,
searchFilter: filterReducer, header: headerReducer,
truncatable: truncatableReducer notifications: notificationsReducer,
}; searchSidebar: sidebarReducer,
searchFilter: filterReducer,
truncatable: truncatableReducer
};

View File

@@ -49,6 +49,7 @@ import { RegistryMetadataschemasResponseParsingService } from './data/registry-m
import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; import { MetadataschemaParsingService } from './data/metadataschema-parsing.service';
import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service'; import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service';
import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service';
import { NotificationsService } from '../shared/notifications/notifications.service';
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -99,6 +100,7 @@ const PROVIDERS = [
SubmissionFormsConfigService, SubmissionFormsConfigService,
SubmissionSectionsConfigService, SubmissionSectionsConfigService,
UUIDService, UUIDService,
NotificationsService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory } { provide: NativeWindowService, useFactory: NativeWindowFactory }
]; ];

View File

@@ -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', [ const fadeEnter = transition(':enter', [
style({ opacity: 0 }), style({ opacity: 0 }),
animate(300, style({ opacity: 1 })) 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 }), style({ opacity: 1 }),
animate(400, style({ opacity: 0 })) animate(400, style({ opacity: 0 }))
]); ]);
const fadeLeave = transition(':leave', [
style({ opacity: 0 }),
animate(300, style({ opacity: 1 }))
]);
export const fadeIn = trigger('fadeIn', [ export const fadeIn = trigger('fadeIn', [
fadeEnter fadeEnter

View File

@@ -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
]);

View File

@@ -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
]);

View File

@@ -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
]);

View File

@@ -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
]);

View File

@@ -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
]);

View File

@@ -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
]);

View File

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

View File

@@ -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'
}

View File

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

View File

@@ -0,0 +1,7 @@
export enum NotificationType {
Success = 'alert-success',
Error = 'alert-danger',
Info = 'alert-info',
Warning = 'alert-warning',
// Bare = 'bare'
}

View File

@@ -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> | string;
content?: Observable<string> | string;
options?: INotificationOptions;
html?: boolean;
}
export class Notification implements INotification {
public id: string;
public type: NotificationType;
public title: Observable<string> | string;
public content: Observable<string> | string;
public options: INotificationOptions;
public html: boolean;
constructor(id: string,
type: NotificationType,
title?: Observable<string> | string,
content?: Observable<string> | 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;
}
}

View File

@@ -0,0 +1,58 @@
<div class="alert {{notification.type}} alert-dismissible p-3" role="alert"
[@enterLeave]="animate">
<div class="notification-progress-loader position-absolute w-100" *ngIf="showProgressBar">
<span [ngStyle]="{'width': progressWidth + '%'}" class="h-100 float-left"></span>
</div>
<button *ngIf="notification.options.clickToClose"
(click)="remove()"
type="button" class="close pt-0 pr-1 pl-0 pb-0" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<div class="d-flex flex-row">
<div class="d-flex flex-column justify-content-center align-items-center">
<div class="notification-icon d-flex justify-content-center"><i
[ngClass]="{'fa fa-2x': true,
'fa-check': notification.type == 'alert-success',
'fa-times-circle': notification.type == 'alert-danger',
'fa-exclamation-triangle': notification.type == 'alert-warning',
'fa-info': notification.type == 'alert-info'
}"></i></div>
</div>
<div class="d-flex flex-column justify-content-center align-content-stretch">
<div class="p-2 mr-3" *ngIf="title">
<strong>
<div class="notification-title pl-1" *ngIf="titleIsTemplate; else regularTitle">
<ng-container *ngTemplateOutlet="title"></ng-container>
</div>
<ng-template #regularTitle>
<div class="notification-title pl-1">{{(title | async)}}</div>
</ng-template>
</strong>
</div>
<div class="p-2 mr-3" *ngIf="content && !html">
<div class="notification-content pl-1" *ngIf="contentIsTemplate; else regularContent">
<ng-container *ngTemplateOutlet="content"></ng-container>
</div>
<ng-template #regularContent>
<div class="notification-content pl-1">{{(content | async)}}</div>
</ng-template>
</div>
<div class="p-2 mr-3" *ngIf="content && html">
<div class="notification-html pl-1" *ngIf="contentIsTemplate; else regularHtml">
<ng-container *ngTemplateOutlet="content"></ng-container>
</div>
<ng-template #regularHtml>
<div class="notification-html pl-1" [innerHTML]="content"></div>
</ng-template>
</div>
</div>
</div>
</div>

View File

@@ -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%);
}

View File

@@ -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<NotificationComponent>;
let deTitle: DebugElement;
let elTitle: HTMLElement;
let deContent: DebugElement;
let elContent: HTMLElement;
let elType: HTMLElement;
beforeEach(async(() => {
const store: Store<Notification> = 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 = `<a class="btn btn-link p-0 m-0 pb-1" href="/test"><strong>test</strong></a>`
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);
})
});

View File

@@ -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<string>;
public content: Observable<string>;
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();
}
}

View File

@@ -0,0 +1,6 @@
<div class="notifications-wrapper position-fixed" [ngClass]="position">
<ds-notification
*ngFor="let a of notifications; let i = index"
[notification]="a">
</ds-notification>
</div>

View File

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

View File

@@ -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<NotificationsBoardComponent>;
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<AppState>) => {
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);
});
})
;

View File

@@ -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<AppState>,
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();
}
}
}

View File

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

View File

@@ -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<Action> = 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<AppState>) {
}
}

View File

@@ -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 = '<p>I\'m a mock test</p>';
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);
});
}));
});

View File

@@ -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<INotification> {
}
/**
* 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;
};

View File

@@ -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<Notification> = 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());
});
});

View File

@@ -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<Notification>) {
}
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
);
}
}

View File

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

View File

@@ -20,11 +20,11 @@ import { SearchResultListElementComponent } from './object-list/search-result-li
import { WrapperListElementComponent } from './object-list/wrapper-list-element/wrapper-list-element.component'; import { WrapperListElementComponent } from './object-list/wrapper-list-element/wrapper-list-element.component';
import { ObjectListComponent } from './object-list/object-list.component'; import { ObjectListComponent } from './object-list/object-list.component';
import { CollectionGridElementComponent} from './object-grid/collection-grid-element/collection-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 { CommunityGridElementComponent } from './object-grid/community-grid-element/community-grid-element.component';
import { ItemGridElementComponent} from './object-grid/item-grid-element/item-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 { 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 { WrapperGridElementComponent } from './object-grid/wrapper-grid-element/wrapper-grid-element.component';
import { ObjectGridComponent } from './object-grid/object-grid.component'; import { ObjectGridComponent } from './object-grid/object-grid.component';
import { ObjectCollectionComponent } from './object-collection/object-collection.component'; import { ObjectCollectionComponent } from './object-collection/object-collection.component';
import { ComcolPageContentComponent } from './comcol-page-content/comcol-page-content.component'; import { ComcolPageContentComponent } from './comcol-page-content/comcol-page-content.component';
@@ -40,11 +40,14 @@ import { SearchResultGridElementComponent } from './object-grid/search-result-gr
import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component';
import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component'; import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component';
import { VarDirective } from './utils/var.directive'; import { VarDirective } from './utils/var.directive';
import { NotificationComponent } from './notifications/notification/notification.component';
import { NotificationsBoardComponent } from './notifications/notifications-board/notifications-board.component';
import { DragClickDirective } from './utils/drag-click.directive'; import { DragClickDirective } from './utils/drag-click.directive';
import { TruncatePipe } from './utils/truncate.pipe'; import { TruncatePipe } from './utils/truncate.pipe';
import { TruncatableComponent } from './truncatable/truncatable.component'; import { TruncatableComponent } from './truncatable/truncatable.component';
import { TruncatableService } from './truncatable/truncatable.service'; import { TruncatableService } from './truncatable/truncatable.service';
import { TruncatablePartComponent } from './truncatable/truncatable-part/truncatable-part.component'; import { TruncatablePartComponent } from './truncatable/truncatable-part/truncatable-part.component';
import { MockAdminGuard } from './mocks/mock-admin-guard.service';
const MODULES = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -97,11 +100,12 @@ const ENTRY_COMPONENTS = [
ItemGridElementComponent, ItemGridElementComponent,
CollectionGridElementComponent, CollectionGridElementComponent,
CommunityGridElementComponent, CommunityGridElementComponent,
SearchResultGridElementComponent SearchResultGridElementComponent,
]; ];
const PROVIDERS = [ const PROVIDERS = [
TruncatableService TruncatableService,
MockAdminGuard
]; ];
const DIRECTIVES = [ const DIRECTIVES = [

View File

@@ -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();
}
}

View File

@@ -4,6 +4,7 @@ import { InjectionToken } from '@angular/core';
import { Config } from './config/config.interface'; import { Config } from './config/config.interface';
import { ServerConfig } from './config/server-config.interface'; import { ServerConfig } from './config/server-config.interface';
import { GlobalConfig } from './config/global-config.interface'; import { GlobalConfig } from './config/global-config.interface';
import { hasValue } from './app/shared/empty.util';
const GLOBAL_CONFIG: InjectionToken<GlobalConfig> = new InjectionToken<GlobalConfig>('config'); const GLOBAL_CONFIG: InjectionToken<GlobalConfig> = new InjectionToken<GlobalConfig>('config');
@@ -55,6 +56,41 @@ if (envConfigFile) {
} }
} }
// allow to override a few important options by environment variables
function createServerConfig(host?: string, port?: string, nameSpace?: string, ssl?: string): ServerConfig {
const result = { host, nameSpace } as any;
if (hasValue(port)) {
result.port = Number(port);
}
if (hasValue(ssl)) {
result.ssl = ssl.trim().match(/^(true|1|yes)$/i) ? true : false;
}
return result;
}
const processEnv = {
ui: createServerConfig(
process.env.DSPACE_HOST,
process.env.DSPACE_PORT,
process.env.DSPACE_NAMESPACE,
process.env.DSPACE_SSL),
rest: createServerConfig(
process.env.DSPACE_REST_HOST,
process.env.DSPACE_REST_PORT,
process.env.DSPACE_REST_NAMESPACE,
process.env.DSPACE_REST_SSL)
} as GlobalConfig;
// merge the environment variables with our configuration.
try {
merge(processEnv)
} catch (e) {
console.warn('Unable to merge environment variable into the configuration')
}
buildBaseUrls(); buildBaseUrls();
// set config for whether running in production // set config for whether running in production

View File

@@ -2,12 +2,14 @@ import { Config } from './config.interface';
import { ServerConfig } from './server-config.interface'; import { ServerConfig } from './server-config.interface';
import { CacheConfig } from './cache-config.interface'; import { CacheConfig } from './cache-config.interface';
import { UniversalConfig } from './universal-config.interface'; import { UniversalConfig } from './universal-config.interface';
import { INotificationBoardOptions } from './notifications-config.interfaces';
export interface GlobalConfig extends Config { export interface GlobalConfig extends Config {
ui: ServerConfig; ui: ServerConfig;
rest: ServerConfig; rest: ServerConfig;
production: boolean; production: boolean;
cache: CacheConfig; cache: CacheConfig;
notifications: INotificationBoardOptions;
universal: UniversalConfig; universal: UniversalConfig;
gaTrackingId: string; gaTrackingId: string;
logDirectory: string; logDirectory: string;

View File

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

View File

@@ -21,8 +21,6 @@ import { ENV_CONFIG } from './config';
export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) { export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
const app = express(); const app = express();
const port = ENV_CONFIG.ui.port ? ENV_CONFIG.ui.port : 80;
if (ENV_CONFIG.production) { if (ENV_CONFIG.production) {
enableProdMode(); enableProdMode();
app.use(compression()); app.use(compression());
@@ -90,7 +88,7 @@ export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
https.createServer({ https.createServer({
key: keys.serviceKey, key: keys.serviceKey,
cert: keys.certificate cert: keys.certificate
}, app).listen(port, ENV_CONFIG.ui.host, () => { }, app).listen(ENV_CONFIG.ui.port, ENV_CONFIG.ui.host, () => {
serverStarted(); serverStarted();
}); });
} }
@@ -127,7 +125,7 @@ export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
}); });
} }
} else { } else {
app.listen(port, ENV_CONFIG.ui.host, () => { app.listen(ENV_CONFIG.ui.port, ENV_CONFIG.ui.host, () => {
serverStarted(); serverStarted();
}); });
}} }}