initial codebase
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route, extract } from '@app/core';
|
||||
import { AboutComponent } from './about.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{ path: 'about', component: AboutComponent, data: { title: extract('About') } }
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
providers: []
|
||||
})
|
||||
export class AboutRoutingModule { }
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="container-fluid">
|
||||
<div class="jumbotron text-center">
|
||||
<h1>
|
||||
<span translate>APP_NAME</span>
|
||||
</h1>
|
||||
<p>
|
||||
<i class="far fa-bookmark"></i> <span translate>Version</span> {{version}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AboutComponent } from './about.component';
|
||||
|
||||
describe('AboutComponent', () => {
|
||||
let component: AboutComponent;
|
||||
let fixture: ComponentFixture<AboutComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [AboutComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(AboutComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about',
|
||||
templateUrl: './about.component.html',
|
||||
styleUrls: ['./about.component.scss']
|
||||
})
|
||||
export class AboutComponent implements OnInit {
|
||||
|
||||
version: string = environment.version;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() { }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { AboutRoutingModule } from './about-routing.module';
|
||||
import { AboutComponent } from './about.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule,
|
||||
AboutRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
AboutComponent
|
||||
]
|
||||
})
|
||||
export class AboutModule { }
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
const routes: Routes = [
|
||||
// Fallback when no prior route is matched
|
||||
{ path: '**', redirectTo: '', pathMatch: 'full' },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule],
|
||||
providers: []
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { TestBed, async } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { CoreModule } from '@app/core';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule,
|
||||
TranslateModule.forRoot(),
|
||||
CoreModule
|
||||
],
|
||||
declarations: [AppComponent],
|
||||
providers: []
|
||||
});
|
||||
TestBed.compileComponents();
|
||||
}));
|
||||
|
||||
it('should create the app', async(() => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { merge } from 'rxjs';
|
||||
import { filter, map, mergeMap } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
import { Logger, I18nService } from '@app/core';
|
||||
|
||||
const log = new Logger('App');
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
|
||||
constructor(private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private titleService: Title,
|
||||
private translateService: TranslateService,
|
||||
private i18nService: I18nService) { }
|
||||
|
||||
ngOnInit() {
|
||||
// Setup logger
|
||||
if (environment.production) {
|
||||
Logger.enableProductionMode();
|
||||
}
|
||||
|
||||
log.debug('init');
|
||||
|
||||
|
||||
// Setup translations
|
||||
this.i18nService.init(environment.defaultLanguage, environment.supportedLanguages);
|
||||
|
||||
const onNavigationEnd = this.router.events.pipe(filter(event => event instanceof NavigationEnd));
|
||||
|
||||
// Change page title on navigation or language change, based on route data
|
||||
merge(this.translateService.onLangChange, onNavigationEnd)
|
||||
.pipe(
|
||||
map(() => {
|
||||
let route = this.activatedRoute;
|
||||
while (route.firstChild) {
|
||||
route = route.firstChild;
|
||||
}
|
||||
return route;
|
||||
}),
|
||||
filter(route => route.outlet === 'primary'),
|
||||
mergeMap(route => route.data)
|
||||
)
|
||||
.subscribe(event => {
|
||||
const title = event['title'];
|
||||
if (title) {
|
||||
this.titleService.setTitle(this.translateService.instant(title));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
import { CoreModule } from '@app/core';
|
||||
import { SharedModule } from '@app/shared';
|
||||
import { HomeModule } from './home/home.module';
|
||||
import { AboutModule } from './about/about.module';
|
||||
import { ClientsModule} from './clients/clients.module'
|
||||
import { LoginModule } from './login/login.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
ServiceWorkerModule.register('./ngsw-worker.js', { enabled: environment.production }),
|
||||
FormsModule,
|
||||
HttpClientModule,
|
||||
TranslateModule.forRoot(),
|
||||
NgbModule.forRoot(),
|
||||
CoreModule,
|
||||
SharedModule,
|
||||
HomeModule,
|
||||
AboutModule,
|
||||
ClientsModule,
|
||||
LoginModule,
|
||||
AppRoutingModule
|
||||
],
|
||||
declarations: [AppComponent,],
|
||||
providers: [
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { extract } from '@app/core';
|
||||
import { ClientsComponent } from './clients.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: 'clients', component: ClientsComponent }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
providers: []
|
||||
})
|
||||
export class ClientsRoutingModule { }
|
||||
@@ -0,0 +1,4 @@
|
||||
<app-header> </app-header>
|
||||
<p>
|
||||
clients works!
|
||||
</p>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
import { HeaderComponent } from '../core/shell/header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-clients',
|
||||
templateUrl: './clients.component.html',
|
||||
styleUrls: ['./clients.component.scss']
|
||||
})
|
||||
export class ClientsComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NgModule,CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ClientsComponent } from './clients.component';
|
||||
import { ClientsRoutingModule } from './clients-routing.module';
|
||||
import { CoreModule } from '../core/core.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CoreModule,
|
||||
CommonModule,
|
||||
ClientsRoutingModule,
|
||||
|
||||
],
|
||||
declarations: [
|
||||
ClientsComponent
|
||||
],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
],
|
||||
|
||||
})
|
||||
export class ClientsModule { }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { AuthenticationService } from './authentication.service';
|
||||
import { MockAuthenticationService } from './authentication.service.mock';
|
||||
import { AuthenticationGuard } from './authentication.guard';
|
||||
|
||||
describe('AuthenticationGuard', () => {
|
||||
let authenticationGuard: AuthenticationGuard;
|
||||
let authenticationService: MockAuthenticationService;
|
||||
let mockRouter: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouter = {
|
||||
navigate: jasmine.createSpy('navigate')
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AuthenticationGuard,
|
||||
{ provide: AuthenticationService, useClass: MockAuthenticationService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(inject([
|
||||
AuthenticationGuard,
|
||||
AuthenticationService
|
||||
], (_authenticationGuard: AuthenticationGuard,
|
||||
_authenticationService: MockAuthenticationService) => {
|
||||
|
||||
authenticationGuard = _authenticationGuard;
|
||||
authenticationService = _authenticationService;
|
||||
}));
|
||||
|
||||
it('should have a canActivate method', () => {
|
||||
expect(typeof authenticationGuard.canActivate).toBe('function');
|
||||
});
|
||||
|
||||
it('should return true if user is authenticated', () => {
|
||||
expect(authenticationGuard.canActivate()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false and redirect to login if user is not authenticated', () => {
|
||||
// Arrange
|
||||
authenticationService.credentials = null;
|
||||
|
||||
// Act
|
||||
const result = authenticationGuard.canActivate();
|
||||
|
||||
// Assert
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(['/login'], {replaceUrl: true});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router, CanActivate } from '@angular/router';
|
||||
|
||||
import { Logger } from '../logger.service';
|
||||
import { AuthenticationService } from './authentication.service';
|
||||
|
||||
const log = new Logger('AuthenticationGuard');
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationGuard implements CanActivate {
|
||||
|
||||
constructor(private router: Router,
|
||||
private authenticationService: AuthenticationService) { }
|
||||
|
||||
canActivate(): boolean {
|
||||
if (this.authenticationService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
log.debug('Not authenticated, redirecting...');
|
||||
this.router.navigate(['/login'], { replaceUrl: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Observable, of } from 'rxjs';
|
||||
|
||||
import { Credentials, LoginContext } from './authentication.service';
|
||||
|
||||
export class MockAuthenticationService {
|
||||
|
||||
credentials: Credentials | null = {
|
||||
username: 'test',
|
||||
token: '123'
|
||||
};
|
||||
|
||||
login(context: LoginContext): Observable<Credentials> {
|
||||
return of({
|
||||
username: context.username,
|
||||
token: '123456'
|
||||
});
|
||||
}
|
||||
|
||||
logout(): Observable<boolean> {
|
||||
this.credentials = null;
|
||||
return of(true);
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { TestBed, inject, fakeAsync, tick } from '@angular/core/testing';
|
||||
|
||||
import { AuthenticationService, Credentials} from './authentication.service';
|
||||
|
||||
const credentialsKey = 'credentials';
|
||||
|
||||
describe('AuthenticationService', () => {
|
||||
let authenticationService: AuthenticationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AuthenticationService]
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(inject([
|
||||
AuthenticationService
|
||||
], (_authenticationService: AuthenticationService) => {
|
||||
authenticationService = _authenticationService;
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
localStorage.removeItem(credentialsKey);
|
||||
sessionStorage.removeItem(credentialsKey);
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should return credentials', fakeAsync(() => {
|
||||
// Act
|
||||
const request = authenticationService.login({
|
||||
username: 'toto',
|
||||
password: '123'
|
||||
});
|
||||
tick();
|
||||
|
||||
// Assert
|
||||
request.subscribe(credentials => {
|
||||
expect(credentials).toBeDefined();
|
||||
expect(credentials.token).toBeDefined();
|
||||
});
|
||||
}));
|
||||
|
||||
it('should authenticate user', fakeAsync(() => {
|
||||
expect(authenticationService.isAuthenticated()).toBe(false);
|
||||
|
||||
// Act
|
||||
const request = authenticationService.login({
|
||||
username: 'toto',
|
||||
password: '123'
|
||||
});
|
||||
tick();
|
||||
|
||||
// Assert
|
||||
request.subscribe(() => {
|
||||
expect(authenticationService.isAuthenticated()).toBe(true);
|
||||
expect(authenticationService.credentials).toBeDefined();
|
||||
expect(authenticationService.credentials).not.toBeNull();
|
||||
expect((<Credentials>authenticationService.credentials).token).toBeDefined();
|
||||
expect((<Credentials>authenticationService.credentials).token).not.toBeNull();
|
||||
});
|
||||
}));
|
||||
|
||||
it('should persist credentials for the session', fakeAsync(() => {
|
||||
// Act
|
||||
const request = authenticationService.login({
|
||||
username: 'toto',
|
||||
password: '123'
|
||||
});
|
||||
tick();
|
||||
|
||||
// Assert
|
||||
request.subscribe(() => {
|
||||
expect(sessionStorage.getItem(credentialsKey)).not.toBeNull();
|
||||
});
|
||||
}));
|
||||
|
||||
it('should persist credentials across sessions', fakeAsync(() => {
|
||||
// Act
|
||||
const request = authenticationService.login({
|
||||
username: 'toto',
|
||||
password: '123',
|
||||
remember: true
|
||||
});
|
||||
tick();
|
||||
|
||||
// Assert
|
||||
request.subscribe(() => {
|
||||
expect(localStorage.getItem(credentialsKey)).not.toBeNull();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should clear user authentication', fakeAsync(() => {
|
||||
// Arrange
|
||||
const loginRequest = authenticationService.login({
|
||||
username: 'toto',
|
||||
password: '123'
|
||||
});
|
||||
tick();
|
||||
|
||||
// Assert
|
||||
loginRequest.subscribe(() => {
|
||||
expect(authenticationService.isAuthenticated()).toBe(true);
|
||||
|
||||
const request = authenticationService.logout();
|
||||
tick();
|
||||
|
||||
request.subscribe(() => {
|
||||
expect(authenticationService.isAuthenticated()).toBe(false);
|
||||
expect(authenticationService.credentials).toBeNull();
|
||||
expect(sessionStorage.getItem(credentialsKey)).toBeNull();
|
||||
expect(localStorage.getItem(credentialsKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should clear persisted user authentication', fakeAsync(() => {
|
||||
// Arrange
|
||||
const loginRequest = authenticationService.login({
|
||||
username: 'toto',
|
||||
password: '123',
|
||||
remember: true
|
||||
});
|
||||
tick();
|
||||
|
||||
// Assert
|
||||
loginRequest.subscribe(() => {
|
||||
expect(authenticationService.isAuthenticated()).toBe(true);
|
||||
|
||||
const request = authenticationService.logout();
|
||||
tick();
|
||||
|
||||
request.subscribe(() => {
|
||||
expect(authenticationService.isAuthenticated()).toBe(false);
|
||||
expect(authenticationService.credentials).toBeNull();
|
||||
expect(sessionStorage.getItem(credentialsKey)).toBeNull();
|
||||
expect(localStorage.getItem(credentialsKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
|
||||
export interface Credentials {
|
||||
// Customize received credentials here
|
||||
username: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface LoginContext {
|
||||
username: string;
|
||||
password: string;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
const credentialsKey = 'credentials';
|
||||
|
||||
/**
|
||||
* Provides a base for authentication workflow.
|
||||
* The Credentials interface as well as login/logout methods should be replaced with proper implementation.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthenticationService {
|
||||
|
||||
private _credentials: Credentials | null;
|
||||
|
||||
constructor() {
|
||||
const savedCredentials = sessionStorage.getItem(credentialsKey) || localStorage.getItem(credentialsKey);
|
||||
if (savedCredentials) {
|
||||
this._credentials = JSON.parse(savedCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates the user.
|
||||
* @param {LoginContext} context The login parameters.
|
||||
* @return {Observable<Credentials>} The user credentials.
|
||||
*/
|
||||
login(context: LoginContext): Observable<Credentials> {
|
||||
// Replace by proper authentication call
|
||||
const data = {
|
||||
username: context.username,
|
||||
token: '123456'
|
||||
};
|
||||
this.setCredentials(data, context.remember);
|
||||
return of(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the user and clear credentials.
|
||||
* @return {Observable<boolean>} True if the user was logged out successfully.
|
||||
*/
|
||||
logout(): Observable<boolean> {
|
||||
// Customize credentials invalidation here
|
||||
this.setCredentials();
|
||||
return of(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks is the user is authenticated.
|
||||
* @return {boolean} True if the user is authenticated.
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user credentials.
|
||||
* @return {Credentials} The user credentials or null if the user is not authenticated.
|
||||
*/
|
||||
get credentials(): Credentials | null {
|
||||
return this._credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user credentials.
|
||||
* The credentials may be persisted across sessions by setting the `remember` parameter to true.
|
||||
* Otherwise, the credentials are only persisted for the current session.
|
||||
* @param {Credentials=} credentials The user credentials.
|
||||
* @param {boolean=} remember True to remember credentials across sessions.
|
||||
*/
|
||||
private setCredentials(credentials?: Credentials, remember?: boolean) {
|
||||
this._credentials = credentials || null;
|
||||
|
||||
if (credentials) {
|
||||
const storage = remember ? localStorage : sessionStorage;
|
||||
storage.setItem(credentialsKey, JSON.stringify(credentials));
|
||||
} else {
|
||||
sessionStorage.removeItem(credentialsKey);
|
||||
localStorage.removeItem(credentialsKey);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NgModule, Optional, SkipSelf } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HTTP_INTERCEPTORS, HttpClient, HttpClientModule } from '@angular/common/http';
|
||||
import { RouteReuseStrategy, RouterModule } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
import { ShellComponent } from './shell/shell.component';
|
||||
import { HeaderComponent } from './shell/header/header.component';
|
||||
import { RouteReusableStrategy } from './route-reusable-strategy';
|
||||
import { AuthenticationService } from './authentication/authentication.service';
|
||||
import { AuthenticationGuard } from './authentication/authentication.guard';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { HttpService } from './http/http.service';
|
||||
import { HttpCacheService } from './http/http-cache.service';
|
||||
import { ApiPrefixInterceptor } from './http/api-prefix.interceptor';
|
||||
import { ErrorHandlerInterceptor } from './http/error-handler.interceptor';
|
||||
import { CacheInterceptor } from './http/cache.interceptor';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
HttpClientModule,
|
||||
TranslateModule,
|
||||
NgbModule,
|
||||
RouterModule
|
||||
],
|
||||
|
||||
declarations: [
|
||||
HeaderComponent,
|
||||
ShellComponent
|
||||
],
|
||||
exports: [
|
||||
HeaderComponent],
|
||||
|
||||
providers: [
|
||||
AuthenticationService,
|
||||
AuthenticationGuard,
|
||||
I18nService,
|
||||
HttpCacheService,
|
||||
ApiPrefixInterceptor,
|
||||
ErrorHandlerInterceptor,
|
||||
CacheInterceptor,
|
||||
{
|
||||
provide: HttpClient,
|
||||
useClass: HttpService
|
||||
},
|
||||
{
|
||||
provide: RouteReuseStrategy,
|
||||
useClass: RouteReusableStrategy
|
||||
}
|
||||
]
|
||||
})
|
||||
export class CoreModule {
|
||||
|
||||
constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
|
||||
// Import guard
|
||||
if (parentModule) {
|
||||
throw new Error(`${parentModule} has already been loaded. Import Core module in the AppModule only.`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
import { ApiPrefixInterceptor } from './api-prefix.interceptor';
|
||||
|
||||
describe('ApiPrefixInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: ApiPrefixInterceptor,
|
||||
multi: true
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(inject([
|
||||
HttpClient,
|
||||
HttpTestingController
|
||||
], (_http: HttpClient,
|
||||
_httpMock: HttpTestingController) => {
|
||||
|
||||
http = _http;
|
||||
httpMock = _httpMock;
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should prepend environment.serverUrl to the request url', () => {
|
||||
// Act
|
||||
http.get('/toto').subscribe();
|
||||
|
||||
// Assert
|
||||
httpMock.expectOne({ url: environment.serverUrl + '/toto' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
/**
|
||||
* Prefixes all requests with `environment.serverUrl`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApiPrefixInterceptor implements HttpInterceptor {
|
||||
|
||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
request = request.clone({ url: environment.serverUrl + request.url });
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient, HttpResponse } from '@angular/common/http';
|
||||
|
||||
import { CacheInterceptor } from './cache.interceptor';
|
||||
import { HttpCacheService } from './http-cache.service';
|
||||
|
||||
describe('CacheInterceptor', () => {
|
||||
let interceptorOptions: Object | null = {};
|
||||
let httpCacheService: HttpCacheService;
|
||||
let cacheInterceptor: CacheInterceptor;
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
function createInterceptor(_httpCacheService: HttpCacheService) {
|
||||
cacheInterceptor = new CacheInterceptor(_httpCacheService).configure(interceptorOptions);
|
||||
return cacheInterceptor;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
HttpCacheService,
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useFactory: createInterceptor,
|
||||
deps: [HttpCacheService],
|
||||
multi: true
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpCacheService.cleanCache();
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
describe('with default configuration', () => {
|
||||
beforeEach(() => {
|
||||
interceptorOptions = null;
|
||||
});
|
||||
|
||||
beforeEach(inject([
|
||||
HttpClient,
|
||||
HttpTestingController,
|
||||
HttpCacheService
|
||||
], (_http: HttpClient,
|
||||
_httpMock: HttpTestingController,
|
||||
_httpCacheService: HttpCacheService) => {
|
||||
|
||||
http = _http;
|
||||
httpMock = _httpMock;
|
||||
httpCacheService = _httpCacheService;
|
||||
}));
|
||||
|
||||
it('should cache the request', () => {
|
||||
// Act
|
||||
http.get('/toto').subscribe(() => {
|
||||
// Assert
|
||||
const cachedData = httpCacheService.getCacheData('/toto');
|
||||
expect(cachedData).toBeDefined();
|
||||
expect(cachedData ? cachedData.body : null).toEqual('someData');
|
||||
});
|
||||
|
||||
httpMock.expectOne({ url: '/toto' }).flush('someData');
|
||||
});
|
||||
|
||||
it('should respond from the cache', () => {
|
||||
// Arrange
|
||||
httpCacheService.setCacheData('/toto', new HttpResponse({ body: 'cachedData' }));
|
||||
|
||||
// Act
|
||||
http.get('/toto').subscribe(response => {
|
||||
// Assert
|
||||
expect(response).toEqual('cachedData');
|
||||
});
|
||||
|
||||
httpMock.expectNone({ url: '/toto' });
|
||||
});
|
||||
|
||||
it('should not cache the request in case of error', () => {
|
||||
// Act
|
||||
http.get('/toto').subscribe(() => {}, () => {
|
||||
// Assert
|
||||
expect(httpCacheService.getCacheData('/toto')).toBeNull();
|
||||
});
|
||||
|
||||
httpMock.expectOne({}).flush(null, {
|
||||
status: 404,
|
||||
statusText: 'error'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with update forced configuration', () => {
|
||||
beforeEach(() => {
|
||||
interceptorOptions = { update: true };
|
||||
});
|
||||
|
||||
beforeEach(inject([
|
||||
HttpClient,
|
||||
HttpTestingController,
|
||||
HttpCacheService
|
||||
], (_http: HttpClient,
|
||||
_httpMock: HttpTestingController,
|
||||
_httpCacheService: HttpCacheService) => {
|
||||
|
||||
http = _http;
|
||||
httpMock = _httpMock;
|
||||
httpCacheService = _httpCacheService;
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
httpCacheService.cleanCache();
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should force cache update', () => {
|
||||
// Arrange
|
||||
httpCacheService.setCacheData('/toto', new HttpResponse({ body: 'oldCachedData' }));
|
||||
cacheInterceptor.configure({ update: true });
|
||||
|
||||
// Act
|
||||
http.get('/toto').subscribe(response => {
|
||||
// Assert
|
||||
expect(response).toEqual('newData');
|
||||
|
||||
});
|
||||
|
||||
httpMock.expectOne({ url: '/toto' }).flush('newData');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||
import { Observable, Subscriber } from 'rxjs';
|
||||
|
||||
import { HttpCacheService } from './http-cache.service';
|
||||
|
||||
/**
|
||||
* Caches HTTP requests.
|
||||
* Use ExtendedHttpClient fluent API to configure caching for each request.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CacheInterceptor implements HttpInterceptor {
|
||||
|
||||
private forceUpdate = false;
|
||||
|
||||
constructor(private httpCacheService: HttpCacheService) { }
|
||||
|
||||
/**
|
||||
* Configures interceptor options
|
||||
* @param {{update: boolean}} options If update option is enabled, forces request to be made and updates cache entry.
|
||||
* @return {CacheInterceptor} The configured instance.
|
||||
*/
|
||||
configure(options?: { update?: boolean } | null): CacheInterceptor {
|
||||
const instance = new CacheInterceptor(this.httpCacheService);
|
||||
if (options && options.update) {
|
||||
instance.forceUpdate = true;
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
if (request.method !== 'GET') {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
return new Observable((subscriber: Subscriber<HttpEvent<any>>) => {
|
||||
const cachedData = this.forceUpdate ? null : this.httpCacheService.getCacheData(request.urlWithParams);
|
||||
if (cachedData !== null) {
|
||||
// Create new response to avoid side-effects
|
||||
subscriber.next(new HttpResponse(cachedData as Object));
|
||||
subscriber.complete();
|
||||
} else {
|
||||
next.handle(request)
|
||||
.subscribe(
|
||||
event => {
|
||||
if (event instanceof HttpResponse) {
|
||||
this.httpCacheService.setCacheData(request.urlWithParams, event);
|
||||
}
|
||||
subscriber.next(event);
|
||||
},
|
||||
error => subscriber.error(error),
|
||||
() => subscriber.complete()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
|
||||
import { ErrorHandlerInterceptor } from './error-handler.interceptor';
|
||||
|
||||
describe('ErrorHandlerInterceptor', () => {
|
||||
let errorHandlerInterceptor: ErrorHandlerInterceptor;
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
function createInterceptor() {
|
||||
errorHandlerInterceptor = new ErrorHandlerInterceptor();
|
||||
return errorHandlerInterceptor;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useFactory: createInterceptor,
|
||||
multi: true
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(inject([
|
||||
HttpClient,
|
||||
HttpTestingController
|
||||
], (_http: HttpClient,
|
||||
_httpMock: HttpTestingController) => {
|
||||
|
||||
http = _http;
|
||||
httpMock = _httpMock;
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should catch error and call error handler', () => {
|
||||
// Arrange
|
||||
// Note: here we spy on private method since target is customization here,
|
||||
// but you should replace it by actual behavior in your app
|
||||
spyOn(ErrorHandlerInterceptor.prototype as any, 'errorHandler').and.callThrough();
|
||||
|
||||
// Act
|
||||
http.get('/toto').subscribe(() => fail('should error'), () => {
|
||||
// Assert
|
||||
expect(ErrorHandlerInterceptor.prototype['errorHandler']).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
httpMock.expectOne({}).flush(null, {
|
||||
status: 404,
|
||||
statusText: 'error'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
import { Logger } from '../logger.service';
|
||||
|
||||
const log = new Logger('ErrorHandlerInterceptor');
|
||||
|
||||
/**
|
||||
* Adds a default error handler to all requests.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
|
||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
return next.handle(request).pipe(catchError(error => this.errorHandler(error)));
|
||||
}
|
||||
|
||||
// Customize the default error handler here if needed
|
||||
private errorHandler(response: HttpEvent<any>): Observable<HttpEvent<any>> {
|
||||
if (!environment.production) {
|
||||
// Do something with the error
|
||||
log.error('Request error', response);
|
||||
}
|
||||
throw response;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
import { HttpResponse } from '@angular/common/http';
|
||||
|
||||
import { HttpCacheService, HttpCacheEntry } from './http-cache.service';
|
||||
|
||||
const cachePersistenceKey = 'httpCache';
|
||||
|
||||
describe('HttpCacheService', () => {
|
||||
let httpCacheService: HttpCacheService;
|
||||
let response: HttpResponse<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [HttpCacheService]
|
||||
});
|
||||
|
||||
// Start fresh
|
||||
window.sessionStorage.removeItem(cachePersistenceKey);
|
||||
window.localStorage.removeItem(cachePersistenceKey);
|
||||
});
|
||||
|
||||
beforeEach(inject([HttpCacheService], (_httpCacheService: HttpCacheService) => {
|
||||
httpCacheService = _httpCacheService;
|
||||
|
||||
response = new HttpResponse({ body: 'data' });
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
httpCacheService.cleanCache();
|
||||
});
|
||||
|
||||
describe('setCacheData', () => {
|
||||
it('should set cache data', () => {
|
||||
// Act
|
||||
httpCacheService.setCacheData('/popo', response);
|
||||
|
||||
// Assert
|
||||
expect(httpCacheService.getCacheData('/popo')).toEqual(response);
|
||||
});
|
||||
|
||||
it('should replace existing data', () => {
|
||||
// Arrange
|
||||
const newResponse = new HttpResponse({ body: 'new data' });
|
||||
|
||||
// Act
|
||||
httpCacheService.setCacheData('/popo', response);
|
||||
httpCacheService.setCacheData('/popo', newResponse);
|
||||
|
||||
// Assert
|
||||
expect(httpCacheService.getCacheData('/popo')).toEqual(newResponse);
|
||||
});
|
||||
|
||||
it('should set cache date correctly', () => {
|
||||
// Act
|
||||
const date = new Date(123);
|
||||
httpCacheService.setCacheData('/popo', response, date);
|
||||
httpCacheService.setCacheData('/hoho', response);
|
||||
|
||||
// Assert
|
||||
expect((<HttpCacheEntry>httpCacheService.getHttpCacheEntry('/popo')).lastUpdated).toBe(date);
|
||||
expect((<HttpCacheEntry>httpCacheService.getHttpCacheEntry('/hoho')).lastUpdated).not.toBe(date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCacheData', () => {
|
||||
it('should return null if no cache', () => {
|
||||
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return cached data if exists', () => {
|
||||
// Act
|
||||
httpCacheService.setCacheData('/hoho', response);
|
||||
|
||||
// Assert
|
||||
expect(httpCacheService.getCacheData('/hoho')).toEqual(response);
|
||||
});
|
||||
|
||||
it('should return cached data with url parameters if exists', () => {
|
||||
// Act
|
||||
httpCacheService.setCacheData('/hoho?pif=paf', response);
|
||||
|
||||
// Assert
|
||||
expect(httpCacheService.getCacheData('/hoho?pif=paf')).toEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHttpCacheEntry', () => {
|
||||
it('should return null if no cache', () => {
|
||||
expect(httpCacheService.getHttpCacheEntry('/hoho')).toBe(null);
|
||||
});
|
||||
|
||||
it('should return cached data date if exists', () => {
|
||||
// Arrange
|
||||
const date = new Date(123);
|
||||
|
||||
// Act
|
||||
httpCacheService.setCacheData('/hoho', response, date);
|
||||
const entry = httpCacheService.getHttpCacheEntry('/hoho');
|
||||
|
||||
// Assert
|
||||
expect(entry).not.toBeNull();
|
||||
expect((<HttpCacheEntry>entry).lastUpdated).toEqual(date);
|
||||
expect((<HttpCacheEntry>entry).data).toEqual(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCacheData', () => {
|
||||
it('should clear existing cache data', () => {
|
||||
// Set cache
|
||||
httpCacheService.setCacheData('/hoho', response);
|
||||
expect(httpCacheService.getCacheData('/hoho')).toEqual(response);
|
||||
|
||||
// Clear cache
|
||||
httpCacheService.clearCache('/hoho');
|
||||
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
|
||||
});
|
||||
|
||||
it('should do nothing if no cache exists', () => {
|
||||
expect(httpCacheService.getCacheData('/lolo')).toBe(null);
|
||||
httpCacheService.clearCache('/hoho');
|
||||
expect(httpCacheService.getCacheData('/lolo')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanCache', () => {
|
||||
it('should clear all cache if no date is specified', () => {
|
||||
// Set cache
|
||||
httpCacheService.setCacheData('/hoho', response);
|
||||
httpCacheService.setCacheData('/popo', response);
|
||||
expect(httpCacheService.getCacheData('/hoho')).toBe(response);
|
||||
expect(httpCacheService.getCacheData('/popo')).toBe(response);
|
||||
|
||||
// Clean cache
|
||||
httpCacheService.cleanCache();
|
||||
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
|
||||
expect(httpCacheService.getCacheData('/popo')).toBe(null);
|
||||
});
|
||||
|
||||
it('should clear existing since specified date', () => {
|
||||
// Set cache
|
||||
httpCacheService.setCacheData('/hoho', response);
|
||||
expect(httpCacheService.getCacheData('/hoho')).toBe(response);
|
||||
|
||||
// Clean cache
|
||||
httpCacheService.cleanCache(new Date());
|
||||
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
|
||||
});
|
||||
|
||||
it('should not affect cache entries newer than specified date', () => {
|
||||
// Set cache
|
||||
httpCacheService.setCacheData('/hoho', response);
|
||||
expect(httpCacheService.getCacheData('/hoho')).toBe(response);
|
||||
|
||||
// Clean cache
|
||||
const date = new Date();
|
||||
httpCacheService.setCacheData('/lolo', response, new Date(date.getTime() + 10));
|
||||
httpCacheService.cleanCache(date);
|
||||
|
||||
// Assert
|
||||
expect(httpCacheService.getCacheData('/hoho')).toBe(null);
|
||||
expect(httpCacheService.getCacheData('/lolo')).toBe(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPersistence', () => {
|
||||
beforeEach(() => {
|
||||
httpCacheService.setPersistence();
|
||||
httpCacheService.cleanCache = jasmine.createSpy('cleanCache');
|
||||
});
|
||||
|
||||
it('should clear previous cache data when persistence value change', () => {
|
||||
httpCacheService.setPersistence('local');
|
||||
expect(httpCacheService.cleanCache).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should persist cache to local storage', () => {
|
||||
expect(localStorage.getItem(cachePersistenceKey)).toBeNull();
|
||||
|
||||
httpCacheService.setPersistence('local');
|
||||
httpCacheService.setCacheData('/hoho', response);
|
||||
|
||||
expect(localStorage.getItem(cachePersistenceKey)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should persist cache to session storage', () => {
|
||||
expect(sessionStorage.getItem(cachePersistenceKey)).toBeNull();
|
||||
|
||||
httpCacheService.setPersistence('session');
|
||||
httpCacheService.setCacheData('/hoho', response);
|
||||
|
||||
expect(sessionStorage.getItem(cachePersistenceKey)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpResponse } from '@angular/common/http';
|
||||
import { each } from 'lodash';
|
||||
|
||||
import { Logger } from '../logger.service';
|
||||
|
||||
const log = new Logger('HttpCacheService');
|
||||
const cachePersistenceKey = 'httpCache';
|
||||
|
||||
export interface HttpCacheEntry {
|
||||
lastUpdated: Date;
|
||||
data: HttpResponse<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a cache facility for HTTP requests with configurable persistence policy.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpCacheService {
|
||||
|
||||
private cachedData: { [key: string]: HttpCacheEntry; } = {};
|
||||
private storage: Storage | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadCacheData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cache data for the specified request.
|
||||
* @param {!string} url The request URL.
|
||||
* @param {ResponseOptions} data The received data.
|
||||
* @param {Date=} lastUpdated The cache last update, current date is used if not specified.
|
||||
*/
|
||||
setCacheData(url: string, data: HttpResponse<any>, lastUpdated?: Date) {
|
||||
this.cachedData[url] = {
|
||||
lastUpdated: lastUpdated || new Date(),
|
||||
data: data
|
||||
};
|
||||
log.debug(`Cache set for key: "${url}"`);
|
||||
this.saveCacheData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cached data for the specified request.
|
||||
* @param {!string} url The request URL.
|
||||
* @return {?ResponseOptions} The cached data or null if no cached data exists for this request.
|
||||
*/
|
||||
getCacheData(url: string): HttpResponse<any> | null {
|
||||
const cacheEntry = this.cachedData[url];
|
||||
|
||||
if (cacheEntry) {
|
||||
log.debug(`Cache hit for key: "${url}"`);
|
||||
return cacheEntry.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cached entry for the specified request.
|
||||
* @param {!string} url The request URL.
|
||||
* @return {?HttpCacheEntry} The cache entry or null if no cache entry exists for this request.
|
||||
*/
|
||||
getHttpCacheEntry(url: string): HttpCacheEntry | null {
|
||||
return this.cachedData[url] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached entry (if exists) for the specified request.
|
||||
* @param {!string} url The request URL.
|
||||
*/
|
||||
clearCache(url: string): void {
|
||||
delete this.cachedData[url];
|
||||
log.debug(`Cache cleared for key: "${url}"`);
|
||||
this.saveCacheData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans cache entries older than the specified date.
|
||||
* @param {date=} expirationDate The cache expiration date. If no date is specified, all cache is cleared.
|
||||
*/
|
||||
cleanCache(expirationDate?: Date) {
|
||||
if (expirationDate) {
|
||||
each(this.cachedData, (value: HttpCacheEntry, key: string) => {
|
||||
if (expirationDate >= value.lastUpdated) {
|
||||
delete this.cachedData[key];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.cachedData = {};
|
||||
}
|
||||
this.saveCacheData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cache persistence policy.
|
||||
* Note that changing the cache persistence will also clear the cache from its previous storage.
|
||||
* @param {'local'|'session'=} persistence How the cache should be persisted, it can be either local or session
|
||||
* storage, or if no value is provided it will be only in-memory (default).
|
||||
*/
|
||||
setPersistence(persistence?: 'local' | 'session') {
|
||||
this.cleanCache();
|
||||
this.storage = persistence === 'local' || persistence === 'session' ? window[persistence + 'Storage'] : null;
|
||||
this.loadCacheData();
|
||||
}
|
||||
|
||||
private saveCacheData() {
|
||||
if (this.storage) {
|
||||
this.storage[cachePersistenceKey] = JSON.stringify(this.cachedData);
|
||||
}
|
||||
}
|
||||
|
||||
private loadCacheData() {
|
||||
const data = this.storage ? this.storage[cachePersistenceKey] : null;
|
||||
this.cachedData = data ? JSON.parse(data) : {};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpClient, HttpInterceptor } from '@angular/common/http';
|
||||
|
||||
import { HttpService } from './http.service';
|
||||
import { HttpCacheService } from './http-cache.service';
|
||||
import { ErrorHandlerInterceptor } from './error-handler.interceptor';
|
||||
import { CacheInterceptor } from './cache.interceptor';
|
||||
import { ApiPrefixInterceptor } from './api-prefix.interceptor';
|
||||
|
||||
describe('HttpService', () => {
|
||||
let httpCacheService: HttpCacheService;
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
ErrorHandlerInterceptor,
|
||||
CacheInterceptor,
|
||||
ApiPrefixInterceptor,
|
||||
HttpCacheService,
|
||||
{
|
||||
provide: HttpClient,
|
||||
useClass: HttpService
|
||||
},
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(inject([
|
||||
HttpClient,
|
||||
HttpTestingController,
|
||||
HttpCacheService
|
||||
], (_http: HttpClient,
|
||||
_httpMock: HttpTestingController,
|
||||
_httpCacheService: HttpCacheService) => {
|
||||
|
||||
http = _http;
|
||||
httpMock = _httpMock;
|
||||
httpCacheService = _httpCacheService;
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
httpCacheService.cleanCache();
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should use error handler, API prefix and no cache by default', () => {
|
||||
// Arrange
|
||||
let interceptors: HttpInterceptor[];
|
||||
const realRequest = http.request;
|
||||
spyOn(HttpService.prototype, 'request').and.callFake(function(this: any) {
|
||||
interceptors = this.interceptors;
|
||||
return realRequest.apply(this, arguments);
|
||||
});
|
||||
|
||||
// Act
|
||||
const request = http.get('/toto');
|
||||
|
||||
// Assert
|
||||
request.subscribe(() => {
|
||||
expect(http.request).toHaveBeenCalled();
|
||||
expect(interceptors.some(i => i instanceof ApiPrefixInterceptor)).toBeTruthy();
|
||||
expect(interceptors.some(i => i instanceof ErrorHandlerInterceptor)).toBeTruthy();
|
||||
expect(interceptors.some(i => i instanceof CacheInterceptor)).toBeFalsy();
|
||||
});
|
||||
httpMock.expectOne({}).flush({});
|
||||
});
|
||||
|
||||
it('should use cache', () => {
|
||||
// Arrange
|
||||
let interceptors: HttpInterceptor[];
|
||||
const realRequest = http.request;
|
||||
spyOn(HttpService.prototype, 'request').and.callFake(function(this: any) {
|
||||
interceptors = this.interceptors;
|
||||
return realRequest.apply(this, arguments);
|
||||
});
|
||||
|
||||
// Act
|
||||
const request = http
|
||||
.cache()
|
||||
.get('/toto');
|
||||
|
||||
// Assert
|
||||
request.subscribe(() => {
|
||||
expect(interceptors.some(i => i instanceof ApiPrefixInterceptor)).toBeTruthy();
|
||||
expect(interceptors.some(i => i instanceof ErrorHandlerInterceptor)).toBeTruthy();
|
||||
expect(interceptors.some(i => i instanceof CacheInterceptor)).toBeTruthy();
|
||||
});
|
||||
httpMock.expectOne({}).flush({});
|
||||
});
|
||||
|
||||
it('should skip error handler', () => {
|
||||
// Arrange
|
||||
let interceptors: HttpInterceptor[];
|
||||
const realRequest = http.request;
|
||||
spyOn(HttpService.prototype, 'request').and.callFake(function(this: any) {
|
||||
interceptors = this.interceptors;
|
||||
return realRequest.apply(this, arguments);
|
||||
});
|
||||
|
||||
// Act
|
||||
const request = http
|
||||
.skipErrorHandler()
|
||||
.get('/toto');
|
||||
|
||||
// Assert
|
||||
request.subscribe(() => {
|
||||
expect(interceptors.some(i => i instanceof ApiPrefixInterceptor)).toBeTruthy();
|
||||
expect(interceptors.some(i => i instanceof ErrorHandlerInterceptor)).toBeFalsy();
|
||||
expect(interceptors.some(i => i instanceof CacheInterceptor)).toBeFalsy();
|
||||
});
|
||||
httpMock.expectOne({}).flush({});
|
||||
});
|
||||
|
||||
it('should not use API prefix', () => {
|
||||
// Arrange
|
||||
let interceptors: HttpInterceptor[];
|
||||
const realRequest = http.request;
|
||||
spyOn(HttpService.prototype, 'request').and.callFake(function(this: any) {
|
||||
interceptors = this.interceptors;
|
||||
return realRequest.apply(this, arguments);
|
||||
});
|
||||
|
||||
// Act
|
||||
const request = http
|
||||
.disableApiPrefix()
|
||||
.get('/toto');
|
||||
|
||||
// Assert
|
||||
request.subscribe(() => {
|
||||
expect(interceptors.some(i => i instanceof ApiPrefixInterceptor)).toBeFalsy();
|
||||
expect(interceptors.some(i => i instanceof ErrorHandlerInterceptor)).toBeTruthy();
|
||||
expect(interceptors.some(i => i instanceof CacheInterceptor)).toBeFalsy();
|
||||
});
|
||||
httpMock.expectOne({}).flush({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Inject, Injectable, InjectionToken, Injector, Optional } from '@angular/core';
|
||||
import { HttpClient, HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { ErrorHandlerInterceptor } from './error-handler.interceptor';
|
||||
import { CacheInterceptor } from './cache.interceptor';
|
||||
import { ApiPrefixInterceptor } from './api-prefix.interceptor';
|
||||
|
||||
// HttpClient is declared in a re-exported module, so we have to extend the original module to make it work properly
|
||||
// (see https://github.com/Microsoft/TypeScript/issues/13897)
|
||||
declare module '@angular/common/http/src/client' {
|
||||
|
||||
// Augment HttpClient with the added configuration methods from HttpService, to allow in-place replacement of
|
||||
// HttpClient with HttpService using dependency injection
|
||||
export interface HttpClient {
|
||||
|
||||
/**
|
||||
* Enables caching for this request.
|
||||
* @param {boolean} forceUpdate Forces request to be made and updates cache entry.
|
||||
* @return {HttpClient} The new instance.
|
||||
*/
|
||||
cache(forceUpdate?: boolean): HttpClient;
|
||||
|
||||
/**
|
||||
* Skips default error handler for this request.
|
||||
* @return {HttpClient} The new instance.
|
||||
*/
|
||||
skipErrorHandler(): HttpClient;
|
||||
|
||||
/**
|
||||
* Do not use API prefix for this request.
|
||||
* @return {HttpClient} The new instance.
|
||||
*/
|
||||
disableApiPrefix(): HttpClient;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// From @angular/common/http/src/interceptor: allows to chain interceptors
|
||||
class HttpInterceptorHandler implements HttpHandler {
|
||||
|
||||
constructor(private next: HttpHandler, private interceptor: HttpInterceptor) { }
|
||||
|
||||
handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
|
||||
return this.interceptor.intercept(request, this.next);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows to override default dynamic interceptors that can be disabled with the HttpService extension.
|
||||
* Except for very specific needs, you should better configure these interceptors directly in the constructor below
|
||||
* for better readability.
|
||||
*
|
||||
* For static interceptors that should always be enabled (like ApiPrefixInterceptor), use the standard
|
||||
* HTTP_INTERCEPTORS token.
|
||||
*/
|
||||
export const HTTP_DYNAMIC_INTERCEPTORS = new InjectionToken<HttpInterceptor>('HTTP_DYNAMIC_INTERCEPTORS');
|
||||
|
||||
/**
|
||||
* Extends HttpClient with per request configuration using dynamic interceptors.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpService extends HttpClient {
|
||||
|
||||
constructor(private httpHandler: HttpHandler,
|
||||
private injector: Injector,
|
||||
@Optional() @Inject(HTTP_DYNAMIC_INTERCEPTORS) private interceptors: HttpInterceptor[] = []) {
|
||||
super(httpHandler);
|
||||
|
||||
if (!this.interceptors) {
|
||||
// Configure default interceptors that can be disabled here
|
||||
this.interceptors = [
|
||||
this.injector.get(ApiPrefixInterceptor),
|
||||
this.injector.get(ErrorHandlerInterceptor)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
cache(forceUpdate?: boolean): HttpClient {
|
||||
const cacheInterceptor = this.injector.get(CacheInterceptor).configure({ update: forceUpdate });
|
||||
return this.addInterceptor(cacheInterceptor);
|
||||
}
|
||||
|
||||
skipErrorHandler(): HttpClient {
|
||||
return this.removeInterceptor(ErrorHandlerInterceptor);
|
||||
}
|
||||
|
||||
disableApiPrefix(): HttpClient {
|
||||
return this.removeInterceptor(ApiPrefixInterceptor);
|
||||
}
|
||||
|
||||
// Override the original method to wire interceptors when triggering the request.
|
||||
request(method?: any, url?: any, options?: any): any {
|
||||
const handler = this.interceptors.reduceRight(
|
||||
(next, interceptor) => new HttpInterceptorHandler(next, interceptor),
|
||||
this.httpHandler
|
||||
);
|
||||
return new HttpClient(handler).request(method, url, options);
|
||||
}
|
||||
|
||||
private removeInterceptor(interceptorType: Function): HttpService {
|
||||
return new HttpService(
|
||||
this.httpHandler,
|
||||
this.injector,
|
||||
this.interceptors.filter(i => !(i instanceof interceptorType))
|
||||
);
|
||||
}
|
||||
|
||||
private addInterceptor(interceptor: HttpInterceptor): HttpService {
|
||||
return new HttpService(
|
||||
this.httpHandler,
|
||||
this.injector,
|
||||
this.interceptors.concat([interceptor])
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { extract, I18nService } from './i18n.service';
|
||||
|
||||
const defaultLanguage = 'en-US';
|
||||
const supportedLanguages = ['eo', 'en-US', 'fr-FR'];
|
||||
|
||||
class MockTranslateService {
|
||||
|
||||
currentLang: string;
|
||||
onLangChange = new Subject();
|
||||
|
||||
use(language: string) {
|
||||
this.currentLang = language;
|
||||
this.onLangChange.next({
|
||||
lang: this.currentLang,
|
||||
translations: {}
|
||||
});
|
||||
}
|
||||
|
||||
getBrowserCultureLang() {
|
||||
return 'en-US';
|
||||
}
|
||||
|
||||
setTranslation(lang: string, translations: Object, shouldMerge?: boolean) { }
|
||||
|
||||
}
|
||||
|
||||
describe('I18nService', () => {
|
||||
let i18nService: I18nService;
|
||||
let translateService: TranslateService;
|
||||
let onLangChangeSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
I18nService,
|
||||
{ provide: TranslateService, useClass: MockTranslateService },
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(inject([
|
||||
I18nService,
|
||||
TranslateService
|
||||
], (_i18nService: I18nService,
|
||||
_translateService: TranslateService) => {
|
||||
|
||||
i18nService = _i18nService;
|
||||
translateService = _translateService;
|
||||
|
||||
// Create spies
|
||||
onLangChangeSpy = jasmine.createSpy('onLangChangeSpy');
|
||||
translateService.onLangChange
|
||||
.subscribe((event: LangChangeEvent) => {
|
||||
onLangChangeSpy(event.lang);
|
||||
});
|
||||
spyOn(translateService, 'use').and.callThrough();
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
localStorage.removeItem('language');
|
||||
});
|
||||
|
||||
describe('extract', () => {
|
||||
it('should not modify string', () => {
|
||||
expect(extract('Hello world !')).toEqual('Hello world !');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should init with default language', () => {
|
||||
// Act
|
||||
i18nService.init(defaultLanguage, supportedLanguages);
|
||||
|
||||
// Assert
|
||||
expect(translateService.use).toHaveBeenCalledWith(defaultLanguage);
|
||||
expect(onLangChangeSpy).toHaveBeenCalledWith(defaultLanguage);
|
||||
});
|
||||
|
||||
it('should init with save language', () => {
|
||||
// Arrange
|
||||
const savedLanguage = 'eo';
|
||||
localStorage.setItem('language', savedLanguage);
|
||||
|
||||
// Act
|
||||
i18nService.init(defaultLanguage, supportedLanguages);
|
||||
|
||||
// Assert
|
||||
expect(translateService.use).toHaveBeenCalledWith(savedLanguage);
|
||||
expect(onLangChangeSpy).toHaveBeenCalledWith(savedLanguage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('set language', () => {
|
||||
it('should change current language', () => {
|
||||
// Arrange
|
||||
const newLanguage = 'eo';
|
||||
i18nService.init(defaultLanguage, supportedLanguages);
|
||||
|
||||
// Act
|
||||
i18nService.language = newLanguage;
|
||||
|
||||
// Assert
|
||||
expect(translateService.use).toHaveBeenCalledWith(newLanguage);
|
||||
expect(onLangChangeSpy).toHaveBeenCalledWith(newLanguage);
|
||||
});
|
||||
|
||||
it('should change current language without a region match', () => {
|
||||
// Arrange
|
||||
const newLanguage = 'fr-CA';
|
||||
i18nService.init(defaultLanguage, supportedLanguages);
|
||||
|
||||
// Act
|
||||
i18nService.language = newLanguage;
|
||||
|
||||
// Assert
|
||||
expect(translateService.use).toHaveBeenCalledWith('fr-FR');
|
||||
expect(onLangChangeSpy).toHaveBeenCalledWith('fr-FR');
|
||||
});
|
||||
|
||||
it('should change current language to default if unsupported', () => {
|
||||
// Arrange
|
||||
const newLanguage = 'es';
|
||||
i18nService.init(defaultLanguage, supportedLanguages);
|
||||
|
||||
// Act
|
||||
i18nService.language = newLanguage;
|
||||
|
||||
// Assert
|
||||
expect(translateService.use).toHaveBeenCalledWith(defaultLanguage);
|
||||
expect(onLangChangeSpy).toHaveBeenCalledWith(defaultLanguage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get language', () => {
|
||||
it('should return current language', () => {
|
||||
// Arrange
|
||||
i18nService.init(defaultLanguage, supportedLanguages);
|
||||
|
||||
// Act
|
||||
const currentLanguage = i18nService.language;
|
||||
|
||||
// Assert
|
||||
expect(currentLanguage).toEqual(defaultLanguage);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
|
||||
import { includes } from 'lodash';
|
||||
|
||||
import { Logger } from './logger.service';
|
||||
import * as enUS from '../../translations/en-US.json';
|
||||
import * as frFR from '../../translations/fr-FR.json';
|
||||
|
||||
const log = new Logger('I18nService');
|
||||
const languageKey = 'language';
|
||||
|
||||
/**
|
||||
* Pass-through function to mark a string for translation extraction.
|
||||
* Running `npm translations:extract` will include the given string by using this.
|
||||
* @param {string} s The string to extract for translation.
|
||||
* @return {string} The same string.
|
||||
*/
|
||||
export function extract(s: string) {
|
||||
return s;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
|
||||
defaultLanguage: string;
|
||||
supportedLanguages: string[];
|
||||
|
||||
constructor(private translateService: TranslateService) {
|
||||
// Embed languages to avoid extra HTTP requests
|
||||
translateService.setTranslation('en-US', enUS);
|
||||
translateService.setTranslation('fr-FR', frFR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes i18n for the application.
|
||||
* Loads language from local storage if present, or sets default language.
|
||||
* @param {!string} defaultLanguage The default language to use.
|
||||
* @param {Array.<String>} supportedLanguages The list of supported languages.
|
||||
*/
|
||||
init(defaultLanguage: string, supportedLanguages: string[]) {
|
||||
this.defaultLanguage = defaultLanguage;
|
||||
this.supportedLanguages = supportedLanguages;
|
||||
this.language = '';
|
||||
|
||||
this.translateService.onLangChange
|
||||
.subscribe((event: LangChangeEvent) => { localStorage.setItem(languageKey, event.lang); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current language.
|
||||
* Note: The current language is saved to the local storage.
|
||||
* If no parameter is specified, the language is loaded from local storage (if present).
|
||||
* @param {string} language The IETF language code to set.
|
||||
*/
|
||||
set language(language: string) {
|
||||
language = language || localStorage.getItem(languageKey) || this.translateService.getBrowserCultureLang();
|
||||
let isSupportedLanguage = includes(this.supportedLanguages, language);
|
||||
|
||||
// If no exact match is found, search without the region
|
||||
if (language && !isSupportedLanguage) {
|
||||
language = language.split('-')[0];
|
||||
language = this.supportedLanguages.find(supportedLanguage => supportedLanguage.startsWith(language)) || '';
|
||||
isSupportedLanguage = Boolean(language);
|
||||
}
|
||||
|
||||
// Fallback if language is not supported
|
||||
if (!isSupportedLanguage) {
|
||||
language = this.defaultLanguage;
|
||||
}
|
||||
|
||||
log.debug(`Language set to ${language}`);
|
||||
this.translateService.use(language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current language.
|
||||
* @return {string} The current language code.
|
||||
*/
|
||||
get language(): string {
|
||||
return this.translateService.currentLang;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from './core.module';
|
||||
export * from './authentication/authentication.service';
|
||||
export * from './authentication/authentication.service.mock';
|
||||
export * from './authentication/authentication.guard';
|
||||
export * from './i18n.service';
|
||||
export * from './http/http.service';
|
||||
export * from './http/http-cache.service';
|
||||
export * from './http/api-prefix.interceptor';
|
||||
export * from './http/cache.interceptor';
|
||||
export * from './http/error-handler.interceptor';
|
||||
export * from './route.service';
|
||||
export * from './route-reusable-strategy';
|
||||
export * from './logger.service';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Logger, LogLevel, LogOutput } from './logger.service';
|
||||
|
||||
const logMethods = ['log', 'info', 'warn', 'error'];
|
||||
|
||||
describe('Logger', () => {
|
||||
let savedConsole: Function[];
|
||||
let savedLevel: LogLevel;
|
||||
let savedOutputs: LogOutput[];
|
||||
|
||||
beforeAll(() => {
|
||||
savedConsole = [];
|
||||
logMethods.forEach((m) => {
|
||||
savedConsole[m] = console[m];
|
||||
console[m] = () => {};
|
||||
});
|
||||
savedLevel = Logger.level;
|
||||
savedOutputs = Logger.outputs;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
logMethods.forEach((m) => { console[m] = savedConsole[m]; });
|
||||
Logger.level = savedLevel;
|
||||
Logger.outputs = savedOutputs;
|
||||
});
|
||||
|
||||
it('should create an instance', () => {
|
||||
expect(new Logger()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should add a new LogOutput and receives log entries', () => {
|
||||
// Arrange
|
||||
const outputSpy = jasmine.createSpy('outputSpy');
|
||||
const log = new Logger('test');
|
||||
|
||||
// Act
|
||||
Logger.outputs.push(outputSpy);
|
||||
|
||||
log.debug('d');
|
||||
log.info('i');
|
||||
log.warn('w');
|
||||
log.error('e', { error: true });
|
||||
|
||||
// Assert
|
||||
expect(outputSpy).toHaveBeenCalled();
|
||||
expect(outputSpy.calls.count()).toBe(4);
|
||||
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Debug, 'd');
|
||||
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Info, 'i');
|
||||
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Warning, 'w');
|
||||
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Error, 'e', { error: true });
|
||||
});
|
||||
|
||||
it('should add a new LogOutput and receives only production log entries', () => {
|
||||
// Arrange
|
||||
const outputSpy = jasmine.createSpy('outputSpy');
|
||||
const log = new Logger('test');
|
||||
|
||||
// Act
|
||||
Logger.outputs.push(outputSpy);
|
||||
Logger.enableProductionMode();
|
||||
|
||||
log.debug('d');
|
||||
log.info('i');
|
||||
log.warn('w');
|
||||
log.error('e', { error: true });
|
||||
|
||||
// Assert
|
||||
expect(outputSpy).toHaveBeenCalled();
|
||||
expect(outputSpy.calls.count()).toBe(2);
|
||||
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Warning, 'w');
|
||||
expect(outputSpy).toHaveBeenCalledWith('test', LogLevel.Error, 'e', { error: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Simple logger system with the possibility of registering custom outputs.
|
||||
*
|
||||
* 4 different log levels are provided, with corresponding methods:
|
||||
* - debug : for debug information
|
||||
* - info : for informative status of the application (success, ...)
|
||||
* - warning : for non-critical errors that do not prevent normal application behavior
|
||||
* - error : for critical errors that prevent normal application behavior
|
||||
*
|
||||
* Example usage:
|
||||
* ```
|
||||
* import { Logger } from 'app/core/logger.service';
|
||||
*
|
||||
* const log = new Logger('myFile');
|
||||
* ...
|
||||
* log.debug('something happened');
|
||||
* ```
|
||||
*
|
||||
* To disable debug and info logs in production, add this snippet to your root component:
|
||||
* ```
|
||||
* export class AppComponent implements OnInit {
|
||||
* ngOnInit() {
|
||||
* if (environment.production) {
|
||||
* Logger.enableProductionMode();
|
||||
* }
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* If you want to process logs through other outputs than console, you can add LogOutput functions to Logger.outputs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The possible log levels.
|
||||
* LogLevel.Off is never emitted and only used with Logger.level property to disable logs.
|
||||
*/
|
||||
export enum LogLevel {
|
||||
Off = 0,
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
Debug
|
||||
}
|
||||
|
||||
/**
|
||||
* Log output handler function.
|
||||
*/
|
||||
export type LogOutput = (source: string, level: LogLevel, ...objects: any[]) => void;
|
||||
|
||||
export class Logger {
|
||||
|
||||
/**
|
||||
* Current logging level.
|
||||
* Set it to LogLevel.Off to disable logs completely.
|
||||
*/
|
||||
static level = LogLevel.Debug;
|
||||
|
||||
/**
|
||||
* Additional log outputs.
|
||||
*/
|
||||
static outputs: LogOutput[] = [];
|
||||
|
||||
/**
|
||||
* Enables production mode.
|
||||
* Sets logging level to LogLevel.Warning.
|
||||
*/
|
||||
static enableProductionMode() {
|
||||
Logger.level = LogLevel.Warning;
|
||||
}
|
||||
|
||||
constructor(private source?: string) { }
|
||||
|
||||
/**
|
||||
* Logs messages or objects with the debug level.
|
||||
* Works the same as console.log().
|
||||
*/
|
||||
debug(...objects: any[]) {
|
||||
this.log(console.log, LogLevel.Debug, objects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs messages or objects with the info level.
|
||||
* Works the same as console.log().
|
||||
*/
|
||||
info(...objects: any[]) {
|
||||
this.log(console.info, LogLevel.Info, objects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs messages or objects with the warning level.
|
||||
* Works the same as console.log().
|
||||
*/
|
||||
warn(...objects: any[]) {
|
||||
this.log(console.warn, LogLevel.Warning, objects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs messages or objects with the error level.
|
||||
* Works the same as console.log().
|
||||
*/
|
||||
error(...objects: any[]) {
|
||||
this.log(console.error, LogLevel.Error, objects);
|
||||
}
|
||||
|
||||
private log(func: Function, level: LogLevel, objects: any[]) {
|
||||
if (level <= Logger.level) {
|
||||
const log = this.source ? ['[' + this.source + ']'].concat(objects) : objects;
|
||||
func.apply(console, log);
|
||||
Logger.outputs.forEach((output) => output.apply(output, [this.source, level].concat(objects)));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
|
||||
|
||||
/**
|
||||
* A route strategy allowing for explicit route reuse.
|
||||
* Used as a workaround for https://github.com/angular/angular/issues/18374
|
||||
* To reuse a given route, add `data: { reuse: true }` to the route definition.
|
||||
*/
|
||||
export class RouteReusableStrategy extends RouteReuseStrategy {
|
||||
|
||||
public shouldDetach(route: ActivatedRouteSnapshot): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle | null): void { }
|
||||
|
||||
public shouldAttach(route: ActivatedRouteSnapshot): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
|
||||
return (future.routeConfig === curr.routeConfig) || future.data.reuse;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { TestBed, inject } from '@angular/core/testing';
|
||||
|
||||
import { AuthenticationService } from './authentication/authentication.service';
|
||||
import { MockAuthenticationService } from './authentication/authentication.service.mock';
|
||||
import { AuthenticationGuard } from './authentication/authentication.guard';
|
||||
import { ShellComponent } from './shell/shell.component';
|
||||
import { Route } from './route.service';
|
||||
|
||||
describe('Route', () => {
|
||||
let route: Route;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AuthenticationGuard,
|
||||
{ provide: AuthenticationService, useClass: MockAuthenticationService },
|
||||
Route
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(inject([Route], (_route: Route) => {
|
||||
route = _route;
|
||||
}));
|
||||
|
||||
describe('withShell', () => {
|
||||
it('should create routes as children of shell', () => {
|
||||
// Prepare
|
||||
const testRoutes = [{ path: 'test' }];
|
||||
|
||||
// Act
|
||||
const result = Route.withShell(testRoutes);
|
||||
|
||||
// Assert
|
||||
expect(result.path).toBe('');
|
||||
expect(result.children).toBe(testRoutes);
|
||||
expect(result.component).toBe(ShellComponent);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Route as ngRoute, Routes } from '@angular/router';
|
||||
|
||||
import { ShellComponent } from './shell/shell.component';
|
||||
import { AuthenticationGuard } from './authentication/authentication.guard';
|
||||
|
||||
/**
|
||||
* Provides helper methods to create routes.
|
||||
*/
|
||||
export class Route {
|
||||
|
||||
/**
|
||||
* Creates routes using the shell component and authentication.
|
||||
* @param routes The routes to add.
|
||||
* @return {Route} The new route using shell as the base.
|
||||
*/
|
||||
static withShell(routes: Routes): ngRoute {
|
||||
return {
|
||||
path: '',
|
||||
component: ShellComponent,
|
||||
children: routes,
|
||||
canActivate: [AuthenticationGuard],
|
||||
// Reuse ShellComponent instance when navigating between child views
|
||||
data: { reuse: true }
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<!-- <header>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<a class="navbar-brand" href="https://github.com/ngx-rocket" translate>APP_NAME</a>
|
||||
<button class="navbar-toggler" type="button" aria-controls="navbar-menu"
|
||||
aria-label="Toggle navigation" (click)="toggleMenu()" [attr.aria-expanded]="!menuHidden">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div id="navbar-menu" class="collapse navbar-collapse float-xs-none" [ngbCollapse]="menuHidden">
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-item nav-link text-uppercase" routerLink="/home" routerLinkActive="active">
|
||||
<i class="fas fa-home"></i>
|
||||
<span translate>Home</span>
|
||||
</a>
|
||||
<a class="nav-item nav-link text-uppercase" routerLink="/about" routerLinkActive="active">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span translate>About</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-nav ml-auto">
|
||||
<div class="nav-item" ngbDropdown placement="bottom-right">
|
||||
<a id="language-dropdown" class="nav-link" ngbDropdownToggle>{{currentLanguage}}</a>
|
||||
<div ngbDropdownMenu aria-labelledby="language-dropdown">
|
||||
<button class="dropdown-item" *ngFor="let language of languages" (click)="setLanguage(language)">
|
||||
{{language}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" ngbDropdown placement="bottom-right">
|
||||
<a id="user-dropdown" class="nav-link" ngbDropdownToggle>
|
||||
<i class="fas fa-user-circle"></i>
|
||||
</a>
|
||||
<div ngbDropdownMenu aria-labelledby="user-dropdown">
|
||||
<h6 class="dropdown-header">
|
||||
<span translate>Logged in as</span> <b>{{username}}</b></h6>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" (click)="logout()" translate>Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header> -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<a class="navbar-brand" href="#">MifosX</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarColor02" aria-controls="navbarColor02" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarColor02">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="/clients" routerLinkActive="active">Clients</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Groups</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">Loans</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="form-inline my-2 my-lg-0">
|
||||
<input class="form-control mr-sm-2" placeholder="Search" type="text">
|
||||
<button class="btn btn-secondary my-2 mr-sm-2 my-sm-0" type="submit">Search</button>
|
||||
</form>
|
||||
<button class="btn btn-secondary my-2 mr-sm-2 my-sm-0" (click)="logout()" type="submit">Logout</button>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,10 @@
|
||||
/* @import "src/theme/theme-variables";
|
||||
|
||||
.navbar {
|
||||
margin-bottom: $spacer;
|
||||
}
|
||||
|
||||
.nav-link.dropdown-toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,40 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
|
||||
import { AuthenticationService } from '../../authentication/authentication.service';
|
||||
import { MockAuthenticationService } from '../../authentication/authentication.service.mock';
|
||||
import { I18nService } from '../../i18n.service';
|
||||
import { HeaderComponent } from './header.component';
|
||||
|
||||
describe('HeaderComponent', () => {
|
||||
let component: HeaderComponent;
|
||||
let fixture: ComponentFixture<HeaderComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule,
|
||||
NgbModule.forRoot(),
|
||||
TranslateModule.forRoot()
|
||||
],
|
||||
declarations: [HeaderComponent],
|
||||
providers: [
|
||||
{ provide: AuthenticationService, useClass: MockAuthenticationService },
|
||||
I18nService
|
||||
]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(HeaderComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { AuthenticationService } from '../../authentication/authentication.service';
|
||||
import { I18nService } from '../../i18n.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
templateUrl: './header.component.html',
|
||||
styleUrls: ['./header.component.scss']
|
||||
})
|
||||
export class HeaderComponent implements OnInit {
|
||||
|
||||
menuHidden = true;
|
||||
|
||||
constructor(private router: Router,
|
||||
private authenticationService: AuthenticationService,
|
||||
private i18nService: I18nService) { }
|
||||
|
||||
ngOnInit() { }
|
||||
|
||||
toggleMenu() {
|
||||
this.menuHidden = !this.menuHidden;
|
||||
}
|
||||
|
||||
setLanguage(language: string) {
|
||||
this.i18nService.language = language;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.authenticationService.logout()
|
||||
.subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
|
||||
}
|
||||
|
||||
get currentLanguage(): string {
|
||||
return this.i18nService.language;
|
||||
}
|
||||
|
||||
get languages(): string[] {
|
||||
return this.i18nService.supportedLanguages;
|
||||
}
|
||||
|
||||
get username(): string | null {
|
||||
const credentials = this.authenticationService.credentials;
|
||||
return credentials ? credentials.username : null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<app-header></app-header>
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
import { AuthenticationService } from '../authentication/authentication.service';
|
||||
import { MockAuthenticationService } from '../authentication/authentication.service.mock';
|
||||
import { CoreModule } from '../core.module';
|
||||
import { ShellComponent } from './shell.component';
|
||||
|
||||
describe('ShellComponent', () => {
|
||||
let component: ShellComponent;
|
||||
let fixture: ComponentFixture<ShellComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule,
|
||||
TranslateModule.forRoot(),
|
||||
NgbModule.forRoot(),
|
||||
CoreModule
|
||||
],
|
||||
providers: [
|
||||
{ provide: AuthenticationService, useClass: MockAuthenticationService }
|
||||
]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ShellComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
templateUrl: './shell.component.html',
|
||||
styleUrls: ['./shell.component.scss']
|
||||
})
|
||||
export class ShellComponent implements OnInit {
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() { }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route, extract } from '@app/core';
|
||||
import { HomeComponent } from './home.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||
{ path: 'home', component: HomeComponent, data: { title: extract('Home') } }
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
providers: []
|
||||
})
|
||||
export class HomeRoutingModule { }
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="container-fluid">
|
||||
<div class="text-center">
|
||||
<img class="logo" src="assets/images/mifos_lg-logo.jpg"/>
|
||||
<!-- <app-loader [isLoading]="isLoading"></app-loader>
|
||||
<q [hidden]="isLoading">{{quote}}</q> -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
.logo {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
q {
|
||||
font-style: italic;
|
||||
font-size: 1.2rem;
|
||||
quotes: "« " " »";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
|
||||
import { CoreModule } from '@app/core';
|
||||
import { SharedModule } from '@app/shared';
|
||||
import { HomeComponent } from './home.component';
|
||||
import { QuoteService } from './quote.service';
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CoreModule,
|
||||
SharedModule,
|
||||
HttpClientTestingModule
|
||||
],
|
||||
declarations: [HomeComponent],
|
||||
providers: [QuoteService]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { QuoteService } from './quote.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss']
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
|
||||
quote: string;
|
||||
isLoading: boolean;
|
||||
|
||||
constructor(private quoteService: QuoteService) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.isLoading = true;
|
||||
this.quoteService.getRandomQuote({ category: 'dev' })
|
||||
.pipe(finalize(() => { this.isLoading = false; }))
|
||||
.subscribe((quote: string) => { this.quote = quote; });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { CoreModule } from '@app/core';
|
||||
import { SharedModule } from '@app/shared';
|
||||
import { HomeRoutingModule } from './home-routing.module';
|
||||
import { HomeComponent } from './home.component';
|
||||
import { QuoteService } from './quote.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule,
|
||||
CoreModule,
|
||||
SharedModule,
|
||||
HomeRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
HomeComponent
|
||||
],
|
||||
providers: [
|
||||
QuoteService
|
||||
]
|
||||
})
|
||||
export class HomeModule { }
|
||||
@@ -0,0 +1,72 @@
|
||||
import { TestBed, inject, async } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
|
||||
import { CoreModule, HttpCacheService } from '@app/core';
|
||||
import { QuoteService } from './quote.service';
|
||||
|
||||
describe('QuoteService', () => {
|
||||
let quoteService: QuoteService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CoreModule,
|
||||
HttpClientTestingModule
|
||||
],
|
||||
providers: [
|
||||
HttpCacheService,
|
||||
QuoteService
|
||||
]
|
||||
});
|
||||
}));
|
||||
|
||||
beforeEach(inject([
|
||||
HttpCacheService,
|
||||
QuoteService,
|
||||
HttpTestingController
|
||||
], (htttpCacheService: HttpCacheService,
|
||||
_quoteService: QuoteService,
|
||||
_httpMock: HttpTestingController) => {
|
||||
|
||||
quoteService = _quoteService;
|
||||
httpMock = _httpMock;
|
||||
|
||||
htttpCacheService.cleanCache();
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
describe('getRandomQuote', () => {
|
||||
it('should return a random Chuck Norris quote', () => {
|
||||
// Arrange
|
||||
const mockQuote = { value: 'a random quote' };
|
||||
|
||||
// Act
|
||||
const randomQuoteSubscription = quoteService.getRandomQuote({ category: 'toto' });
|
||||
|
||||
// Assert
|
||||
randomQuoteSubscription.subscribe((quote: string) => {
|
||||
expect(quote).toEqual(mockQuote.value);
|
||||
});
|
||||
httpMock.expectOne({}).flush(mockQuote);
|
||||
});
|
||||
|
||||
it('should return a string in case of error', () => {
|
||||
// Act
|
||||
const randomQuoteSubscription = quoteService.getRandomQuote({ category: 'toto' });
|
||||
|
||||
// Assert
|
||||
randomQuoteSubscription.subscribe((quote: string) => {
|
||||
expect(typeof quote).toEqual('string');
|
||||
expect(quote).toContain('Error');
|
||||
});
|
||||
httpMock.expectOne({}).flush(null, {
|
||||
status: 500,
|
||||
statusText: 'error'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
|
||||
const routes = {
|
||||
quote: (c: RandomQuoteContext) => `/jokes/random?category=${c.category}`
|
||||
};
|
||||
|
||||
export interface RandomQuoteContext {
|
||||
// The quote's category: 'dev', 'explicit'...
|
||||
category: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuoteService {
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getRandomQuote(context: RandomQuoteContext): Observable<string> {
|
||||
return this.httpClient
|
||||
.cache()
|
||||
.get(routes.quote(context))
|
||||
.pipe(
|
||||
map((body: any) => body.value),
|
||||
catchError(() => of('Error, could not load joke :-('))
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { extract } from '@app/core';
|
||||
import { LoginComponent } from './login.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: 'login', component: LoginComponent, data: { title: extract('Login') } }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
providers: []
|
||||
})
|
||||
export class LoginRoutingModule { }
|
||||
@@ -0,0 +1,62 @@
|
||||
<div class="login-container bg-light">
|
||||
<div class="login-box">
|
||||
<img class="text-center img-responsive" src="assets/images/mifos-logo-flat.png" style="width:30%">
|
||||
<div>
|
||||
<h6 class="d-inline-block">v{{version}}</h6>
|
||||
<div class="d-inline-block ml-3" ngbDropdown>
|
||||
<button id="language-dropdown" class="btn btn-sm btn-secondary" ngbDropdownToggle>
|
||||
{{currentLanguage}}
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="language-dropdown">
|
||||
<button class="dropdown-item" *ngFor="let language of languages" (click)="setLanguage(language)">
|
||||
{{language}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="card col-md-6 mt-3 mx-auto">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-center">
|
||||
<i class="far fa-3x fa-user-circle text-muted"></i>
|
||||
</h4>
|
||||
<form (ngSubmit)="login()" [formGroup]="loginForm" novalidate>
|
||||
<div class="alert alert-danger" [hidden]="!error || isLoading" translate>
|
||||
Username or password incorrect.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="d-block">
|
||||
<input type="text" class="form-control" formControlName="username" autocomplete="username"
|
||||
[placeholder]="'Username' | translate"/>
|
||||
<span hidden translate>Username</span>
|
||||
<small [hidden]="loginForm.controls.username.valid || loginForm.controls.username.untouched"
|
||||
class="text-danger" translate>
|
||||
Username is required
|
||||
</small>
|
||||
</label>
|
||||
<label class="d-block">
|
||||
<input type="password" class="form-control" formControlName="password" autocomplete="current-password"
|
||||
[placeholder]="'Password' | translate" required/>
|
||||
<span hidden translate>Password</span>
|
||||
<small [hidden]="loginForm.controls.password.valid || loginForm.controls.password.untouched"
|
||||
class="text-danger" translate>
|
||||
Password is required
|
||||
</small>
|
||||
</label>
|
||||
<div class="form-check">
|
||||
<label class="form-check-label">
|
||||
<input type="checkbox" class="form-check-input" formControlName="remember"/>
|
||||
<span translate>Remember me</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" type="submit" [disabled]="loginForm.invalid || isLoading">
|
||||
<i class="fas fa-cog fa-spin" [hidden]="!isLoading"></i>
|
||||
<span translate>Login</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
@import "src/theme/theme-variables";
|
||||
@import "~bootstrap/scss/mixins/_breakpoints";
|
||||
|
||||
.login-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.ng-invalid.ng-touched:not(form) {
|
||||
border-left: 4px solid theme-color("danger");
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
import { CoreModule } from '@app/core';
|
||||
import { LoginComponent } from './login.component';
|
||||
|
||||
describe('LoginComponent', () => {
|
||||
let component: LoginComponent;
|
||||
let fixture: ComponentFixture<LoginComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbModule.forRoot(),
|
||||
RouterTestingModule,
|
||||
TranslateModule.forRoot(),
|
||||
ReactiveFormsModule,
|
||||
CoreModule
|
||||
],
|
||||
declarations: [LoginComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(LoginComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
import { Logger, I18nService, AuthenticationService } from '@app/core';
|
||||
|
||||
const log = new Logger('Login');
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.scss']
|
||||
})
|
||||
export class LoginComponent implements OnInit {
|
||||
|
||||
version: string = environment.version;
|
||||
error: string;
|
||||
loginForm: FormGroup;
|
||||
isLoading = false;
|
||||
|
||||
constructor(private router: Router,
|
||||
private formBuilder: FormBuilder,
|
||||
private i18nService: I18nService,
|
||||
private authenticationService: AuthenticationService) {
|
||||
this.createForm();
|
||||
}
|
||||
|
||||
ngOnInit() { }
|
||||
|
||||
login() {
|
||||
this.isLoading = true;
|
||||
this.authenticationService.login(this.loginForm.value)
|
||||
.pipe(finalize(() => {
|
||||
this.loginForm.markAsPristine();
|
||||
this.isLoading = false;
|
||||
}))
|
||||
.subscribe(credentials => {
|
||||
log.debug(`${credentials.username} successfully logged in`);
|
||||
this.router.navigate(['/'], { replaceUrl: true });
|
||||
}, error => {
|
||||
log.debug(`Login error: ${error}`);
|
||||
this.error = error;
|
||||
});
|
||||
}
|
||||
|
||||
setLanguage(language: string) {
|
||||
this.i18nService.language = language;
|
||||
}
|
||||
|
||||
get currentLanguage(): string {
|
||||
return this.i18nService.language;
|
||||
}
|
||||
|
||||
get languages(): string[] {
|
||||
return this.i18nService.supportedLanguages;
|
||||
}
|
||||
|
||||
private createForm() {
|
||||
this.loginForm = this.formBuilder.group({
|
||||
username: ['', Validators.required],
|
||||
password: ['', Validators.required],
|
||||
remember: true
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
import { LoginRoutingModule } from './login-routing.module';
|
||||
import { LoginComponent } from './login.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
NgbModule,
|
||||
LoginRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
LoginComponent
|
||||
]
|
||||
})
|
||||
export class LoginModule { }
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './shared.module';
|
||||
export * from './loader/loader.component';
|
||||
@@ -0,0 +1,4 @@
|
||||
<div [hidden]="!isLoading" class="text-xs-center">
|
||||
<i class="fas fa-cog fa-spin fa-3x"></i> <span>{{message}}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.fa {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { LoaderComponent } from './loader.component';
|
||||
|
||||
describe('LoaderComponent', () => {
|
||||
let component: LoaderComponent;
|
||||
let fixture: ComponentFixture<LoaderComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [LoaderComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(LoaderComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should not be visible by default', () => {
|
||||
// Arrange
|
||||
const element = fixture.nativeElement;
|
||||
const div = element.querySelectorAll('div')[0];
|
||||
|
||||
// Assert
|
||||
expect(div.getAttribute('hidden')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should be visible when app is loading', () => {
|
||||
// Arrange
|
||||
const element = fixture.nativeElement;
|
||||
const div = element.querySelectorAll('div')[0];
|
||||
|
||||
// Act
|
||||
fixture.componentInstance.isLoading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
expect(div.getAttribute('hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not display a message by default', () => {
|
||||
// Arrange
|
||||
const element = fixture.nativeElement;
|
||||
const span = element.querySelectorAll('span')[0];
|
||||
|
||||
// Assert
|
||||
expect(span.innerText).toBe('');
|
||||
});
|
||||
|
||||
it('should display specified message', () => {
|
||||
// Arrange
|
||||
const element = fixture.nativeElement;
|
||||
const span = element.querySelectorAll('span')[0];
|
||||
|
||||
// Act
|
||||
fixture.componentInstance.message = 'testing';
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
expect(span.innerText).toBe('testing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Component, OnInit, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-loader',
|
||||
templateUrl: './loader.component.html',
|
||||
styleUrls: ['./loader.component.scss']
|
||||
})
|
||||
export class LoaderComponent implements OnInit {
|
||||
|
||||
@Input() isLoading = false;
|
||||
@Input() message: string;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit() { }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { LoaderComponent } from './loader/loader.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule
|
||||
],
|
||||
declarations: [
|
||||
LoaderComponent
|
||||
],
|
||||
exports: [
|
||||
LoaderComponent
|
||||
]
|
||||
})
|
||||
export class SharedModule { }
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="50px" height="50px" viewBox="0 0 128 128" xml:space="preserve"><path fill="#4f99ed" fill-opacity="1" d="M64.4 16a49 49 0 0 0-50 48 51 51 0 0 0 50 52.2 53 53 0 0 0 54-52c-.7-48-45-55.7-45-55.7s45.3 3.8 49 55.6c.8 32-24.8 59.5-58 60.2-33 .8-61.4-25.7-62-60C1.3 29.8 28.8.6 64.3 0c0 0 8.5 0 8.7 8.4 0 8-8.6 7.6-8.6 7.6z"><animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="800ms" repeatCount="indefinite"></animateTransform></path></svg>
|
||||
|
After Width: | Height: | Size: 672 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 559 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 318 B |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg width='20px' height='20px' xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" class="uil-ring"><rect x="0" y="0" width="100" height="100" fill="none" class="bk"></rect><defs><filter id="uil-ring-shadow" x="-100%" y="-100%" width="300%" height="300%"><feOffset result="offOut" in="SourceGraphic" dx="0" dy="0"></feOffset><feGaussianBlur result="blurOut" in="offOut" stdDeviation="0"></feGaussianBlur><feBlend in="SourceGraphic" in2="blurOut" mode="normal"></feBlend></filter></defs><path d="M10,50c0,0,0,0.5,0.1,1.4c0,0.5,0.1,1,0.2,1.7c0,0.3,0.1,0.7,0.1,1.1c0.1,0.4,0.1,0.8,0.2,1.2c0.2,0.8,0.3,1.8,0.5,2.8 c0.3,1,0.6,2.1,0.9,3.2c0.3,1.1,0.9,2.3,1.4,3.5c0.5,1.2,1.2,2.4,1.8,3.7c0.3,0.6,0.8,1.2,1.2,1.9c0.4,0.6,0.8,1.3,1.3,1.9 c1,1.2,1.9,2.6,3.1,3.7c2.2,2.5,5,4.7,7.9,6.7c3,2,6.5,3.4,10.1,4.6c3.6,1.1,7.5,1.5,11.2,1.6c4-0.1,7.7-0.6,11.3-1.6 c3.6-1.2,7-2.6,10-4.6c3-2,5.8-4.2,7.9-6.7c1.2-1.2,2.1-2.5,3.1-3.7c0.5-0.6,0.9-1.3,1.3-1.9c0.4-0.6,0.8-1.3,1.2-1.9 c0.6-1.3,1.3-2.5,1.8-3.7c0.5-1.2,1-2.4,1.4-3.5c0.3-1.1,0.6-2.2,0.9-3.2c0.2-1,0.4-1.9,0.5-2.8c0.1-0.4,0.1-0.8,0.2-1.2 c0-0.4,0.1-0.7,0.1-1.1c0.1-0.7,0.1-1.2,0.2-1.7C90,50.5,90,50,90,50s0,0.5,0,1.4c0,0.5,0,1,0,1.7c0,0.3,0,0.7,0,1.1 c0,0.4-0.1,0.8-0.1,1.2c-0.1,0.9-0.2,1.8-0.4,2.8c-0.2,1-0.5,2.1-0.7,3.3c-0.3,1.2-0.8,2.4-1.2,3.7c-0.2,0.7-0.5,1.3-0.8,1.9 c-0.3,0.7-0.6,1.3-0.9,2c-0.3,0.7-0.7,1.3-1.1,2c-0.4,0.7-0.7,1.4-1.2,2c-1,1.3-1.9,2.7-3.1,4c-2.2,2.7-5,5-8.1,7.1 c-0.8,0.5-1.6,1-2.4,1.5c-0.8,0.5-1.7,0.9-2.6,1.3L66,87.7l-1.4,0.5c-0.9,0.3-1.8,0.7-2.8,1c-3.8,1.1-7.9,1.7-11.8,1.8L47,90.8 c-1,0-2-0.2-3-0.3l-1.5-0.2l-0.7-0.1L41.1,90c-1-0.3-1.9-0.5-2.9-0.7c-0.9-0.3-1.9-0.7-2.8-1L34,87.7l-1.3-0.6 c-0.9-0.4-1.8-0.8-2.6-1.3c-0.8-0.5-1.6-1-2.4-1.5c-3.1-2.1-5.9-4.5-8.1-7.1c-1.2-1.2-2.1-2.7-3.1-4c-0.5-0.6-0.8-1.4-1.2-2 c-0.4-0.7-0.8-1.3-1.1-2c-0.3-0.7-0.6-1.3-0.9-2c-0.3-0.7-0.6-1.3-0.8-1.9c-0.4-1.3-0.9-2.5-1.2-3.7c-0.3-1.2-0.5-2.3-0.7-3.3 c-0.2-1-0.3-2-0.4-2.8c-0.1-0.4-0.1-0.8-0.1-1.2c0-0.4,0-0.7,0-1.1c0-0.7,0-1.2,0-1.7C10,50.5,10,50,10,50z" fill="#ffffff" filter="url(#uil-ring-shadow)"><animateTransform attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" repeatCount="indefinite" dur="1s"></animateTransform></path></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,10 @@
|
||||
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
|
||||
last 2 versions
|
||||
> 0.5%
|
||||
Firefox ESR
|
||||
not dead
|
||||
# For IE 9-11 support, please uncomment the next line and adjust as needed
|
||||
# IE 9-11
|
||||
@@ -0,0 +1,13 @@
|
||||
// `.env.ts` is generated by the `npm run env` command
|
||||
import env from './.env';
|
||||
|
||||
export const environment = {
|
||||
production: true,
|
||||
version: env.npm_package_version,
|
||||
serverUrl: '',
|
||||
defaultLanguage: 'en-US',
|
||||
supportedLanguages: [
|
||||
'en-US',
|
||||
'fr-FR'
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// The file contents for the current environment will overwrite these during build.
|
||||
// The build system defaults to the dev environment which uses `environment.ts`, but if you do
|
||||
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
|
||||
// The list of which env maps to which file can be found in `.angular-cli.json`.
|
||||
|
||||
// `.env.ts` is generated by the `npm run env` command
|
||||
import env from './.env';
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
version: env.npm_package_version + '-dev',
|
||||
serverUrl: '',
|
||||
defaultLanguage: 'en-US',
|
||||
supportedLanguages: [
|
||||
'en-US',
|
||||
'fr-FR'
|
||||
]
|
||||
};
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>MifosX</title>
|
||||
<base href="/"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="theme-color" content="#4e8ef7"/>
|
||||
<link rel="manifest" href="manifest.json"/>
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png"/>
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!--[if lt IE 10]>
|
||||
<p>
|
||||
You are using an <strong>outdated</strong> browser.
|
||||
Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.
|
||||
</p>
|
||||
<![endif]-->
|
||||
<noscript>
|
||||
<p>
|
||||
This page requires JavaScript to work properly. Please enable JavaScript in your browser.
|
||||
</p>
|
||||
</noscript>
|
||||
<app-root>
|
||||
|
||||
</app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
process.env.CHROME_BIN = require('puppeteer').executablePath();
|
||||
const path = require('path');
|
||||
|
||||
module.exports = function(config) {
|
||||
config.set({
|
||||
basePath: '..',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-junit-reporter'),
|
||||
require('karma-coverage-istanbul-reporter'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false, // leave Jasmine Spec Runner output visible in browser
|
||||
captureConsole: Boolean(process.env.KARMA_ENABLE_CONSOLE)
|
||||
},
|
||||
junitReporter: {
|
||||
outputDir: path.join(__dirname, '../reports/junit/'),
|
||||
outputFile: 'TESTS-xunit.xml',
|
||||
useBrowserName: false,
|
||||
suite: '' // Will become the package name attribute in xml testsuite element
|
||||
},
|
||||
coverageIstanbulReporter: {
|
||||
reports: ['html', 'lcovonly', 'text-summary'],
|
||||
dir: path.join(__dirname, '../reports/coverage'),
|
||||
fixWebpackSourcePaths: true
|
||||
},
|
||||
angularCli: {
|
||||
environment: 'dev'
|
||||
},
|
||||
reporters: ['progress', 'junit'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
// Level of logging, can be: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['ChromeHeadless'],
|
||||
singleRun: false
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Entry point of global application style.
|
||||
* Component-specific style should not go here and be included directly as part of the components.
|
||||
*/
|
||||
|
||||
// Theme variables, must be included before the libraries to allow overriding defaults
|
||||
|
||||
/*
|
||||
@import "theme/theme-variables";
|
||||
|
||||
// 3rd party libraries
|
||||
|
||||
@import "~@fortawesome/fontawesome-free-webfonts/scss/fontawesome.scss";
|
||||
@import "~@fortawesome/fontawesome-free-webfonts/scss/fa-brands.scss";
|
||||
@import "~@fortawesome/fontawesome-free-webfonts/scss/fa-regular.scss";
|
||||
@import "~@fortawesome/fontawesome-free-webfonts/scss/fa-solid.scss"; */
|
||||
|
||||
// Theme customization
|
||||
@import "styles/bootstrap-material/_variables.scss";
|
||||
@import "~bootstrap/scss/bootstrap";
|
||||
@import "styles/bootstrap-material/_bootswatch.scss";
|
||||
|
||||
|
||||
//other scss imports
|
||||
@import "styles/_loader.scss"
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Entry point of the application.
|
||||
* Only platform bootstrapping code should be here.
|
||||
* For app-specific initialization, use `app/app.component.ts`.
|
||||
*/
|
||||
|
||||
import { enableProdMode } from '@angular/core';
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from '@app/app.module';
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode();
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "MifosX",
|
||||
"short_name": "Mifosx",
|
||||
"theme_color": "#488aff",
|
||||
"background_color": "#488aff",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"icons": [{
|
||||
"src": "assets/ngx-rocket-logo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}],
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
|
||||
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/** IE9, IE10 and IE11 requires all of the following polyfills. */
|
||||
// import 'core-js/es6/symbol';
|
||||
// import 'core-js/es6/object';
|
||||
// import 'core-js/es6/function';
|
||||
// import 'core-js/es6/parse-int';
|
||||
// import 'core-js/es6/parse-float';
|
||||
// import 'core-js/es6/number';
|
||||
// import 'core-js/es6/math';
|
||||
// import 'core-js/es6/string';
|
||||
// import 'core-js/es6/date';
|
||||
// import 'core-js/es6/array';
|
||||
// import 'core-js/es6/regexp';
|
||||
// import 'core-js/es6/map';
|
||||
// import 'core-js/es6/weak-map';
|
||||
// import 'core-js/es6/set';
|
||||
|
||||
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
|
||||
// import 'classlist.js'; // Run `npm install --save classlist.js`.
|
||||
|
||||
/** IE10 and IE11 requires the following for the Reflect API. */
|
||||
// import 'core-js/es6/reflect';
|
||||
|
||||
|
||||
/** Evergreen browsers require these. */
|
||||
// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove.
|
||||
import 'core-js/es7/reflect';
|
||||
|
||||
|
||||
/**
|
||||
* Web Animations `@angular/platform-browser/animations`
|
||||
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
|
||||
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
|
||||
*/
|
||||
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
*/
|
||||
|
||||
// (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
// (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
// (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
|
||||
/*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*/
|
||||
// (window as any).__Zone_enable_cross_context_check = true;
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js/dist/zone'; // Included with Angular CLI.
|
||||
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
@@ -0,0 +1,5 @@
|
||||
# www.robotstxt.org/
|
||||
|
||||
# Allow crawling of all content
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,49 @@
|
||||
.wrapper {
|
||||
margin: 50px auto;
|
||||
height: 250px;
|
||||
width: 240px;
|
||||
position: relative;
|
||||
|
||||
.triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
position: absolute;
|
||||
animation: rotate 0.7s infinite 1s;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
border-width: 0 100px 100px 100px;
|
||||
border-color: transparent transparent #1fb050 transparent;
|
||||
bottom: 10px;
|
||||
left: calc(50% - 100px);
|
||||
}
|
||||
|
||||
.top {
|
||||
border-width: 100px 100px 0 100px;
|
||||
border-color: #b4d575 transparent transparent transparent;
|
||||
top: 10px;
|
||||
left: calc(50% - 100px);
|
||||
}
|
||||
|
||||
.left {
|
||||
border-width: 100px 0 100px 100px;
|
||||
border-color: transparent transparent transparent #1074b9;
|
||||
top: calc(50% - 100px);
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.right {
|
||||
border-width: 100px 100px 100px 0;
|
||||
border-color: transparent #1daeec transparent transparent;
|
||||
top: calc(50% - 100px);
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
// Materia 4.1.1
|
||||
// Bootswatch
|
||||
|
||||
|
||||
// Variables ===================================================================
|
||||
|
||||
$web-font-path: "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" !default;
|
||||
@import url($web-font-path);
|
||||
|
||||
// Effects =====================================================================
|
||||
|
||||
@mixin ripple($color){
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-left: 0;
|
||||
@include gradient-radial($color 10%, transparent 10.01%);
|
||||
background-size: 1000% 1000%;
|
||||
background-position: 50%;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: background .5s, opacity 1s;
|
||||
}
|
||||
|
||||
&:active:after {
|
||||
background-size: 0 0;
|
||||
opacity: .2;
|
||||
transition: 0s;
|
||||
}
|
||||
|
||||
&.disabled,
|
||||
&[disabled] {
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navbar ======================================================================
|
||||
|
||||
.navbar {
|
||||
border: none;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.3);
|
||||
|
||||
&-brand {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&-nav .nav-link {
|
||||
padding-top: 0.9rem;
|
||||
padding-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
&.bg-dark,
|
||||
&.bg-primary {
|
||||
|
||||
input[type=text],
|
||||
input[type=password] {
|
||||
color: #fff;
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.5);
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 -2px 0 #fff;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons =====================================================================
|
||||
|
||||
@mixin btn($class,$bg,$color){
|
||||
.btn-#{$class} {
|
||||
&:focus {
|
||||
background-color: $bg;
|
||||
box-shadow: 0 0 0 2px rgba(204, 204, 204, 0.5);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active:hover {
|
||||
background-color: darken($bg, 6%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 2px 2px 4px rgba(0,0,0,.4);
|
||||
}
|
||||
|
||||
@include ripple($color);
|
||||
}
|
||||
|
||||
.btn-outline-#{$class} {
|
||||
@include ripple($color);
|
||||
}
|
||||
}
|
||||
|
||||
@include btn(primary,$primary,$white);
|
||||
@include btn(secondary,$secondary,$gray-500);
|
||||
@include btn(success,$success,$white);
|
||||
@include btn(info,$info,$white);
|
||||
@include btn(warning,$warning,$white);
|
||||
@include btn(danger,$danger,$white);
|
||||
@include btn(dark,$dark,$white);
|
||||
@include btn(light,$light,$white);
|
||||
|
||||
.btn {
|
||||
text-transform: uppercase;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.4);
|
||||
transition: all 0.4s;
|
||||
|
||||
&-link {
|
||||
box-shadow: none;
|
||||
color: $link-color;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
color: $link-hover-color;
|
||||
text-decoration: $link-hover-decoration;
|
||||
}
|
||||
|
||||
&.disabled, // Although btn-link is intended for buttons, which want to look like link, I include here a.disable for the sake of consistency
|
||||
&[disabled],
|
||||
fieldset[disabled] & {
|
||||
&:hover,
|
||||
&:active:hover {
|
||||
color: $btn-link-disabled-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
&.disabled,
|
||||
&[disabled],
|
||||
fieldset[disabled] & {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
opacity: 1;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-outline-secondary {
|
||||
border-color: $gray-200;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
&-warning {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.dropdown-toggle::before {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: 0.3em;
|
||||
vertical-align: middle;
|
||||
content: "";
|
||||
border-top: 0.3em solid;
|
||||
border-right: 0.3em solid transparent;
|
||||
border-left: 0.3em solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
.btn + .btn,
|
||||
.btn + .btn-group,
|
||||
.btn-group + .btn,
|
||||
.btn-group + .btn-group {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&-vertical {
|
||||
> .btn + .btn,
|
||||
> .btn + .btn-group,
|
||||
> .btn-group + .btn,
|
||||
> .btn-group + .btn-group {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-secondary {
|
||||
border: 1px solid #dfdfdf !important;
|
||||
}
|
||||
|
||||
// Typography ==================================================================
|
||||
|
||||
body,
|
||||
input,
|
||||
button {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: .1px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
// Tables ======================================================================
|
||||
|
||||
.table-hover {
|
||||
> tbody > tr,
|
||||
> tbody > tr > th,
|
||||
> tbody > tr > td {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.thead-inverse th {
|
||||
background-color: $primary;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
// Forms =======================================================================
|
||||
|
||||
.col-form-label {
|
||||
font-size: 16px;
|
||||
|
||||
&-sm {
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&-lg {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
|
||||
textarea,
|
||||
textarea.form-control,
|
||||
input.form-control,
|
||||
input[type=text],
|
||||
input[type=password],
|
||||
input[type=email],
|
||||
input[type=number],
|
||||
[type=text].form-control,
|
||||
[type=password].form-control,
|
||||
[type=email].form-control,
|
||||
[type=tel].form-control,
|
||||
[contenteditable].form-control {
|
||||
font-size: 1rem;
|
||||
box-shadow: inset 0 -1px 0 #ddd;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 -2px 0 $primary;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&[readonly] {
|
||||
box-shadow: none;
|
||||
border-bottom: 1px dotted #ddd;
|
||||
}
|
||||
|
||||
&:not(textarea) {
|
||||
height: $input-height;
|
||||
}
|
||||
|
||||
&.form-control {
|
||||
&-sm {
|
||||
height: $input-height-sm;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&-lg {
|
||||
height: $input-height-lg;
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
|
||||
&::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
select,
|
||||
select.form-control {
|
||||
appearance: none;
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAMAAACelLz8AAAAJ1BMVEVmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmaP/QSjAAAADHRSTlMAAgMJC0uWpKa6wMxMdjkoAAAANUlEQVR4AeXJyQEAERAAsNl7Hf3X6xt0QL6JpZWq30pdvdadme+0PMdzvHm8YThHcT1H7K0BtOMDniZhWOgAAAAASUVORK5CYII=);
|
||||
background-size: 13px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right center;
|
||||
box-shadow: inset 0 -1px 0 #ddd;
|
||||
|
||||
&::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.input {
|
||||
&-sm {
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&-lg {
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: inset 0 -2px 0 $primary;
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAMAAACelLz8AAAAJ1BMVEUhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhISF8S9ewAAAADHRSTlMAAgMJC0uWpKa6wMxMdjkoAAAANUlEQVR4AeXJyQEAERAAsNl7Hf3X6xt0QL6JpZWq30pdvdadme+0PMdzvHm8YThHcT1H7K0BtOMDniZhWOgAAAAASUVORK5CYII=);
|
||||
}
|
||||
|
||||
&[multiple] {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-control-label {
|
||||
&::before {
|
||||
top: 0;
|
||||
border: 2px solid $gray-400;
|
||||
}
|
||||
&::after {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.custom-control-input:checked ~ &::before {
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-checkbox .custom-control-input:checked {
|
||||
~ .custom-control-label::after {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-radio .custom-control-input {
|
||||
&:checked {
|
||||
~ .custom-control-label::before {
|
||||
background-color: $white;
|
||||
}
|
||||
~ .custom-control-label::after {
|
||||
transform: scale(1.125);
|
||||
transition: 240ms;
|
||||
}
|
||||
}
|
||||
& {
|
||||
~ .custom-control-label::after {
|
||||
transform: scale(.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.has-warning {
|
||||
input:not([type=checkbox]),
|
||||
.form-control,
|
||||
input.form-control[readonly],
|
||||
input[type=text][readonly],
|
||||
[type=text].form-control[readonly],
|
||||
input:not([type=checkbox]):focus,
|
||||
.form-control:focus {
|
||||
border-bottom: none;
|
||||
box-shadow: inset 0 -2px 0 $warning;
|
||||
}
|
||||
}
|
||||
|
||||
.has-danger {
|
||||
input:not([type=checkbox]),
|
||||
.form-control,
|
||||
input.form-control[readonly],
|
||||
input[type=text][readonly],
|
||||
[type=text].form-control[readonly],
|
||||
input:not([type=checkbox]):focus,
|
||||
.form-control:focus {
|
||||
border-bottom: none;
|
||||
box-shadow: inset 0 -2px 0 $danger;
|
||||
}
|
||||
}
|
||||
|
||||
.has-success {
|
||||
input:not([type=checkbox]),
|
||||
.form-control,
|
||||
input.form-control[readonly],
|
||||
input[type=text][readonly],
|
||||
[type=text].form-control[readonly],
|
||||
input:not([type=checkbox]):focus,
|
||||
.form-control:focus {
|
||||
border-bottom: none;
|
||||
box-shadow: inset 0 -2px 0 $success;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Bootstrap feedback styles for input addons
|
||||
.input-group-addon {
|
||||
.has-warning &, .has-danger &, .has-success & {
|
||||
color: $input-color;
|
||||
border-color: $input-group-addon-border-color;
|
||||
background-color: $input-group-addon-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
|
||||
> .input-group-prepend > .input-group-text {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
> .input-group-append > .input-group-text {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-sm {
|
||||
|
||||
> .input-group-prepend > .btn,
|
||||
> .input-group-append > .btn {
|
||||
padding: $input-btn-padding-y-sm $input-btn-padding-x-sm;
|
||||
}
|
||||
|
||||
> .input-group-prepend > .input-group-text {
|
||||
padding: $input-btn-padding-y-sm $input-btn-padding-x-sm $input-btn-padding-y-sm 0;
|
||||
}
|
||||
|
||||
> .input-group-append > .input-group-text {
|
||||
padding: $input-btn-padding-y-sm 0 $input-btn-padding-y-sm $input-btn-padding-x-sm;
|
||||
}
|
||||
|
||||
> .input-group-prepend > .btn {
|
||||
border-top-left-radius: $btn-border-radius-sm;
|
||||
border-bottom-left-radius: $btn-border-radius-sm;
|
||||
}
|
||||
|
||||
> .input-group-append > .btn {
|
||||
border-top-right-radius: $btn-border-radius-sm;
|
||||
border-bottom-right-radius: $btn-border-radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-lg {
|
||||
|
||||
> .input-group-prepend > .btn,
|
||||
> .input-group-append > .btn {
|
||||
padding: $input-btn-padding-y-lg $input-btn-padding-x-lg;
|
||||
}
|
||||
|
||||
> .input-group-prepend > .input-group-text {
|
||||
padding: $input-btn-padding-y-lg $input-btn-padding-x-lg $input-btn-padding-y-lg 0;
|
||||
}
|
||||
|
||||
> .input-group-append > .input-group-text {
|
||||
padding: $input-btn-padding-y-lg 0 $input-btn-padding-y-lg $input-btn-padding-x-lg;
|
||||
}
|
||||
|
||||
> .input-group-prepend > .btn {
|
||||
border-top-left-radius: $btn-border-radius-lg;
|
||||
border-bottom-left-radius: $btn-border-radius-lg;
|
||||
}
|
||||
|
||||
> .input-group-append > .btn {
|
||||
border-top-right-radius: $btn-border-radius-lg;
|
||||
border-bottom-right-radius: $btn-border-radius-lg;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form-group-lg {
|
||||
select,
|
||||
select.form-control {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Navs ========================================================================
|
||||
|
||||
.nav-tabs {
|
||||
.nav-item + .nav-item {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.nav-link:focus {
|
||||
margin-right: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: $body-color;
|
||||
box-shadow: inset 0 -1px 0 #ddd;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: inset 0 -2px 0 $primary;
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.nav-link.active:focus {
|
||||
border: none;
|
||||
box-shadow: inset 0 -2px 0 $primary;
|
||||
color: $primary;
|
||||
|
||||
&:hover {
|
||||
border: none;
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link.disabled {
|
||||
box-shadow: inset 0 -1px 0 #ddd;
|
||||
}
|
||||
|
||||
&.nav-justified {
|
||||
|
||||
.nav-link,
|
||||
.nav-link:hover,
|
||||
.nav-link:focus,
|
||||
.nav-link.active,
|
||||
.nav-link.active:hover,
|
||||
.nav-link.active:focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
margin-top: 0;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
// Indicators ==================================================================
|
||||
|
||||
.alert {
|
||||
padding-right: 2.5rem;
|
||||
|
||||
&, h1, h2, h3, h4, h5, h6 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@each $color, $value in $theme-colors {
|
||||
&-#{$color} {
|
||||
background-color: $value;
|
||||
}
|
||||
}
|
||||
|
||||
a:not(.btn),
|
||||
.alert-link {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&-secondary,
|
||||
&-light {
|
||||
&,
|
||||
& a:not(.btn),
|
||||
& .alert-link {
|
||||
color: $body-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
|
||||
&-secondary {
|
||||
background-color: $gray-500;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&-light {
|
||||
background-color: $gray-200;
|
||||
}
|
||||
|
||||
&-warning {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 4px 6px 4px;
|
||||
}
|
||||
|
||||
// Progress bars ===============================================================
|
||||
|
||||
// Containers ==================================================================
|
||||
|
||||
.close {
|
||||
line-height: 0.5;
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.4);
|
||||
|
||||
&.border-primary,
|
||||
&.border-secondary,
|
||||
&.border-success,
|
||||
&.border-info,
|
||||
&.border-warning,
|
||||
&.border-danger,
|
||||
&.border-light,
|
||||
&.border-dark {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-dismissible {
|
||||
.close {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-group {
|
||||
&-item-action.active {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 0.2rem;
|
||||
box-shadow: 0 6px 36px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
.popover {
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
&-caption {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Materia 4.1.1
|
||||
// Bootswatch
|
||||
|
||||
//
|
||||
// Color system
|
||||
//
|
||||
|
||||
$white: #fff !default;
|
||||
$gray-100: #f8f9fa !default;
|
||||
$gray-200: #eee !default;
|
||||
$gray-300: #dee2e6 !default;
|
||||
$gray-400: #ced4da !default;
|
||||
$gray-500: #bbb !default;
|
||||
$gray-600: #666 !default;
|
||||
$gray-700: #444 !default;
|
||||
$gray-800: #222 !default;
|
||||
$gray-900: #212121 !default;
|
||||
$black: #000 !default;
|
||||
|
||||
$blue: #2196F3 !default;
|
||||
$indigo: #6610f2 !default;
|
||||
$purple: #6f42c1 !default;
|
||||
$pink: #e83e8c !default;
|
||||
$red: #e51c23 !default;
|
||||
$orange: #fd7e14 !default;
|
||||
$yellow: #ff9800 !default;
|
||||
$green: #4CAF50 !default;
|
||||
$teal: #20c997 !default;
|
||||
$cyan: #9C27B0 !default;
|
||||
|
||||
$primary: $blue !default;
|
||||
$secondary: $white !default;
|
||||
$success: $green !default;
|
||||
$info: $cyan !default;
|
||||
$warning: $yellow !default;
|
||||
$danger: $red !default;
|
||||
$light: $white !default;
|
||||
$dark: $gray-800 !default;
|
||||
|
||||
// Body
|
||||
|
||||
$body-color: $gray-700 !default;
|
||||
|
||||
// Fonts
|
||||
|
||||
$font-family-sans-serif: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !default;
|
||||
|
||||
$font-size-base: 0.8125rem !default;
|
||||
|
||||
$font-weight-base: 400 !default;
|
||||
|
||||
// Buttons
|
||||
|
||||
$input-btn-padding-y: 1rem !default;
|
||||
$input-btn-padding-x: 1rem !default;
|
||||
|
||||
// Forms
|
||||
|
||||
$input-padding-y: 1rem !default;
|
||||
$input-padding-x: 0 !default;
|
||||
|
||||
$input-padding-y-sm: $font-size-base !default;
|
||||
$input-padding-x-sm: 0 !default;
|
||||
|
||||
$input-padding-y-lg: ($font-size-base * 1.25) !default;
|
||||
$input-padding-x-lg: 0 !default;
|
||||
|
||||
$input-bg: transparent !default;
|
||||
$input-disabled-bg: transparent !default;
|
||||
|
||||
$input-color: $gray-600 !default;
|
||||
$input-border-color: transparent !default;
|
||||
$input-border-width: 0rem !default;
|
||||
|
||||
$input-border-radius: 0 !default;
|
||||
$input-border-radius-lg: 0 !default;
|
||||
$input-border-radius-sm: 0 !default;
|
||||
|
||||
$input-placeholder-color: rgba(0,0,0,0.4) !default;
|
||||
|
||||
$input-group-addon-bg: transparent !default;
|
||||
|
||||
$custom-control-gutter: 1.75rem !default;
|
||||
|
||||
$custom-control-indicator-focus-box-shadow: 0 !default;
|
||||
|
||||
$custom-control-indicator-size: 1.25rem !default;
|
||||
$custom-control-indicator-bg: $white;
|
||||
$custom-checkbox-indicator-border-radius: 2px !default;
|
||||
|
||||
$custom-control-indicator-disabled-bg: $gray-100 !default;
|
||||
|
||||
$custom-radio-indicator-icon-checked: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='4' fill='%232196F3'/%3E%3C/svg%3E") !default;
|
||||
|
||||
// Navs
|
||||
|
||||
$nav-link-disabled-color: $gray-500 !default;
|
||||
|
||||
$nav-tabs-border-color: transparent !default;
|
||||
|
||||
// Navbar
|
||||
|
||||
$navbar-padding-y: 1rem !default;
|
||||
|
||||
$navbar-dark-color: rgba($white,.75) !default;
|
||||
$navbar-dark-hover-color: $white !default;
|
||||
|
||||
// Cards
|
||||
|
||||
$card-border-width: 0;
|
||||
$card-border-color: transparent;
|
||||
|
||||
// Tooltips
|
||||
|
||||
$tooltip-bg: $gray-700 !default;
|
||||
|
||||
// Modals
|
||||
|
||||
$modal-content-border-color: transparent !default;
|
||||
|
||||
// Progress bars
|
||||
|
||||
$progress-height: 0.375rem !default;
|
||||
$progress-border-radius: 0 !default;
|
||||
|
||||
// Close
|
||||
|
||||
$close-font-size: 2.125rem !default;
|
||||
$close-font-weight: 300 !default;
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Prepare environment for unit tests.
|
||||
* This file is required by karma.conf.js and loads recursively all the .spec and framework files.
|
||||
*/
|
||||
|
||||
import 'zone.js/dist/zone-testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
declare const require: any;
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting()
|
||||
);
|
||||
// Then we find all the tests.
|
||||
const context = require.context('./', true, /\.spec\.ts$/);
|
||||
// And load the modules.
|
||||
context.keys().map(context);
|
||||