mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge remote-tracking branch 'remotes/origin/master' into authentication
# Conflicts: # src/app/app.effects.ts # src/app/app.module.ts # src/app/app.reducer.ts # src/app/core/core.module.ts # src/app/shared/shared.module.ts
This commit is contained in:
@@ -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,
|
||||
|
@@ -16,7 +16,7 @@ import { TopLevelCommunityListComponent } from './top-level-community-list/top-l
|
||||
declarations: [
|
||||
HomePageComponent,
|
||||
TopLevelCommunityListComponent,
|
||||
HomeNewsComponent
|
||||
HomeNewsComponent,
|
||||
]
|
||||
})
|
||||
export class HomePageModule {
|
||||
|
@@ -1,11 +1,17 @@
|
||||
<div class="outer-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<ds-header></ds-header>
|
||||
|
||||
<main class="main-content">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<ds-footer></ds-footer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="outer-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<ds-header></ds-header>
|
||||
|
||||
<ds-notifications-board
|
||||
[options]="config.notifications">
|
||||
</ds-notifications-board>
|
||||
|
||||
<main class="main-content">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<ds-footer></ds-footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
@@ -1,9 +1,10 @@
|
||||
|
||||
import { HeaderEffects } from './header/header.effects';
|
||||
import { StoreEffects } from './store.effects';
|
||||
import { AuthEffects } from './core/auth/auth.effects';
|
||||
import { NotificationsEffects } from './shared/notifications/notifications.effects';
|
||||
|
||||
export const appEffects = [
|
||||
StoreEffects,
|
||||
HeaderEffects
|
||||
HeaderEffects,
|
||||
NotificationsEffects
|
||||
];
|
||||
|
@@ -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';
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
|
||||
export function getConfig() {
|
||||
@@ -87,7 +88,9 @@ if (!ENV_CONFIG.production) {
|
||||
AppComponent,
|
||||
HeaderComponent,
|
||||
FooterComponent,
|
||||
PageNotFoundComponent
|
||||
PageNotFoundComponent,
|
||||
NotificationComponent,
|
||||
NotificationsBoardComponent
|
||||
],
|
||||
exports: [AppComponent]
|
||||
})
|
||||
|
@@ -11,12 +11,14 @@ import {
|
||||
filterReducer,
|
||||
SearchFiltersState
|
||||
} from './+search-page/search-filters/search-filter/search-filter.reducer';
|
||||
import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers';
|
||||
import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer';
|
||||
|
||||
export interface AppState {
|
||||
router: fromRouter.RouterReducerState;
|
||||
hostWindow: HostWindowState;
|
||||
header: HeaderState;
|
||||
notifications: NotificationsState;
|
||||
searchSidebar: SearchSidebarState;
|
||||
searchFilter: SearchFiltersState;
|
||||
truncatable: TruncatablesState;
|
||||
@@ -26,6 +28,7 @@ export const appReducers: ActionReducerMap<AppState> = {
|
||||
router: fromRouter.routerReducer,
|
||||
hostWindow: hostWindowReducer,
|
||||
header: headerReducer,
|
||||
notifications: notificationsReducer,
|
||||
searchSidebar: sidebarReducer,
|
||||
searchFilter: filterReducer,
|
||||
truncatable: truncatableReducer
|
||||
|
@@ -50,6 +50,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';
|
||||
|
||||
const IMPORTS = [
|
||||
CommonModule,
|
||||
@@ -99,13 +100,14 @@ const PROVIDERS = [
|
||||
SubmissionFormsConfigService,
|
||||
SubmissionSectionsConfigService,
|
||||
UUIDService,
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowFactory },
|
||||
// register AuthInterceptor as HttpInterceptor
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthInterceptor,
|
||||
multi: true
|
||||
}
|
||||
NotificationsService,
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowFactory }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@@ -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
|
||||
|
26
src/app/shared/animations/fromBottom.ts
Normal file
26
src/app/shared/animations/fromBottom.ts
Normal 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
|
||||
]);
|
26
src/app/shared/animations/fromLeft.ts
Normal file
26
src/app/shared/animations/fromLeft.ts
Normal 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
|
||||
]);
|
26
src/app/shared/animations/fromRight.ts
Normal file
26
src/app/shared/animations/fromRight.ts
Normal 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
|
||||
]);
|
26
src/app/shared/animations/fromTop.ts
Normal file
26
src/app/shared/animations/fromTop.ts
Normal 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
|
||||
]);
|
26
src/app/shared/animations/rotate.ts
Normal file
26
src/app/shared/animations/rotate.ts
Normal 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
|
||||
]);
|
26
src/app/shared/animations/scale.ts
Normal file
26
src/app/shared/animations/scale.ts
Normal 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
|
||||
]);
|
23
src/app/shared/mocks/mock-admin-guard.service.ts
Normal file
23
src/app/shared/mocks/mock-admin-guard.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@@ -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'
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
7
src/app/shared/notifications/models/notification-type.ts
Normal file
7
src/app/shared/notifications/models/notification-type.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum NotificationType {
|
||||
Success = 'alert-success',
|
||||
Error = 'alert-danger',
|
||||
Info = 'alert-info',
|
||||
Warning = 'alert-warning',
|
||||
// Bare = 'bare'
|
||||
}
|
38
src/app/shared/notifications/models/notification.model.ts
Normal file
38
src/app/shared/notifications/models/notification.model.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
@@ -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">×</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>
|
@@ -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%);
|
||||
}
|
@@ -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);
|
||||
})
|
||||
|
||||
});
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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);
|
||||
});
|
||||
|
||||
})
|
||||
;
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
61
src/app/shared/notifications/notifications.actions.ts
Normal file
61
src/app/shared/notifications/notifications.actions.ts
Normal 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;
|
32
src/app/shared/notifications/notifications.effects.ts
Normal file
32
src/app/shared/notifications/notifications.effects.ts
Normal 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>) {
|
||||
}
|
||||
}
|
140
src/app/shared/notifications/notifications.reducers.spec.ts
Normal file
140
src/app/shared/notifications/notifications.reducers.spec.ts
Normal 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);
|
||||
});
|
||||
|
||||
}));
|
||||
|
||||
});
|
43
src/app/shared/notifications/notifications.reducers.ts
Normal file
43
src/app/shared/notifications/notifications.reducers.ts
Normal 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;
|
||||
};
|
80
src/app/shared/notifications/notifications.service.spec.ts
Normal file
80
src/app/shared/notifications/notifications.service.spec.ts
Normal 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());
|
||||
});
|
||||
|
||||
});
|
77
src/app/shared/notifications/notifications.service.ts
Normal file
77
src/app/shared/notifications/notifications.service.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
9
src/app/shared/notifications/selectors.ts
Normal file
9
src/app/shared/notifications/selectors.ts
Normal 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;
|
@@ -20,11 +20,11 @@ import { SearchResultListElementComponent } from './object-list/search-result-li
|
||||
import { WrapperListElementComponent } from './object-list/wrapper-list-element/wrapper-list-element.component';
|
||||
import { ObjectListComponent } from './object-list/object-list.component';
|
||||
|
||||
import { CollectionGridElementComponent} from './object-grid/collection-grid-element/collection-grid-element.component'
|
||||
import { CommunityGridElementComponent} from './object-grid/community-grid-element/community-grid-element.component'
|
||||
import { ItemGridElementComponent} from './object-grid/item-grid-element/item-grid-element.component'
|
||||
import { AbstractListableElementComponent} from './object-collection/shared/object-collection-element/abstract-listable-element.component'
|
||||
import { WrapperGridElementComponent} from './object-grid/wrapper-grid-element/wrapper-grid-element.component'
|
||||
import { CollectionGridElementComponent } from './object-grid/collection-grid-element/collection-grid-element.component';
|
||||
import { CommunityGridElementComponent } from './object-grid/community-grid-element/community-grid-element.component';
|
||||
import { ItemGridElementComponent } from './object-grid/item-grid-element/item-grid-element.component';
|
||||
import { AbstractListableElementComponent } from './object-collection/shared/object-collection-element/abstract-listable-element.component';
|
||||
import { WrapperGridElementComponent } from './object-grid/wrapper-grid-element/wrapper-grid-element.component';
|
||||
import { ObjectGridComponent } from './object-grid/object-grid.component';
|
||||
import { ObjectCollectionComponent } from './object-collection/object-collection.component';
|
||||
import { ComcolPageContentComponent } from './comcol-page-content/comcol-page-content.component';
|
||||
@@ -43,11 +43,14 @@ import { VarDirective } from './utils/var.directive';
|
||||
import { LogInComponent } from './log-in/log-in.component';
|
||||
import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component';
|
||||
import { LogOutComponent } from './log-out/log-out.component';
|
||||
import { NotificationComponent } from './notifications/notification/notification.component';
|
||||
import { NotificationsBoardComponent } from './notifications/notifications-board/notifications-board.component';
|
||||
import { DragClickDirective } from './utils/drag-click.directive';
|
||||
import { TruncatePipe } from './utils/truncate.pipe';
|
||||
import { TruncatableComponent } from './truncatable/truncatable.component';
|
||||
import { TruncatableService } from './truncatable/truncatable.service';
|
||||
import { TruncatablePartComponent } from './truncatable/truncatable-part/truncatable-part.component';
|
||||
import { MockAdminGuard } from './mocks/mock-admin-guard.service';
|
||||
|
||||
const MODULES = [
|
||||
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
|
||||
@@ -103,11 +106,12 @@ const ENTRY_COMPONENTS = [
|
||||
ItemGridElementComponent,
|
||||
CollectionGridElementComponent,
|
||||
CommunityGridElementComponent,
|
||||
SearchResultGridElementComponent
|
||||
SearchResultGridElementComponent,
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
TruncatableService
|
||||
TruncatableService,
|
||||
MockAdminGuard
|
||||
];
|
||||
|
||||
const DIRECTIVES = [
|
||||
|
46
src/app/shared/testing/notifications-service-stub.ts
Normal file
46
src/app/shared/testing/notifications-service-stub.ts
Normal 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();
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
11
src/config/notifications-config.interfaces.ts
Normal file
11
src/config/notifications-config.interfaces.ts
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user