Merged PR 1813: Strukturanpassung

Related work items: #4832
This commit is contained in:
Lorenz Hilpert
2024-10-16 14:07:57 +00:00
committed by Nino Righi
parent a518fc50e2
commit f37dfd41f1
4212 changed files with 6631 additions and 21281 deletions

View File

@@ -0,0 +1,47 @@
import { Injectable, isDevMode } from '@angular/core';
import { EnvironmentService } from '@core/environment';
import { PromptModalData, UiModalService, UiPromptModalComponent } from '@ui/modal';
import { Observable } from 'rxjs';
import { ScanAdapter } from './scan-adapter';
@Injectable()
export class DevScanAdapter implements ScanAdapter {
readonly name = 'Dev';
constructor(
private _modal: UiModalService,
private _environmentService: EnvironmentService,
) {}
async init(): Promise<boolean> {
return Promise.resolve(false);
// return new Promise((resolve, reject) => {
// resolve(isDevMode());
// });
}
scan(): Observable<string> {
return new Observable((observer) => {
const modalRef = this._modal.open({
content: UiPromptModalComponent,
title: 'Scannen',
data: {
message: 'Diese Eingabemaske dient nur zu Entwicklungs und Testzwecken.',
placeholder: 'Scan Code',
confirmText: 'weiter',
cancelText: 'abbrechen',
} as PromptModalData,
});
const sub = modalRef.afterClosed$.subscribe((result) => {
observer.next(result.data);
observer.complete();
});
return () => {
modalRef.close();
sub.unsubscribe();
};
});
}
}

View File

@@ -0,0 +1,5 @@
describe('Dummy', () => {
it('should work', () => {
expect(true).toBeTruthy();
});
});

View File

@@ -0,0 +1,7 @@
export * from './dev.scan-adapter';
export * from './native.scan-adapter';
export * from './scan-adapter';
export * from './scan.module';
export * from './scan.service';
export * from './scandit';
export * from './tokens';

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import { NativeContainerService } from '@external/native-container';
import { Observable } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
import { ScanAdapter } from './scan-adapter';
@Injectable()
export class NativeScanAdapter implements ScanAdapter {
readonly name = 'Native';
constructor(private readonly nativeContainerService: NativeContainerService) {}
init(): Promise<boolean> {
return new Promise((resolve, reject) => {
resolve(this.nativeContainerService.isNative);
});
}
scan(): Observable<string> {
return this.nativeContainerService.openScanner('scanBook').pipe(
filter((result) => result.status === 'SUCCESS'),
map((result) => result.data),
take(1),
);
}
}

View File

@@ -0,0 +1,18 @@
import { Observable } from 'rxjs';
export interface ScanAdapter {
/**
* Name to identify the adapter
*/
readonly name: string;
/**
* @returns true if this adapter can be used
*/
init(): Promise<boolean>;
/**
* scan for a barcode
*/
scan(): Observable<string>;
}

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { DevScanAdapter } from './dev.scan-adapter';
import { NativeScanAdapter } from './native.scan-adapter';
import { SCAN_ADAPTER } from './tokens';
@NgModule({})
export class ScanAdapterModule {
static forRoot() {
return {
ngModule: ScanAdapterModule,
providers: [
{ provide: SCAN_ADAPTER, useClass: NativeScanAdapter, multi: true },
{ provide: SCAN_ADAPTER, useClass: DevScanAdapter, multi: true },
],
// Use for testing:
// providers: [{ provide: SCAN_ADAPTER, useClass: dev ? DevScanAdapter : NativeScanAdapter, multi: true }],
};
}
}

View File

@@ -0,0 +1,52 @@
import { Inject, Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { ScanAdapter } from './scan-adapter';
import { SCAN_ADAPTER } from './tokens';
@Injectable({
providedIn: 'root',
})
export class ScanAdapterService {
private _readyAdapters: Record<string, boolean> = {};
constructor(@Inject(SCAN_ADAPTER) private readonly scanAdapters: ScanAdapter[]) {}
async init(): Promise<void> {
for (const adapter of this.scanAdapters) {
const isReady = await adapter.init();
this._readyAdapters[adapter.name] = isReady;
}
}
adapters(): ScanAdapter[] {
return [...this.scanAdapters];
}
getAdapter(name: string): ScanAdapter | undefined {
return this._readyAdapters[name] && this.scanAdapters.find((adapter) => adapter.name === name);
}
isReady(): boolean {
return Object.values(this._readyAdapters).some((ready) => ready);
}
scan(): Observable<string> {
const adapterOrder = ['Native', 'Scandit', 'Dev'];
let adapter: ScanAdapter;
for (const name of adapterOrder) {
adapter = this.getAdapter(name);
if (adapter) {
break;
}
}
if (!adapter) {
return throwError('No adapter found');
}
return adapter.scan();
}
}

View File

@@ -0,0 +1,3 @@
export * from './scandit-overlay.component';
export * from './scandit-scan-adapter.module';
export * from './scandit.scan-adapter';

View File

@@ -0,0 +1,19 @@
:host {
@apply block relative;
}
.scanner-container {
width: 100vw;
height: 100vh;
}
.close-scanner {
@apply absolute bottom-12 left-[50%] -translate-x-[50%] block px-6 py-4 bg-white text-brand border-2 border-solid border-brand rounded-full text-lg font-bold mx-auto mt-4;
}
@screen desktop {
.scanner-container {
max-width: 900px;
max-height: 900px;
}
}

View File

@@ -0,0 +1,2 @@
<div class="scanner-container" #scanContainer></div>
<button class="close-scanner" type="button" (click)="close()">Scan abbrechen</button>

View File

@@ -0,0 +1,103 @@
import { Component, ChangeDetectionStrategy, ElementRef, ViewChild, NgZone, AfterViewInit, OnDestroy } from '@angular/core';
import { UiMessageModalComponent, UiModalService } from '@ui/modal';
import { Barcode, BarcodePicker, ScanResult, ScanSettings } from 'scandit-sdk';
@Component({
selector: 'app-scandit-overlay',
templateUrl: 'scandit-overlay.component.html',
styleUrls: ['scandit-overlay.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScanditOverlayComponent implements AfterViewInit, OnDestroy {
private _barcodePicker: BarcodePicker;
private _onScan?: (code: string) => void;
private _onClose?: () => void;
@ViewChild('scanContainer', { read: ElementRef, static: true }) scanContainer: ElementRef;
constructor(
private _zone: NgZone,
private _modal: UiModalService,
) {}
ngAfterViewInit(): void {
this.createBarcodePicker()
.then(() => {
this._barcodePicker.on('scan', (scanResult) => {
this._zone.run(() => this.handleScanrResult(scanResult));
});
})
.catch((err: Error) => {
this._modal
.open({
content: UiMessageModalComponent,
title: 'Zugriff auf Kamera verweigert',
data: { message: 'Falls Sie den Zugriff erlauben möchten, können Sie das über die Webseiteinstellung Ihres Browsers.' },
})
.afterClosed$.subscribe(() => {
this._onClose?.();
});
});
}
async createBarcodePicker() {
this._barcodePicker = await BarcodePicker.create(this.scanContainer.nativeElement, {
playSoundOnScan: true,
vibrateOnScan: true,
});
this._barcodePicker.applyScanSettings(this.getScanSettings());
}
getScanSettings(): ScanSettings {
return new ScanSettings({
blurryRecognition: false,
enabledSymbologies: [
Barcode.Symbology.EAN8,
Barcode.Symbology.EAN13,
Barcode.Symbology.UPCA,
Barcode.Symbology.UPCE,
Barcode.Symbology.CODE128,
Barcode.Symbology.CODE39,
Barcode.Symbology.CODE93,
Barcode.Symbology.INTERLEAVED_2_OF_5,
Barcode.Symbology.QR,
],
codeDuplicateFilter: 1000,
});
}
onScan(fn: (code: string) => void) {
this._onScan = fn;
}
onClose(fn: () => void) {
this._onClose = fn;
}
handleScanrResult(scanRestul: ScanResult) {
let result: string | undefined;
if (scanRestul.barcodes.length) {
result = scanRestul.barcodes[0].data;
} else if (scanRestul.texts.length) {
result = scanRestul.texts[0].value;
}
if (result) {
this._onScan?.(result);
}
}
close() {
this._onClose?.();
}
ngOnDestroy(): void {
this._zone.runOutsideAngular(() => {
this._barcodePicker?.destroy(true);
});
}
}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScanditOverlayComponent } from './scandit-overlay.component';
import { ScanditScanAdapter } from './scandit.scan-adapter';
import { SCAN_ADAPTER } from '../tokens';
@NgModule({
imports: [CommonModule],
exports: [ScanditOverlayComponent],
declarations: [ScanditOverlayComponent],
})
export class ScanditScanAdapterModule {
static forRoot() {
return {
ngModule: ScanditScanAdapterModule,
providers: [{ provide: SCAN_ADAPTER, useClass: ScanditScanAdapter, multi: true }],
};
}
}

View File

@@ -0,0 +1,95 @@
import { Injectable } from '@angular/core';
import { Observable, Subscriber } from 'rxjs';
import { ScanAdapter } from '../scan-adapter';
import { Overlay } from '@angular/cdk/overlay';
import { configure } from 'scandit-sdk';
// import { ScanditModalComponent } from './scandit-modal';
import { Config } from '@core/config';
import { ComponentPortal } from '@angular/cdk/portal';
import { ScanditOverlayComponent } from './scandit-overlay.component';
import { EnvironmentService } from '@core/environment';
import { injectNetworkStatus$ } from 'apps/isa-app/src/app/services/network-status.service';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable()
export class ScanditScanAdapter implements ScanAdapter {
readonly name = 'Scandit';
private $networkStatus = toSignal(injectNetworkStatus$());
constructor(
private readonly _config: Config,
private _overlay: Overlay,
private _environmentService: EnvironmentService,
) {}
async init(): Promise<boolean> {
if (this._environmentService.isTablet()) {
await configure(this._config.get('licence.scandit'), {
engineLocation: '/scandit/',
});
return true;
}
return false;
}
scan(): Observable<string> {
return new Observable((observer) => {
if (this.$networkStatus() === 'offline') {
observer.error(new Error('No network connection'));
return;
}
const overlay = this.createOverlay();
const portal = this.createPortal();
const ref = overlay.attach(portal);
const sub = new Subscriber();
const complete = () => {
overlay.detach();
ref.destroy();
sub.unsubscribe();
sub.complete();
observer.complete();
};
sub.add(
overlay.backdropClick().subscribe(() => {
complete();
}),
);
ref.instance.onScan((code) => {
observer.next(code);
complete();
});
ref.instance.onClose(() => {
complete();
});
return complete;
});
}
createOverlay() {
const overlay = this._overlay.create({
positionStrategy: this._overlay.position().global().centerHorizontally().centerVertically(),
hasBackdrop: true,
});
return overlay;
}
createPortal() {
const portal = new ComponentPortal(ScanditOverlayComponent);
return portal;
}
}

View File

@@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
import { ScanAdapter } from './scan-adapter';
export const SCAN_ADAPTER = new InjectionToken<ScanAdapter>('SCAN_ADAPTER');

View File

@@ -3,7 +3,7 @@ import { EffectsModule } from '@ngrx/effects';
import { ActionReducer, MetaReducer, StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { storeFreeze } from 'ngrx-store-freeze';
import packageInfo from 'package';
import packageInfo from 'packageJson';
import { environment } from '../environments/environment';
import { RootStateService } from './store/root-state.service';
import { rootReducer } from './store/root.reducer';

View File

@@ -5,7 +5,7 @@ import { SwUpdate } from '@angular/service-worker';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { NotificationsHub } from '@hub/notifications';
import packageInfo from 'package';
import packageInfo from 'packageJson';
import { asapScheduler, interval, Subscription } from 'rxjs';
import { UserStateService } from '@swagger/isa';
import { IsaLogProvider } from './providers';

View File

@@ -33,7 +33,7 @@ import { ScanAdapterModule, ScanAdapterService, ScanditScanAdapterModule } from
import { RootStateService } from './store/root-state.service';
import * as Commands from './commands';
import { PreviewComponent } from './preview';
import { NativeContainerService } from 'native-container';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { MainComponent } from './main.component';
import { IconModule } from '@shared/components/icon';

View File

@@ -3,12 +3,15 @@ import { Router } from '@angular/router';
import { ApplicationService } from '@core/application';
import { ActionHandler } from '@core/command';
import { Result } from '@domain/defs';
import { encodeFormData, mapCustomerInfoDtoToCustomerCreateFormData } from '@page/customer';
import { CustomerInfoDTO } from '@swagger/crm';
import { encodeFormData, mapCustomerInfoDtoToCustomerCreateFormData } from 'apps/page/customer/src/lib/create-customer';
@Injectable()
export class CreateKubiCustomerCommand extends ActionHandler<Result<CustomerInfoDTO[]>> {
constructor(private _router: Router, private _application: ApplicationService) {
constructor(
private _router: Router,
private _application: ApplicationService,
) {
super('CREATE_KUBI_CUSTOMER');
}

View File

@@ -1,12 +1,15 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { CheckoutNavigationService } from '@shared/services';
import { CheckoutNavigationService } from '@shared/services/navigation';
import { first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class CanActivateCartGuard {
constructor(private readonly _applicationService: ApplicationService, private _checkoutNavigationService: CheckoutNavigationService) {}
constructor(
private readonly _applicationService: ApplicationService,
private _checkoutNavigationService: CheckoutNavigationService,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const processes = await this._applicationService.getProcesses$('customer').pipe(first()).toPromise();

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { CustomerOrdersNavigationService } from '@shared/services';
import { CustomerOrdersNavigationService } from '@shared/services/navigation';
import { first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
@@ -10,7 +10,7 @@ export class CanActivateCustomerOrdersGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _checkoutService: DomainCheckoutService,
private readonly _navigationService: CustomerOrdersNavigationService
private readonly _navigationService: CustomerOrdersNavigationService,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { CustomerSearchNavigation } from '@shared/services';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
@@ -11,7 +11,7 @@ export class CanActivateCustomerGuard {
private readonly _applicationService: ApplicationService,
private readonly _checkoutService: DomainCheckoutService,
private readonly _router: Router,
private readonly _navigation: CustomerSearchNavigation
private readonly _navigation: CustomerSearchNavigation,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { ProductCatalogNavigationService } from '@shared/services';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
@@ -10,7 +10,7 @@ export class CanActivateProductGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _checkoutService: DomainCheckoutService,
private readonly _navigationService: ProductCatalogNavigationService
private readonly _navigationService: ProductCatalogNavigationService,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {

View File

@@ -4,7 +4,7 @@ import { Store } from '@ngrx/store';
import { UserStateService } from '@swagger/isa';
import { debounceTime, switchMap, takeUntil } from 'rxjs/operators';
import { RootState } from './root.state';
import packageInfo from 'package';
import packageInfo from 'packageJson';
import { environment } from '../../environments/environment';
import { Subject } from 'rxjs';
@@ -14,7 +14,11 @@ export class RootStateService {
private _cancelSave = new Subject<void>();
constructor(private readonly _userStateService: UserStateService, private _logger: Logger, private _store: Store) {
constructor(
private readonly _userStateService: UserStateService,
private _logger: Logger,
private _store: Store,
) {
if (!environment.production) {
console.log('Die UserState kann in der Konsole mit der Funktion "clearUserState()" geleert werden.');
}
@@ -40,7 +44,7 @@ export class RootStateService {
const raw = JSON.stringify({ ...state, version: packageInfo.version });
RootStateService.SaveToLocalStorageRaw(raw);
return this._userStateService.UserStateSetUserState({ content: raw });
})
}),
)
.subscribe();
}

View File

@@ -0,0 +1,5 @@
export * from './product-image-navigation.directive';
export * from './product-image.module';
export * from './product-image.pipe';
export * from './product-image.service';
export * from './tokens';

View File

@@ -0,0 +1,31 @@
import { Directive, HostListener, Input } from '@angular/core';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
@Directive({
selector: '[productImageNavigation]',
standalone: true,
})
export class NavigateOnClickDirective {
@Input('productImageNavigation') ean: string;
constructor(private readonly _productCatalogNavigation: ProductCatalogNavigationService) {}
@HostListener('click', ['$event'])
async onClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.ean) {
await this._navigateToProductSearchDetails();
}
}
private async _navigateToProductSearchDetails() {
await this._productCatalogNavigation
.getArticleDetailsPathByEan({
processId: Date.now(),
ean: this.ean,
extras: { queryParams: { main_qs: this.ean } },
})
.navigate();
}
}

View File

@@ -0,0 +1,9 @@
import { NgModule } from '@angular/core';
import { ProductImagePipe } from './product-image.pipe';
@NgModule({
declarations: [],
imports: [ProductImagePipe],
exports: [ProductImagePipe],
})
export class ProductImageModule {}

View File

@@ -0,0 +1,14 @@
import { Pipe, PipeTransform } from '@angular/core';
import { ProductImageService } from './product-image.service';
@Pipe({
name: 'productImage',
standalone: true,
pure: true,
})
export class ProductImagePipe implements PipeTransform {
constructor(private imageService: ProductImageService) {}
transform(imageId: string, width?: number, height?: number, showDummy?: boolean): any {
return this.imageService.getImageUrl({ imageId, width, height, showDummy });
}
}

View File

@@ -0,0 +1,20 @@
import { Config } from '@core/config';
import { createServiceFactory, SpectatorService } from '@ngneat/spectator';
import { ProductImageService } from './product-image.service';
describe('ProductImageService', () => {
let spectator: SpectatorService<ProductImageService>;
const createService = createServiceFactory({
service: ProductImageService,
mocks: [Config],
});
beforeEach(() => {
spectator = createService();
});
it('should be created', () => {
expect(spectator.service).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { Inject, Injectable } from '@angular/core';
import { Config } from '@core/config';
import { CDN_PRODUCT_IMAGE } from './tokens';
@Injectable({
providedIn: 'root',
})
export class ProductImageService {
constructor(private readonly _config: Config) {}
getImageUrl({
imageId,
width = 150,
height = 150,
showDummy = true,
}: {
imageId: string;
width?: number;
height?: number;
showDummy?: boolean;
}): string {
return `${this._config.get('@cdn/product-image.url')}/${imageId}_${width}x${height}.jpg?showDummy=${showDummy}`;
}
}

View File

@@ -0,0 +1,3 @@
import { InjectionToken } from '@angular/core';
export const CDN_PRODUCT_IMAGE = new InjectionToken<string>('cdn.product.image');

View File

@@ -0,0 +1,23 @@
import { NgModule, ModuleWithProviders } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { applicationReducer } from './store';
import { ApplicationService } from './application.service';
@NgModule({
declarations: [],
imports: [],
exports: [],
})
export class CoreApplicationModule {
static forRoot(): ModuleWithProviders<CoreApplicationModule> {
return {
ngModule: RootCoreApplicationModule,
};
}
}
@NgModule({
imports: [StoreModule.forFeature('core-application', applicationReducer)],
providers: [ApplicationService],
})
export class RootCoreApplicationModule {}

View File

@@ -0,0 +1,233 @@
// import { createServiceFactory, SpectatorService, SpyObject } from '@ngneat/spectator';
// import { Store } from '@ngrx/store';
// import { Observable, of } from 'rxjs';
// import { first } from 'rxjs/operators';
// import { ApplicationProcess } from './defs';
// import { ApplicationService } from './application.service';
// import * as actions from './store/application.actions';
// describe('ApplicationService', () => {
// let spectator: SpectatorService<ApplicationService>;
// let store: SpyObject<Store>;
// const createService = createServiceFactory({
// service: ApplicationService,
// mocks: [Store],
// });
// beforeEach(() => {
// spectator = createService({});
// store = spectator.inject(Store);
// });
// it('should be created', () => {
// expect(spectator.service).toBeTruthy();
// });
// describe('activatedProcessId$', () => {
// it('should return an observable', () => {
// expect(spectator.service.activatedProcessId$).toBeInstanceOf(Observable);
// });
// });
// describe('activatedProcessId', () => {
// it('should return the process id as a number', () => {
// spyOnProperty(spectator.service['activatedProcessIdSubject'] as any, 'value').and.returnValue(2);
// expect(spectator.service.activatedProcessId).toBe(2);
// });
// });
// describe('getProcesses$()', () => {
// it('should call select on store and return all selected processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$().pipe(first()).toPromise();
// expect(result).toEqual(processes);
// expect(store.select).toHaveBeenCalled();
// });
// it('should call select on store and return all section customer processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$('customer').pipe(first()).toPromise();
// expect(result).toEqual([processes[0]]);
// expect(store.select).toHaveBeenCalled();
// });
// it('should call select on store and return all section branch processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$('branch').pipe(first()).toPromise();
// expect(result).toEqual([processes[1]]);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('getProcessById$()', () => {
// it('should return the process by id', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer' },
// { id: 2, name: 'Vorgang 2', section: 'customer' },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// const process = await spectator.service.getProcessById$(1).toPromise();
// expect(process.id).toBe(1);
// });
// });
// describe('getSection$()', () => {
// it('should return the selected section branch', async () => {
// const section = 'branch';
// store.select.and.returnValue(of(section));
// const result = await spectator.service.getSection$().pipe(first()).toPromise();
// expect(result).toEqual(section);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('getActivatedProcessId$', () => {
// it('should return the current selected activated process id', async () => {
// const activatedProcessId = 2;
// store.select.and.returnValue(of({ id: activatedProcessId }));
// const result = await spectator.service.getActivatedProcessId$().pipe(first()).toPromise();
// expect(result).toEqual(activatedProcessId);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('activateProcess()', () => {
// it('should dispatch action setActivatedProcess with argument activatedProcessId and action type', () => {
// const activatedProcessId = 2;
// spectator.service.activateProcess(activatedProcessId);
// expect(store.dispatch).toHaveBeenCalledWith({ activatedProcessId, type: actions.setActivatedProcess.type });
// });
// });
// describe('removeProcess()', () => {
// it('should dispatch action removeProcess with argument processId and action type', () => {
// const processId = 2;
// spectator.service.removeProcess(processId);
// expect(store.dispatch).toHaveBeenCalledWith({ processId, type: actions.removeProcess.type });
// });
// });
// describe('createProcess()', () => {
// it('should dispatch action addProcess with process', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// const timestamp = 100;
// spyOn(spectator.service as any, '_createTimestamp').and.returnValue(timestamp);
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of(undefined));
// await spectator.service.createProcess(process);
// expect(store.dispatch).toHaveBeenCalledWith({
// type: actions.addProcess.type,
// process: {
// ...process,
// activated: 0,
// created: timestamp,
// },
// });
// });
// it('should throw an error if the process id is already existing', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of(process));
// await expectAsync(spectator.service.createProcess(process)).toBeRejectedWithError('Process Id existiert bereits');
// });
// it('should throw an error if the process id is not a number', async () => {
// const process: ApplicationProcess = {
// id: undefined,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of({ id: 5, name: 'Vorgang 2', section: 'customer' }));
// await expectAsync(spectator.service.createProcess(process)).toBeRejectedWithError('Process Id nicht gesetzt');
// });
// });
// describe('patchProcess', () => {
// it('should dispatch action patchProcess with changes', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// await spectator.service.patchProcess(process.id, process);
// expect(store.dispatch).toHaveBeenCalledWith({
// type: actions.patchProcess.type,
// processId: process.id,
// changes: {
// ...process,
// },
// });
// });
// });
// describe('setSection()', () => {
// it('should dispatch action setSection with argument section and action type', () => {
// const section = 'customer';
// spectator.service.setSection(section);
// expect(store.dispatch).toHaveBeenCalledWith({ section, type: actions.setSection.type });
// });
// });
// describe('getLastActivatedProcessWithSectionAndType()', () => {
// it('should return the last activated process by section and type', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', type: 'cart', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', type: 'cart', activated: 200 },
// { id: 3, name: 'Vorgang 3', section: 'customer', type: 'goodsOut', activated: 300 },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// expect(await spectator.service.getLastActivatedProcessWithSectionAndType$('customer', 'cart').pipe(first()).toPromise()).toBe(
// processes[1]
// );
// });
// });
// describe('getLastActivatedProcessWithSection()', () => {
// it('should return the last activated process by section', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', activated: 200 },
// { id: 3, name: 'Vorgang 3', section: 'customer', activated: 300 },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// expect(await spectator.service.getLastActivatedProcessWithSection$('customer').pipe(first()).toPromise()).toBe(processes[2]);
// });
// });
// describe('_createTimestamp', () => {
// it('should return the current timestamp in ms', () => {
// expect(spectator.service['_createTimestamp']()).toBeCloseTo(Date.now());
// });
// });
// });

View File

@@ -0,0 +1,169 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { BranchDTO } from '@swagger/checkout';
import { isBoolean, isNumber } from '@utils/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { ApplicationProcess } from './defs';
import {
removeProcess,
selectSection,
selectProcesses,
setSection,
addProcess,
setActivatedProcess,
selectActivatedProcess,
patchProcess,
patchProcessData,
selectTitle,
setTitle,
} from './store';
@Injectable()
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(map((processes) => processes.filter((process) => (section ? process.section === section : true))));
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(map((processes) => processes.find((process) => process.id === processId)));
}
getSection$() {
return this.store.select(selectSection);
}
getTitle$() {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store.select(selectActivatedProcess).pipe(map((process) => process?.id));
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) => this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch))),
);
}
return this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch));
}
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await this.getProcesses$('customer').pipe(first()).toPromise();
const processIds = processes.filter((x) => this.REGEX_PROCESS_NAME.test(x.name)).map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id).pipe(first()).toPromise();
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
}
getLastActivatedProcessWithSectionAndType$(section: 'customer' | 'branch', type: string): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(section: 'customer' | 'branch'): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
private _createTimestamp() {
return Date.now();
}
}

View File

@@ -0,0 +1,11 @@
export interface ApplicationProcess {
id: number;
created?: number;
activated?: number;
name: string;
section: 'customer' | 'branch';
type?: string;
data?: { [key: string]: any };
closeable?: boolean;
confirmClosing?: boolean;
}

View File

@@ -0,0 +1,3 @@
// start:ng42.barrel
export * from './application-process';
// end:ng42.barrel

View File

@@ -0,0 +1,4 @@
export * from './application.module';
export * from './application.service';
export * from './defs';
export * from './store';

View File

@@ -0,0 +1,18 @@
import { createAction, props } from '@ngrx/store';
import { ApplicationProcess } from '..';
const prefix = '[CORE-APPLICATION]';
export const setTitle = createAction(`${prefix} Set Title`, props<{ title: string }>());
export const setSection = createAction(`${prefix} Set Section`, props<{ section: 'customer' | 'branch' }>());
export const addProcess = createAction(`${prefix} Add Process`, props<{ process: ApplicationProcess }>());
export const removeProcess = createAction(`${prefix} Remove Process`, props<{ processId: number }>());
export const setActivatedProcess = createAction(`${prefix} Set Activated Process`, props<{ activatedProcessId: number }>());
export const patchProcess = createAction(`${prefix} Patch Process`, props<{ processId: number; changes: Partial<ApplicationProcess> }>());
export const patchProcessData = createAction(`${prefix} Patch Process Data`, props<{ processId: number; data: Record<string, any> }>());

View File

@@ -0,0 +1,200 @@
import { INITIAL_APPLICATION_STATE } from './application.state';
import * as actions from './application.actions';
import { applicationReducer } from './application.reducer';
import { ApplicationProcess } from '../defs';
import { ApplicationState } from './application.state';
describe('applicationReducer', () => {
describe('setSection()', () => {
it('should return modified state with section customer', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.setSection({ section: 'customer' });
const state = applicationReducer(initialState, action);
expect(state).toEqual({
...initialState,
section: 'customer',
});
});
it('should return modified state with section branch', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.setSection({ section: 'branch' });
const state = applicationReducer(initialState, action);
expect(state).toEqual({
...initialState,
section: 'branch',
});
});
});
describe('addProcess()', () => {
it('should return modified state with new process if no processes are registered in the state', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
data: {},
};
const action = actions.addProcess({ process });
const state = applicationReducer(initialState, action);
expect(state.processes[0]).toEqual(process);
});
});
describe('patchProcess()', () => {
it('should return modified state with updated process when id is found', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
};
const action = actions.patchProcess({ processId: process.id, changes: { ...process, name: 'Test' } });
const state = applicationReducer(
{
...initialState,
processes: [process],
},
action,
);
expect(state.processes[0].name).toEqual('Test');
});
it('should return unmodified state when id is not existing', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
};
const action = actions.patchProcess({ processId: process.id, changes: { ...process, id: 2 } });
const state = applicationReducer(
{
...initialState,
processes: [process],
},
action,
);
expect(state.processes).toEqual([process]);
});
});
describe('removeProcess()', () => {
it('should return initial state if no processes are registered in the state', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(initialState, action);
expect(state).toEqual(initialState);
});
it('should return the unmodified state if processId not found', () => {
const initialState = INITIAL_APPLICATION_STATE;
const modifiedState: ApplicationState = {
...initialState,
section: 'customer',
processes: [
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
{
id: 4,
name: 'Vorgang',
section: 'customer',
type: 'goods-out',
},
] as ApplicationProcess[],
};
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(modifiedState, action);
expect(state).toEqual(modifiedState);
});
it('should return modified state, after process gets removed', () => {
const initialState = INITIAL_APPLICATION_STATE;
const modifiedState: ApplicationState = {
...initialState,
section: 'customer',
processes: [
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
{
id: 2,
name: 'Vorgang',
section: 'customer',
type: 'goods-out',
},
] as ApplicationProcess[],
};
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(modifiedState, action);
expect(state.processes).toEqual([
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
]);
});
});
describe('setActivatedProcess()', () => {
it('should return modified state with process.activated value', () => {
const process: ApplicationProcess = {
id: 3,
name: 'Vorgang 3',
section: 'customer',
};
const initialState: ApplicationState = {
...INITIAL_APPLICATION_STATE,
processes: [process],
};
const action = actions.setActivatedProcess({ activatedProcessId: 3 });
const state = applicationReducer(initialState, action);
expect(state.processes[0].activated).toBeDefined();
});
it('should return modified state without process.activated value when activatedProcessId doesnt exist', () => {
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang 3',
section: 'customer',
};
const initialState: ApplicationState = {
...INITIAL_APPLICATION_STATE,
processes: [process],
};
const action = actions.setActivatedProcess({ activatedProcessId: 3 });
const state = applicationReducer(initialState, action);
expect(state.processes[0].activated).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,56 @@
import { Action, createReducer, on } from '@ngrx/store';
import {
setSection,
addProcess,
removeProcess,
setActivatedProcess,
patchProcess,
patchProcessData,
setTitle,
} from './application.actions';
import { ApplicationState, INITIAL_APPLICATION_STATE } from './application.state';
const _applicationReducer = createReducer(
INITIAL_APPLICATION_STATE,
on(setTitle, (state, { title }) => ({ ...state, title })),
on(setSection, (state, { section }) => ({ ...state, section })),
on(addProcess, (state, { process }) => ({ ...state, processes: [...state.processes, { data: {}, ...process }] })),
on(removeProcess, (state, { processId }) => {
const processes = state?.processes?.filter((process) => process.id !== processId) || [];
return { ...state, processes };
}),
on(setActivatedProcess, (state, { activatedProcessId }) => {
const processes = state.processes.map((process) => {
if (process.id === activatedProcessId) {
return { ...process, activated: Date.now() };
}
return process;
});
return { ...state, processes };
}),
on(patchProcess, (state, { processId, changes }) => {
const processes = state.processes.map((process) => {
if (process.id === processId) {
return { ...process, ...changes, id: processId };
}
return process;
});
return { ...state, processes };
}),
on(patchProcessData, (state, { processId, data }) => {
const processes = state.processes.map((process) => {
if (process.id === processId) {
return { ...process, data: { ...(process.data || {}), ...data } };
}
return process;
});
return { ...state, processes };
}),
);
export function applicationReducer(state: ApplicationState, action: Action) {
return _applicationReducer(state, action);
}

View File

@@ -0,0 +1,35 @@
// import { ApplicationState } from './application.state';
// import { ApplicationProcess } from '../defs';
// import * as selectors from './application.selectors';
// describe('applicationSelectors', () => {
// it('should select the processes', () => {
// const processes: ApplicationProcess[] = [{ id: 1, name: 'Vorgang 1', section: 'customer' }];
// const state: ApplicationState = {
// processes,
// section: 'customer',
// };
// expect(selectors.selectProcesses.projector(state)).toEqual(processes);
// });
// it('should select the section', () => {
// const state: ApplicationState = {
// processes: [],
// section: 'customer',
// };
// expect(selectors.selectSection.projector(state)).toEqual('customer');
// });
// it('should select the activatedProcess', () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', activated: 300 },
// { id: 3, name: 'Vorgang 3', section: 'customer', activated: 200 },
// ];
// const state: ApplicationState = {
// processes,
// section: 'customer',
// };
// expect(selectors.selectActivatedProcess.projector(state)).toEqual(processes[1]);
// });
// });

View File

@@ -0,0 +1,18 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ApplicationState } from './application.state';
export const selectApplicationState = createFeatureSelector<ApplicationState>('core-application');
export const selectTitle = createSelector(selectApplicationState, (s) => s.title);
export const selectSection = createSelector(selectApplicationState, (s) => s.section);
export const selectProcesses = createSelector(selectApplicationState, (s) => s.processes);
export const selectActivatedProcess = createSelector(selectApplicationState, (s) =>
s?.processes?.reduce((process, current) => {
if (!process) {
return current;
}
return process.activated > current.activated ? process : current;
}, undefined),
);

View File

@@ -0,0 +1,13 @@
import { ApplicationProcess } from '../defs';
export interface ApplicationState {
title: string;
processes: ApplicationProcess[];
section: 'customer' | 'branch';
}
export const INITIAL_APPLICATION_STATE: ApplicationState = {
title: '',
processes: [],
section: 'customer',
};

View File

@@ -0,0 +1,6 @@
// start:ng42.barrel
export * from './application.actions';
export * from './application.reducer';
export * from './application.selectors';
export * from './application.state';
// end:ng42.barrel

View File

@@ -0,0 +1,21 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { AuthService } from './auth.service';
import { OAuthModule } from 'angular-oauth2-oidc';
import { IfRoleDirective } from './if-role.directive';
@NgModule({
declarations: [IfRoleDirective],
exports: [IfRoleDirective],
})
export class AuthModule {
static forRoot(): ModuleWithProviders<AuthModule> {
return {
ngModule: AuthModule,
providers: [
AuthService,
OAuthModule.forRoot({
resourceServer: { sendAccessToken: true },
}).providers,
],
};
}
}

View File

@@ -0,0 +1,151 @@
import { Config } from '@core/config';
import { SpectatorService, createServiceFactory, SpyObject } from '@ngneat/spectator';
import { OAuthService } from 'angular-oauth2-oidc';
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let spectator: SpectatorService<AuthService>;
const createService = createServiceFactory({
service: AuthService,
mocks: [Config, OAuthService],
});
let config: SpyObject<Config>;
let oAuthService: SpyObject<OAuthService>;
beforeEach(() => {
spectator = createService();
config = spectator.inject(Config);
oAuthService = spectator.inject(OAuthService);
});
it('should be created', () => {
expect(spectator.service).toBeTruthy();
});
describe('init()', () => {
it('should configure the oAuthService', () => {
config.get.and.returnValue({});
spectator.service.init();
expect(oAuthService.configure).toHaveBeenCalledWith({
redirectUri: window.location.origin,
silentRefreshRedirectUri: window.location.origin + '/silent-refresh.html',
useSilentRefresh: true,
});
expect(oAuthService.tokenValidationHandler).toBeInstanceOf(JwksValidationHandler);
expect(oAuthService.setupAutomaticSilentRefresh).toHaveBeenCalled();
});
it('should load the discovery document', async () => {
config.get.and.returnValue({});
oAuthService.loadDiscoveryDocumentAndTryLogin.and.returnValue(Promise.resolve(true));
spyOn(spectator.service['_initialized'], 'next');
await spectator.service.init();
expect(oAuthService.loadDiscoveryDocumentAndTryLogin).toHaveBeenCalled();
expect(spectator.service['_initialized'].next).toHaveBeenCalledWith(true);
});
it('should throw an error if its already initialized', async () => {
spyOn(spectator.service['_initialized'], 'getValue').and.returnValue(true);
await expectAsync(spectator.service.init()).toBeRejectedWithError('AuthService is already initialized');
});
});
describe('isAuthenticated()', () => {
it('should call hasValidIdToken() and return its value', () => {
oAuthService.hasValidIdToken.and.returnValue(true);
expect(spectator.service.isAuthenticated()).toBeTrue();
expect(oAuthService.hasValidIdToken).toHaveBeenCalled();
});
});
describe('getToken()', () => {
it('should call getAccessToken() and return its value', () => {
oAuthService.getAccessToken.and.returnValue('token');
expect(spectator.service.getToken()).toEqual('token');
expect(oAuthService.getAccessToken).toHaveBeenCalled();
});
});
describe('getClaims()', () => {
it('should call getAccessToken() and return its value', () => {
oAuthService.getAccessToken.and.returnValue('token');
const claims = {
claim1: 'value',
claim2: 'value2',
};
spyOn(spectator.service, 'parseJwt').and.returnValue(claims);
expect(spectator.service.getClaims()).toEqual(claims);
expect(spectator.service.parseJwt).toHaveBeenCalledWith('token');
expect(oAuthService.getAccessToken).toHaveBeenCalled();
});
});
describe('getClaimByKey()', () => {
it('should call getClaims() and return its key value', () => {
spyOn(spectator.service, 'getClaims').and.returnValue({
claim1: 'value',
claim2: 'value2',
});
expect(spectator.service.getClaimByKey('claim1')).toEqual('value');
expect(spectator.service.getClaims).toHaveBeenCalled();
});
it('should return null if getClaims() returns null or undefined', () => {
spyOn(spectator.service, 'getClaims').and.returnValue(null);
expect(spectator.service.getClaimByKey('claim1')).toBeNull();
expect(spectator.service.getClaims).toHaveBeenCalled();
});
});
describe('parseJwt()', () => {
it('should return null if the token is null or undefined', () => {
expect(spectator.service.parseJwt(null)).toBeNull();
expect(spectator.service.parseJwt(undefined)).toBeNull();
});
it('should return the value of the key', () => {
const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
expect(spectator.service.parseJwt(token)).toEqual({
sub: '1234567890',
name: 'John Doe',
admin: true,
});
});
});
describe('login()', () => {
it('should call initLoginFlow()', () => {
spectator.service.login();
expect(oAuthService.initLoginFlow).toHaveBeenCalled();
});
});
describe('logout()', () => {
it('should call revokeTokenAndLogout()', async () => {
await spectator.service.logout();
expect(oAuthService.revokeTokenAndLogout).toHaveBeenCalled();
});
});
describe('getToken()', () => {
it('should return getAccessToken()', () => {
spectator.service.getToken();
expect(oAuthService.getAccessToken).toHaveBeenCalled();
});
});
describe('initialized', () => {
it('should return _initialized as Observable', () => {
spyOn(spectator.service['_initialized'], 'asObservable').and.callThrough();
expect(spectator.service.initialized$).toBeInstanceOf(Observable);
expect(spectator.service['_initialized'].asObservable).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,130 @@
import { coerceArray, coerceStringArray } from '@angular/cdk/coercion';
import { Injectable } from '@angular/core';
import { Config } from '@core/config';
import { isNullOrUndefined } from '@utils/common';
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
import { asapScheduler, BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly _initialized = new BehaviorSubject<boolean>(false);
get initialized$() {
return this._initialized.asObservable();
}
private _authConfig: AuthConfig;
constructor(
private _config: Config,
private readonly _oAuthService: OAuthService,
) {
this._oAuthService.events?.subscribe((event) => {
if (event.type === 'token_received') {
console.log('SSO Token Expiration:', new Date(this._oAuthService.getAccessTokenExpiration()));
}
});
}
async init() {
if (this._initialized.getValue()) {
throw new Error('AuthService is already initialized');
}
this._authConfig = this._config.get('@core/auth');
this._authConfig.redirectUri = window.location.origin;
this._authConfig.silentRefreshRedirectUri = window.location.origin + '/silent-refresh.html';
this._authConfig.useSilentRefresh = true;
this._oAuthService.configure(this._authConfig);
this._oAuthService.tokenValidationHandler = new JwksValidationHandler();
this._oAuthService.setupAutomaticSilentRefresh();
try {
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
} catch (error) {
this.login();
throw error;
}
this._initialized.next(true);
}
isAuthenticated() {
return this._oAuthService.hasValidIdToken();
}
getToken() {
return this._oAuthService.getAccessToken();
}
getClaims() {
const token = this._oAuthService.getAccessToken();
return this.parseJwt(token);
}
getClaimByKey(key: string) {
const claims = this.getClaims();
if (isNullOrUndefined(claims)) {
return null;
}
return claims[key];
}
parseJwt(token: string) {
if (isNullOrUndefined(token)) {
return null;
}
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const encoded = window.atob(base64);
return JSON.parse(encoded);
}
login() {
this._oAuthService.initLoginFlow();
}
setKeyCardToken(token: string) {
this._oAuthService.customQueryParams = {
temp_token: token,
};
}
async logout() {
await this._oAuthService.revokeTokenAndLogout();
// asapScheduler.schedule(() => {
// window.location.reload();
// }, 250);
}
hasRole(role: string | string[]) {
const roles = coerceArray(role);
const userRoles = this.getClaimByKey('role');
if (isNullOrUndefined(userRoles)) {
return false;
}
return roles.every((r) => userRoles.includes(r));
}
async refresh() {
try {
if (this._authConfig.responseType.includes('code') && this._authConfig.scope.includes('offline_access')) {
await this._oAuthService.refreshToken();
} else {
await this._oAuthService.silentRefresh();
}
} catch (error) {
console.error(error);
}
}
}

View File

@@ -0,0 +1,65 @@
// import { SpectatorDirective, createDirectiveFactory } from '@ngneat/spectator';
// import { IfRoleDirective } from './if-role.directive';
// import { AuthService } from './auth.service';
// import { TemplateRef, ViewContainerRef } from '@angular/core';
// describe('IfRoleDirective', () => {
// let spectator: SpectatorDirective<IfRoleDirective>;
// const createDirective = createDirectiveFactory({
// directive: IfRoleDirective,
// mocks: [AuthService],
// });
// it('should create an instance', () => {
// spectator = createDirective(`<div *ifRole="'admin'"></div>`);
// expect(spectator.directive).toBeTruthy();
// });
// it('should render template when user has the role', () => {
// spectator = createDirective(`<div *ifRole="'admin'"></div>`);
// const authService = spectator.inject(AuthService);
// const viewContainerRef = spectator.inject(ViewContainerRef);
// const templateRef = spectator.inject(TemplateRef);
// authService.hasRole.and.returnValue(true);
// spectator.directive.ngOnChanges();
// expect(viewContainerRef.createEmbeddedView).toHaveBeenCalledWith(templateRef, spectator.directive.getContext());
// });
// it('should render else template when user does not have the role', () => {
// authService.hasRole.and.returnValue(false);
// const elseTemplateRef = {} as TemplateRef<any>;
// spectator = createDirective(`<ng-template #elseTemplateRef></ng-template><div *ifRole="'admin'; else elseTemplateRef"></div>`, {
// hostProps: {
// elseTemplateRef,
// },
// });
// spectator.directive.ngOnChanges();
// expect(viewContainerRef.createEmbeddedView).toHaveBeenCalledWith(elseTemplateRef, spectator.directive.getContext());
// });
// it('should render else template when user does not have the role using ifNotRole input', () => {
// authService.hasRole.and.returnValue(false);
// const elseTemplateRef = {} as TemplateRef<any>;
// spectator = createDirective(`<ng-template #elseTemplateRef></ng-template><div *ifNotRole="'admin'; else elseTemplateRef"></div>`, {
// hostProps: {
// elseTemplateRef,
// },
// });
// spectator.directive.ngOnChanges();
// expect(viewContainerRef.createEmbeddedView).toHaveBeenCalledWith(elseTemplateRef, spectator.directive.getContext());
// });
// it('should clear view when user does not have the role and elseTemplateRef is not defined', () => {
// authService.hasRole.and.returnValue(false);
// spectator = createDirective(`<div *ifRole="'admin'"></div>`);
// spectator.directive.ngOnChanges();
// expect(viewContainerRef.clear).toHaveBeenCalled();
// });
// it('should set $implicit to ifRole or ifNotRole input', () => {
// spectator = createDirective(`<div *ifRole="'admin'"></div>`);
// expect(spectator.directive.getContext().$implicit).toEqual('admin');
// spectator.setInput('ifNotRole', 'user');
// expect(spectator.directive.getContext().$implicit).toEqual('user');
// });
// });

View File

@@ -0,0 +1,63 @@
import { Directive, Input, OnChanges, TemplateRef, ViewContainerRef } from '@angular/core';
import { AuthService } from './auth.service';
@Directive({
selector: '[ifRole],[ifRoleElse],[ifNotRole],[ifNotRoleElse]',
})
export class IfRoleDirective implements OnChanges {
@Input()
ifRole: string | string[];
@Input()
ifRoleElse: TemplateRef<any>;
@Input()
ifNotRole: string | string[];
@Input()
ifNotRoleElse: TemplateRef<any>;
get renderTemplateRef() {
if (this.ifRole) {
return this._authService.hasRole(this.ifRole);
}
if (this.ifNotRole) {
return !this._authService.hasRole(this.ifNotRole);
}
return false;
}
get elseTemplateRef() {
return this.ifRoleElse || this.ifNotRoleElse;
}
constructor(
private _templateRef: TemplateRef<any>,
private _viewContainer: ViewContainerRef,
private _authService: AuthService,
) {}
ngOnChanges() {
this.render();
}
render() {
if (this.renderTemplateRef) {
this._viewContainer.createEmbeddedView(this._templateRef, this.getContext());
return;
}
if (this.elseTemplateRef) {
this._viewContainer.createEmbeddedView(this.elseTemplateRef, this.getContext());
return;
}
this._viewContainer.clear();
}
getContext(): { $implicit: string | string[] } {
return {
$implicit: this.ifRole || this.ifNotRole,
};
}
}

View File

@@ -0,0 +1,3 @@
export * from './auth.module';
export * from './auth.service';
export * from './if-role.directive';

View File

@@ -0,0 +1,34 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { isNumber } from '@utils/common';
import { of } from 'rxjs';
import { BreadcrumbService } from './breadcrumb.service';
import { Breadcrumb } from './defs';
import * as actions from './store/breadcrumb.actions';
import * as selectors from './store/breadcrumb.selectors';
describe('Breadcrumb Service', () => {
let store: jasmine.SpyObj<Store<any>>;
let service: BreadcrumbService;
beforeEach(() => {
store = jasmine.createSpyObj<Store<any>>('Store', ['select', 'dispatch']);
TestBed.configureTestingModule({
providers: [BreadcrumbService, { provide: Store, useValue: store }],
});
service = TestBed.inject(BreadcrumbService);
});
describe('addBreadcrumb', () => {
it('should call store.dispatch with the addBreadecrumb action and retuns a breadcrumb with an id', () => {
let breadcrumb: Breadcrumb = { name: 'unit-test', key: 'hello-key', path: 'Run The Test', section: 'customer' };
breadcrumb = service.addBreadcrumb(breadcrumb);
expect(store.dispatch).toHaveBeenCalledWith(actions.addBreadcrumb({ breadcrumb }));
expect(isNumber(breadcrumb.id)).toBeTruthy();
expect(isNumber(breadcrumb.timestamp)).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,143 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { getNumberId } from '@utils/id';
import { Observable } from 'rxjs';
import { first, map, take } from 'rxjs/operators';
import { Breadcrumb } from './defs';
import * as actions from './store/breadcrumb.actions';
import * as selectors from './store/breadcrumb.selectors';
@Injectable()
export class BreadcrumbService {
constructor(private store: Store<any>) {}
getAll$() {
return this.store.select(selectors.selectBreadcrumbs);
}
getByKey$(key: string): Observable<Breadcrumb[]> {
return this.store.select(selectors.selectBreadcrumbsByKey, key);
}
getBreadcrumbById$(id: number): Observable<Breadcrumb> {
return this.store.select(selectors.selectBreadcrumbById, id);
}
getBreadcrumbByKey$(key: string | number): Observable<Breadcrumb[]> {
return this.store.select(selectors.selectBreadcrumbsByKey, key);
}
getLastActivatedBreadcrumbByKey$(key: string | number): Observable<Breadcrumb> {
return this.getBreadcrumbByKey$(key).pipe(
map((crumbs) =>
crumbs.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest.timestamp > current.timestamp ? latest : current;
}, undefined),
),
);
}
addBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb {
const newBreadcrumb: Breadcrumb = { ...breadcrumb, id: getNumberId(), timestamp: Date.now(), changed: Date.now() };
this.store.dispatch(actions.addBreadcrumb({ breadcrumb: newBreadcrumb }));
return newBreadcrumb;
}
patchBreadcrumb(breadcrumbId: number, changes: Partial<Breadcrumb>) {
this.store.dispatch(actions.updateBreadcrumb({ id: breadcrumbId, changes: { ...changes, changed: Date.now() } }));
}
async patchBreadcrumbByKeyAndTags(key: string | number, tags: string[], changes: Partial<Breadcrumb>) {
const crumbs = await this.getBreadcrumbsByKeyAndTags$(key, tags).pipe(first()).toPromise();
crumbs.forEach((crumb) => this.patchBreadcrumb(crumb.id, changes));
}
async addBreadcrumbIfNotExists(breadcrumb: Breadcrumb) {
const crumbs = await this.getBreadcrumbsByKeyAndTags$(breadcrumb.key, breadcrumb.tags).pipe(take(1)).toPromise();
if (crumbs.length === 0) {
return this.addBreadcrumb(breadcrumb);
}
return crumbs[0];
}
async addOrUpdateBreadcrumbIfNotExists(breadcrumb: Breadcrumb) {
const crumbs = await this.getBreadcrumbsByKeyAndTags$(breadcrumb.key, breadcrumb.tags).pipe(take(1)).toPromise();
if (crumbs.length === 0) {
return this.addBreadcrumb(breadcrumb);
}
return this.patchBreadcrumb(crumbs[0].id, breadcrumb);
}
getBreadcrumbsByKeyAndTag$(key: string | number, tag: string): Observable<Breadcrumb[]> {
return this.store.select(selectors.selectBreadcrumbsByKeyAndTag, { key, tag });
}
getBreadcrumbsByKeyAndTags$(key: string | number, tags: string[]): Observable<Breadcrumb[]> {
return this.store.select(selectors.selectBreadcrumbsByKeyAndTags, { key, tags });
}
async removeBreadcrumbsAfter(breadcrumbId: number, withTags: string[] = []) {
const breadcrumb = await this.getBreadcrumbById$(breadcrumbId).pipe(take(1)).toPromise();
if (!breadcrumb) {
return;
}
let breadcrumbs: Breadcrumb[];
if (withTags?.length > 0) {
breadcrumbs = await this.getBreadcrumbsByKeyAndTags$(breadcrumb.key, withTags).pipe(take(1)).toPromise();
} else {
breadcrumbs = await this.getBreadcrumbByKey$(breadcrumb.key).pipe(take(1)).toPromise();
}
if (!breadcrumbs?.length) {
return;
}
const breadcrumbsToRemove = breadcrumbs.filter((crumb) => crumb.timestamp > breadcrumb.timestamp);
if (!breadcrumbsToRemove.length) {
return;
}
this.store.dispatch(actions.removeManyBreadcrumb({ ids: breadcrumbsToRemove.map((crumb) => crumb.id) }));
}
async removeBreadcrumb(breadcrumbId: number, recursive: boolean = true) {
const breadcrumb = await this.getBreadcrumbById$(breadcrumbId).pipe(take(1)).toPromise();
if (!breadcrumb) {
return;
}
let breadcrumbsToRemove = [breadcrumb];
if (recursive) {
const breadcrumbs = await this.getBreadcrumbByKey$(breadcrumb.key).pipe(take(1)).toPromise();
breadcrumbsToRemove = [...breadcrumbsToRemove, ...breadcrumbs.filter((crumb) => crumb.timestamp > breadcrumb.timestamp)];
}
if (!breadcrumbsToRemove.length) {
return;
}
this.store.dispatch(actions.removeManyBreadcrumb({ ids: breadcrumbsToRemove.map((crumb) => crumb.id) }));
}
async removeBreadcrumbsByKeyAndTags(key: number | string, tags: string[]) {
const crumbs = await this.getBreadcrumbsByKeyAndTags$(key, tags).pipe(first()).toPromise();
crumbs.forEach((crumb) => this.removeBreadcrumb(crumb.id));
}
getLatestBreadcrumbForSection(section: 'customer' | 'branch', predicate: (crumb: Breadcrumb) => boolean = (_) => true) {
return this.store
.select(selectors.selectBreadcrumbsBySection, { section })
.pipe(map((crumbs) => crumbs.sort((a, b) => b.timestamp - a.timestamp).find((f) => predicate(f))));
}
}

View File

@@ -0,0 +1,22 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { BreadcrumbService } from './breadcrumb.service';
import { BreadcrumbEffects } from './store/breadcrumb.effect';
import { breadcrumbReducer } from './store/breadcrumb.reducer';
import { featureName } from './store/breadcrumb.state';
@NgModule()
export class CoreBreadcrumbModule {
static forRoot(): ModuleWithProviders<CoreBreadcrumbModule> {
return {
ngModule: CoreBreadcrumbForRootModule,
};
}
}
@NgModule({
imports: [StoreModule.forFeature(featureName, breadcrumbReducer), EffectsModule.forFeature([BreadcrumbEffects])],
providers: [BreadcrumbService],
})
export class CoreBreadcrumbForRootModule {}

View File

@@ -0,0 +1,46 @@
export interface Breadcrumb {
/**
* Eindeutige ID für die Entity
*/
id?: number;
/**
* Identifier für ein Teilbereich/ProzessId der Applikation
*/
key: number | string;
/**
* Tags
*/
tags?: string[];
/**
* Anzeigename
*/
name: string;
/**
* Url
*/
path: string | any[];
/**
* Query Parameter
*/
params?: Object;
/**
* Timestamp
*/
timestamp?: number;
/**
* Cahnged
*/
changed?: number;
/**
* Applicatiuon Section
*/
section: string;
}

View File

@@ -0,0 +1,3 @@
// start:ng42.barrel
export * from './breadcrumb.model';
// end:ng42.barrel

View File

@@ -0,0 +1,3 @@
export * from './breadcrumb.service';
export * from './core-breadcrumb.module';
export * from './defs';

View File

@@ -0,0 +1 @@
describe('Breadcrumb Actions', () => {});

View File

@@ -0,0 +1,24 @@
import { createAction, props } from '@ngrx/store';
import { Breadcrumb } from '../defs';
const prefix = '[CORE-BREADCRUMB]';
/**
* Action um Breadcrumb zum State hinzufügen
*/
export const addBreadcrumb = createAction(`${prefix} Add Breadcrumb`, props<{ breadcrumb: Breadcrumb }>());
/**
* Action um Breadcrumb im State zu ändern
*/
export const updateBreadcrumb = createAction(`${prefix} Update Breadcrumb`, props<{ id: number; changes: Partial<Breadcrumb> }>());
/**
* Action um Breadcrumb im State zu entfernen
*/
export const removeBreadcrumb = createAction(`${prefix} Remove Breadcrumb`, props<{ id: number }>());
/**
* Action um mehrere Breadcrumbs im State zu entfernen
*/
export const removeManyBreadcrumb = createAction(`${prefix} Remove Many Breadcrumb`, props<{ ids: number[] }>());

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { removeProcess } from '@core/application';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { NEVER } from 'rxjs';
import { mergeMap, tap, first, map } from 'rxjs/operators';
import { BreadcrumbService } from '../breadcrumb.service';
@Injectable()
export class BreadcrumbEffects {
removeProcess$ = createEffect(
() =>
this.actions$.pipe(
ofType(removeProcess),
mergeMap((action) =>
this.breadcrumb.getBreadcrumbByKey$(action.processId).pipe(
first(),
tap((breadcrumbs) => {
breadcrumbs?.forEach((crumb) => this.breadcrumb.removeBreadcrumb(crumb.id));
}),
),
),
),
{ dispatch: false },
);
constructor(
private actions$: Actions,
private breadcrumb: BreadcrumbService,
) {}
}

View File

@@ -0,0 +1,92 @@
import { Breadcrumb } from '../defs';
import * as action from './breadcrumb.actions';
import { breadcrumbReducer } from './breadcrumb.reducer';
import { INIT } from './breadcrumb.state';
describe('Breadcrumb Reducer', () => {
it('should return the initial state if the current state is empty', () => {
const fixture = breadcrumbReducer(undefined, { type: '' });
expect(fixture).toEqual(INIT);
});
describe('addBreadcrumb', () => {
it('should add the breadcrumb to the state', () => {
const breadcrumb: Breadcrumb = {
id: 1,
key: 'unit-test',
name: 'Test Name',
path: 'Test Patch',
section: 'customer',
};
const fixture = breadcrumbReducer(INIT, action.addBreadcrumb({ breadcrumb }));
expect(fixture.entities[1]).toEqual(breadcrumb);
});
});
describe('updateBreadcrumb', () => {
it('should update an existing breadcrumb', () => {
const breadcrumb: Breadcrumb = {
id: 1,
key: 'unit-test',
name: 'Test Name',
path: 'Test Patch',
section: 'customer',
};
const expected = {
...breadcrumb,
name: 'Test Name 2',
};
const state = breadcrumbReducer(INIT, action.addBreadcrumb({ breadcrumb }));
const fixture = breadcrumbReducer(state, action.updateBreadcrumb({ id: breadcrumb.id, changes: { name: 'Test Name 2' } }));
expect(fixture.entities[breadcrumb.id]).toEqual(expected);
});
});
describe('removeBreadcrumb', () => {
it('should remove the breadcrumb', () => {
const breadcrumb: Breadcrumb = {
id: 1,
key: 'unit-test',
name: 'Test Name',
path: 'Test Patch',
section: 'customer',
};
const state = breadcrumbReducer(INIT, action.addBreadcrumb({ breadcrumb }));
const fixture = breadcrumbReducer(state, action.removeBreadcrumb({ id: breadcrumb.id }));
expect(fixture.entities[breadcrumb.id]).toBeUndefined();
});
});
describe('removeManyBreadcrumb', () => {
it('should remove all breadcrumbs for the given ids', () => {
const breadcrumb1: Breadcrumb = {
id: 1,
key: 'unit-test',
name: 'Test Name',
path: 'Test Patch',
section: 'customer',
};
const breadcrumb2 = { ...breadcrumb1, id: 2 };
const breadcrumb3 = { ...breadcrumb1, id: 3 };
let state = breadcrumbReducer(INIT, action.addBreadcrumb({ breadcrumb: breadcrumb1 }));
state = breadcrumbReducer(state, action.addBreadcrumb({ breadcrumb: breadcrumb2 }));
state = breadcrumbReducer(state, action.addBreadcrumb({ breadcrumb: breadcrumb3 }));
const fixture = breadcrumbReducer(state, action.removeManyBreadcrumb({ ids: [1, 3] }));
expect(Object.keys(fixture.entities).length).toEqual(1);
expect(fixture.entities[breadcrumb2.id]).toEqual(breadcrumb2);
});
});
});

View File

@@ -0,0 +1,16 @@
import { Action, createReducer, on } from '@ngrx/store';
import { breadcrumbAdapter, BreadcrumbState, INIT } from './breadcrumb.state';
import * as actions from './breadcrumb.actions';
const _breadcrumbReducer = createReducer(
INIT,
on(actions.addBreadcrumb, (s, a) => breadcrumbAdapter.addOne(a.breadcrumb, s)),
on(actions.updateBreadcrumb, (s, a) => breadcrumbAdapter.updateOne(a, s)),
on(actions.removeBreadcrumb, (s, a) => breadcrumbAdapter.removeOne(a.id, s)),
on(actions.removeManyBreadcrumb, (s, a) => breadcrumbAdapter.removeMany(a.ids, s)),
);
export function breadcrumbReducer(state: BreadcrumbState, action: Action) {
return _breadcrumbReducer(state, action);
}

View File

@@ -0,0 +1,57 @@
import * as selector from './breadcrumb.selectors';
import * as action from './breadcrumb.actions';
import { breadcrumbReducer } from './breadcrumb.reducer';
import { BreadcrumbState, INIT } from './breadcrumb.state';
describe('Breadcrumb Selectors', () => {
let state: BreadcrumbState;
beforeEach(() => {
state = breadcrumbReducer(
INIT,
action.addBreadcrumb({ breadcrumb: { id: 1, key: 'unit-test-1', path: '', name: 'Unit Test 1', section: 'customer' } }),
);
state = breadcrumbReducer(
state,
action.addBreadcrumb({
breadcrumb: { id: 2, key: 'unit-test-1', path: '', name: 'Unit Test 1', tags: ['details'], section: 'customer' },
}),
);
state = breadcrumbReducer(
state,
action.addBreadcrumb({ breadcrumb: { id: 3, key: 'unit-test-2', path: '', name: 'Unit Test 1', section: 'customer' } }),
);
state = breadcrumbReducer(
state,
action.addBreadcrumb({ breadcrumb: { id: 4, key: 'unit-test-3', path: '', name: 'Unit Test 1', section: 'customer' } }),
);
state = breadcrumbReducer(
state,
action.addBreadcrumb({
breadcrumb: { id: 5, key: 'unit-test-3', path: '', name: 'Unit Test 1', tags: ['details'], section: 'customer' },
}),
);
});
describe('selectBreadcrumbsByKey', () => {
it('should return all breadcrumbs with the key unit-test-1', () => {
const fixture = selector.selectBreadcrumbsByKey.projector(Object.values(state.entities), 'unit-test-1');
expect(fixture.length).toBe(2);
expect(fixture[0].key).toBe('unit-test-1');
expect(fixture[1].key).toBe('unit-test-1');
});
});
describe('selectBreadcrumbsByKeyAndTag', () => {
it('should return all breadcrumbs with the key unit-test-3 and tag details', () => {
const fixture = selector.selectBreadcrumbsByKeyAndTag.projector(Object.values(state.entities), {
key: 'unit-test-3',
tag: 'details',
});
expect(fixture.length).toBe(1);
expect(fixture[0].key).toBe('unit-test-3');
expect(fixture[0].tags).toContain('details');
});
});
});

View File

@@ -0,0 +1,48 @@
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { Breadcrumb } from '../defs';
import { breadcrumbAdapter, BreadcrumbState, featureName } from './breadcrumb.state';
import { isArray } from '@utils/common';
const selectFeature = createFeatureSelector<BreadcrumbState>(featureName);
const { selectAll, selectEntities } = breadcrumbAdapter.getSelectors(selectFeature);
/**
* Gibt alle Breadcrumb Entities als Array zurück
*/
export const selectBreadcrumbs = selectAll;
/**
* Gibt alle Breadcrumb Entities als Array zurück die den key enthalten
*/
export const selectBreadcrumbById = createSelector(selectEntities, (entities, id: number) => entities[id]);
/**
* Gibt alle Breadcrumb Entities als Array zurück die den key enthalten
*/
export const selectBreadcrumbsByKey = createSelector(selectAll, (entities, key: string) => entities.filter((crumb) => crumb.key == key));
/**
* Gibt alle Breadcrumb Entities als Array zurück die den key und tag enthalten
*/
export const selectBreadcrumbsByKeyAndTag = createSelector(
selectAll,
(entities: Breadcrumb[], { key, tag }: { key: string; tag: string }) =>
entities.filter((crumb) => crumb.key == key && isArray(crumb.tags) && crumb.tags.includes(tag)),
);
/**
* Gibt alle Breadcrumb Entities als Array zurück die den key und tags enthalten
*/
export const selectBreadcrumbsByKeyAndTags = createSelector(
selectAll,
(entities: Breadcrumb[], { key, tags }: { key: string; tags: string[] }) =>
entities.filter((crumb) => crumb.key == key && isArray(crumb.tags) && tags.every((tag) => crumb.tags.includes(tag))),
);
/**
* Gibt alle Breadcrumb Entities als Array zurück die die tags enthalten
*/
export const selectBreadcrumbsBySection = createSelector(selectAll, (entities: Breadcrumb[], { section }: { section: string }) =>
entities.filter((crumb) => crumb.section === section),
);

View File

@@ -0,0 +1,12 @@
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { Breadcrumb } from '../defs';
export interface BreadcrumbState extends EntityState<Breadcrumb> {}
export const featureName = 'core-breadcrumb';
export const breadcrumbAdapter = createEntityAdapter<Breadcrumb>();
export const INIT: BreadcrumbState = {
...breadcrumbAdapter.getInitialState(),
};

View File

@@ -0,0 +1,3 @@
export interface CacheOptions {
ttl?: number;
}

View File

@@ -0,0 +1,8 @@
import { NgModule } from '@angular/core';
@NgModule({
declarations: [],
imports: [],
exports: [],
})
export class CacheModule {}

View File

@@ -0,0 +1,184 @@
import { effect, inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AuthService } from '@core/auth';
import { memorize } from '@utils/common';
import { interval } from 'rxjs';
interface DbEntry<T> {
key: string;
token: object | string;
data: T;
ttl: number;
}
const H12INMS = 1000 * 60 * 60 * 12;
@Injectable({ providedIn: 'root' })
export class CacheService {
private auth = inject(AuthService);
private db!: IDBDatabase;
get sub() {
return Number(this.auth.getClaimByKey('sub'));
}
_cleanupInterval = toSignal(interval(1000 * 60));
_cleanupIntervalEffect = effect(() => {
this._cleanupInterval();
this.cleanup();
});
get storeName() {
return 'cache';
}
private getKey(token: Object | string): string {
if (typeof token === 'string') {
return this.hash(token);
}
return this.hash(JSON.stringify(token));
}
private hash(data: string): string {
let hash = 0;
for (let i = 0; i < data.length; i++) {
hash = data.charCodeAt(i) + ((hash << 5) - hash);
}
return (this.sub + hash).toString(16);
}
@memorize()
private async openDB(): Promise<IDBDatabase> {
if (this.db) {
return this.db; // Datenbank bereits geöffnet, bestehende Verbindung zurückgeben
}
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('isa-cache', 1);
request.onerror = (event) => {
reject(event);
};
request.onupgradeneeded = (event) => {
this.db = (event.target as IDBOpenDBRequest).result;
if (!this.db.objectStoreNames.contains(this.storeName)) {
this.db.createObjectStore(this.storeName, { keyPath: 'key' });
}
};
request.onsuccess = (event) => {
this.db = (event.target as IDBOpenDBRequest).result;
resolve(this.db);
};
});
}
private async getObjectStore(mode: IDBTransactionMode = 'readonly'): Promise<IDBObjectStore> {
const db = await this.openDB(); // Datenbankverbindung öffnen oder wiederverwenden
const transaction = db.transaction(this.storeName, mode);
return transaction.objectStore(this.storeName);
}
async set<T>(token: object | string, data: T, options: { ttl?: number } = {}): Promise<string> {
const store = await this.getObjectStore('readwrite');
return new Promise<string>((resolve, reject) => {
const key = this.getKey(token);
const entry: DbEntry<T> = {
key,
data,
token,
ttl: Date.now() + (options.ttl || H12INMS),
};
const request = store.put(entry);
request.onsuccess = (event) => {
resolve(key);
};
request.onerror = (event) => {
reject(event);
};
});
}
private async cached(token: Object | string): Promise<DbEntry<any> | undefined> {
const store = await this.getObjectStore();
return new Promise<DbEntry<any> | undefined>((resolve, reject) => {
const request = store.get(this.getKey(token));
request.onsuccess = (event) => {
resolve((event.target as IDBRequest).result);
};
request.onerror = (event) => {
reject(event);
};
});
}
async get<T = any>(token: Object | string): Promise<T | undefined> {
const cached = await this.cached(token);
if (!cached) {
return undefined;
}
if (cached.ttl < Date.now()) {
this.delete(token);
return undefined;
}
return cached.data;
}
async delete(token: Object | string): Promise<void> {
const store = await this.getObjectStore('readwrite');
return new Promise<void>((resolve, reject) => {
const request = store.delete(this.getKey(token));
request.onsuccess = () => {
resolve();
};
request.onerror = (event) => {
reject(event);
};
});
}
async cleanup() {
const store = await this.getObjectStore('readwrite');
store.openCursor().onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor) {
if (cursor.value.ttl < Date.now()) {
store.delete(cursor.key);
}
cursor.continue();
}
};
return new Promise<void>((resolve, reject) => {
store.transaction.oncomplete = () => {
resolve();
};
store.transaction.onerror = (event) => {
reject(event);
};
});
}
async clear() {
const store = await this.getObjectStore('readwrite');
return new Promise<void>((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = (event) => {
reject(event);
};
});
}
}

3
apps/isa-app/src/core/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
export * from './cache-options';
export * from './cache.module';
export * from './cache.service';

View File

@@ -0,0 +1,6 @@
import { CommandService } from './command.service';
export abstract class ActionHandler<T = any> {
constructor(readonly action: string) {}
abstract handler(data: T, service?: CommandService): Promise<T>;
}

View File

@@ -0,0 +1,25 @@
import { ModuleWithProviders, NgModule, Provider, Type } from '@angular/core';
import { ActionHandler } from './action-handler.interface';
import { CommandService } from './command.service';
import { FEATURE_ACTION_HANDLERS, ROOT_ACTION_HANDLERS } from './tokens';
export function provideActionHandlers(actionHandlers: Type<ActionHandler>[]): Provider[] {
return [CommandService, actionHandlers.map((handler) => ({ provide: FEATURE_ACTION_HANDLERS, useClass: handler, multi: true }))];
}
@NgModule({})
export class CoreCommandModule {
static forRoot(actionHandlers: Type<ActionHandler>[]): ModuleWithProviders<CoreCommandModule> {
return {
ngModule: CoreCommandModule,
providers: [CommandService, actionHandlers.map((handler) => ({ provide: ROOT_ACTION_HANDLERS, useClass: handler, multi: true }))],
};
}
static forChild(actionHandlers: Type<ActionHandler>[]): ModuleWithProviders<CoreCommandModule> {
return {
ngModule: CoreCommandModule,
providers: [CommandService, actionHandlers.map((handler) => ({ provide: FEATURE_ACTION_HANDLERS, useClass: handler, multi: true }))],
};
}
}

View File

@@ -0,0 +1,16 @@
// import { TestBed } from '@angular/core/testing';
// import { CommandService } from './command.service';
// describe('CommandService', () => {
// let service: CommandService;
// beforeEach(() => {
// TestBed.configureTestingModule({});
// service = TestBed.inject(CommandService);
// });
// it('should be created', () => {
// expect(service).toBeTruthy();
// });
// });

View File

@@ -0,0 +1,43 @@
import { Injectable, Injector, Optional, SkipSelf } from '@angular/core';
import { ActionHandler } from './action-handler.interface';
import { FEATURE_ACTION_HANDLERS, ROOT_ACTION_HANDLERS } from './tokens';
@Injectable()
export class CommandService {
constructor(
private injector: Injector,
@Optional() @SkipSelf() private _parent: CommandService,
) {}
async handleCommand<T>(command: string, data?: T): Promise<T> {
const actions = this.getActions(command);
for (const action of actions) {
const handler = this.getActionHandler(action);
if (!handler) {
console.error('CommandService.handleCommand', 'Action Handler does not exist', { action });
throw new Error('Action Handler does not exist');
}
data = await handler.handler(data, this);
}
return data;
}
getActions(command: string) {
return command?.split('|') || [];
}
getActionHandler(action: string): ActionHandler | undefined {
const featureActionHandlers: ActionHandler[] = this.injector.get(FEATURE_ACTION_HANDLERS, []);
const rootActionHandlers: ActionHandler[] = this.injector.get(ROOT_ACTION_HANDLERS, []);
let handler = [...featureActionHandlers, ...rootActionHandlers].find((handler) => handler.action === action);
if (this._parent && !handler) {
handler = this._parent.getActionHandler(action);
}
return handler;
}
}

View File

@@ -0,0 +1,4 @@
export * from './action-handler.interface';
export * from './command.module';
export * from './command.service';
export * from './tokens';

View File

@@ -0,0 +1,6 @@
import { InjectionToken } from '@angular/core';
import { ActionHandler } from './action-handler.interface';
export const ROOT_ACTION_HANDLERS = new InjectionToken<ActionHandler[]>('@core/domain ROOT_ACTION_HANDLER');
export const FEATURE_ACTION_HANDLERS = new InjectionToken<ActionHandler[]>('@core/domain FEATURE_ACTION_HANDLER');

View File

@@ -0,0 +1,8 @@
import { Observable } from 'rxjs';
/**
* Config loader interface for loading configurations
*/
export interface ConfigLoader {
load(): Promise<any>;
}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './config-loader';
export * from './json.config-loader';
// end:ng42.barrel

View File

@@ -0,0 +1,36 @@
// unit test JsonConfigLoader
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { createServiceFactory, SpectatorService } from '@ngneat/spectator';
import { CORE_JSON_CONFIG_LOADER_URL } from '../tokens';
import { JsonConfigLoader } from './json.config-loader';
describe('JsonConfigLoader', () => {
let spectator: SpectatorService<JsonConfigLoader>;
const createService = createServiceFactory({
imports: [HttpClientTestingModule],
service: JsonConfigLoader,
mocks: [],
providers: [{ provide: CORE_JSON_CONFIG_LOADER_URL, useValue: '/assets/config.json' }],
});
let httpTestingController: HttpTestingController;
beforeEach(() => {
spectator = createService();
httpTestingController = spectator.inject(HttpTestingController);
});
it('should create', () => {
expect(spectator.service).toBeTruthy();
});
describe('load', () => {
it('should call the provided url', async () => {
const reqPromise = spectator.service.load();
const req = httpTestingController.expectOne('/assets/config.json');
req.flush({ unit: 'test' });
const result = await reqPromise;
httpTestingController.verify();
expect(result).toEqual({ unit: 'test' });
});
});
});

View File

@@ -0,0 +1,16 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { ConfigLoader } from './config-loader';
import { CORE_JSON_CONFIG_LOADER_URL } from '../tokens';
@Injectable()
export class JsonConfigLoader implements ConfigLoader {
constructor(
@Inject(CORE_JSON_CONFIG_LOADER_URL) private url: string,
private http: HttpClient,
) {}
load(): Promise<any> {
return this.http.get(this.url).toPromise();
}
}

View File

@@ -0,0 +1,7 @@
import { Type } from '@angular/core';
import { ConfigLoader } from './config-loaders';
export interface ConfigModuleOptions {
useConfigLoader: Type<ConfigLoader>;
jsonConfigLoaderUrl?: string;
}

View File

@@ -0,0 +1,28 @@
import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core';
import { CORE_CONFIG_LOADER } from '@core/config';
import { Config } from './config';
import { ConfigModuleOptions } from './config-module-options';
import { CORE_JSON_CONFIG_LOADER_URL } from './tokens';
export function _initializeConfigFactory(config: Config) {
return () => config.init();
}
@NgModule({})
export class ConfigModule {
static forRoot(options: ConfigModuleOptions): ModuleWithProviders<ConfigModule> {
const configLoaderProvider = {
provide: CORE_CONFIG_LOADER,
useClass: options.useConfigLoader,
};
return {
ngModule: ConfigModule,
providers: [
Config,
configLoaderProvider,
options.jsonConfigLoaderUrl ? { provide: CORE_JSON_CONFIG_LOADER_URL, useValue: options.jsonConfigLoaderUrl } : null,
],
};
}
}

View File

@@ -0,0 +1,45 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator';
import { Config } from './config';
import { ConfigLoader } from './config-loaders';
import { CORE_CONFIG_LOADER } from './tokens';
class TestConfigLoader implements ConfigLoader {
load() {
return Promise.resolve({});
}
}
// Unit test Config
describe('Config', () => {
let spectator: SpectatorService<Config>;
const createService = createServiceFactory({
service: Config,
providers: [{ provide: CORE_CONFIG_LOADER, useClass: TestConfigLoader }],
});
let configLoader: ConfigLoader;
beforeEach(() => {
spectator = createService();
configLoader = spectator.inject(CORE_CONFIG_LOADER);
});
it('should create', () => {
expect(spectator.service).toBeTruthy();
});
describe('init()', () => {
it('should load config and assigns it to _config', async () => {
const config = { unit: 'test' };
spyOn(configLoader, 'load').and.returnValue(Promise.resolve(config));
await spectator.service.init();
expect(spectator.service['_config']).toEqual(config);
});
});
describe('get()', () => {
it('should return config value', () => {
spectator.service['_config'] = { test: 'test' };
expect(spectator.service.get('test')).toEqual('test');
});
});
});

View File

@@ -0,0 +1,27 @@
import { Inject, Injectable } from '@angular/core';
import { ReplaySubject } from 'rxjs';
import { ConfigLoader } from './config-loaders';
import { CORE_CONFIG_LOADER } from './tokens';
import { pick } from './utils';
@Injectable()
export class Config {
private _config: any;
private readonly _initilized = new ReplaySubject<void>(1);
get initialized() {
return this._initilized.asObservable();
}
constructor(@Inject(CORE_CONFIG_LOADER) private readonly _configLoader: ConfigLoader) {}
// load config and assign it to this._config
async init() {
this._config = await this._configLoader.load();
this._initilized.next();
}
get(path: string) {
return pick(path, this._config);
}
}

View File

@@ -0,0 +1,6 @@
export * from './config-loaders';
export * from './config-module-options';
export * from './config.module';
export * from './config';
export * from './tokens';
export * from './utils';

View File

@@ -0,0 +1,6 @@
import { InjectionToken } from '@angular/core';
import { ConfigLoader } from './config-loaders';
export const CORE_CONFIG_LOADER = new InjectionToken<ConfigLoader>('core.config.loader');
export const CORE_JSON_CONFIG_LOADER_URL = new InjectionToken<ConfigLoader>('core.json.config.loader.url');

View File

@@ -0,0 +1,3 @@
// start:ng42.barrel
export * from './pick';
// end:ng42.barrel

View File

@@ -0,0 +1,41 @@
import { pick } from './pick';
describe('pick', () => {
it('should pick properties from the 1st level from the object', () => {
const obj = {
foo: 'bar',
};
expect(pick('foo', obj)).toEqual('bar');
});
it('should pick properties from the 2nd level from the object', () => {
const obj = {
foo: {
bar: 'baz',
},
};
expect(pick('foo.bar', obj)).toEqual('baz');
});
it('should pick properties from the 3rd level from the object', () => {
const obj = {
foo: {
bar: {
baz: 'qux',
},
},
};
expect(pick('foo.bar.baz', obj)).toEqual('qux');
});
it('should throw an error of obj is not an object', () => {
expect(() => pick('foo', 'bar')).toThrowError(`bar is not an object`);
});
it('should return undefined if the property is not found', () => {
const obj = {
foo: 'bar',
};
expect(pick('bar', obj)).toEqual(undefined);
});
});

View File

@@ -0,0 +1,33 @@
/**
* Pick a value from an object at a given path.
* @param path path of the value to pick
* @param obj object to pick from
* @returns the value at the path or undefined
* @throws if obj is not an object
*/
export function pick<T = any>(path: string, obj: Object): T {
const paths = path.split('.');
// check if obj is null or undefined
if (obj == null) {
return undefined;
}
// check if obj is of type object and not an array
// and throw an error if not
if (typeof obj !== 'object' || Array.isArray(obj)) {
throw new Error(`${obj} is not an object`);
}
let result = obj;
// loop through the path and pick the value
// early exit if the path is empty
for (const path of paths) {
result = result[path];
if (result == null) {
return undefined;
}
}
return result as T;
}

View File

@@ -0,0 +1,8 @@
import { NgModule } from '@angular/core';
@NgModule({
declarations: [],
imports: [],
exports: [],
})
export class EnvironmentModule {}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { EnvironmentService } from './environment.service';
describe('EnvironmentService', () => {
let service: EnvironmentService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(EnvironmentService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,86 @@
import { Injectable } from '@angular/core';
import { Platform } from '@angular/cdk/platform';
import { NativeContainerService } from '@external/native-container';
import { BreakpointObserver } from '@angular/cdk/layout';
import { map } from 'rxjs/operators';
const MATCH_TABLET = '(max-width: 1023px)';
const MATCH_DESKTOP_SMALL = '(min-width: 1024px) and (max-width: 1279px)';
const MATCH_DESKTOP = '(min-width: 1280px)';
const MATCH_DESKTOP_LARGE = '(min-width: 1440px)';
const MATCH_DESKTOP_XLARGE = '(min-width: 1920px)';
const MATCH_DESKTOP_XXLARGE = '(min-width: 2736px)';
@Injectable({
providedIn: 'root',
})
export class EnvironmentService {
constructor(
private _platform: Platform,
private _nativeContainer: NativeContainerService,
private _breakpointObserver: BreakpointObserver,
) {}
matchTablet(): boolean {
return this._breakpointObserver.isMatched(MATCH_TABLET);
}
matchTablet$ = this._breakpointObserver.observe(MATCH_TABLET).pipe(map((result) => result.matches));
matchDesktopSmall(): boolean {
return this._breakpointObserver.isMatched(MATCH_DESKTOP_SMALL);
}
matchDesktopSmall$ = this._breakpointObserver.observe(MATCH_DESKTOP_SMALL).pipe(map((result) => result.matches));
matchDesktop(): boolean {
return this._breakpointObserver.isMatched(MATCH_DESKTOP);
}
matchDesktop$ = this._breakpointObserver.observe(MATCH_DESKTOP).pipe(map((result) => result.matches));
matchDesktopLarge(): boolean {
return this._breakpointObserver.isMatched(MATCH_DESKTOP_LARGE);
}
matchDesktopLarge$ = this._breakpointObserver.observe(MATCH_DESKTOP_LARGE).pipe(map((result) => result.matches));
matchDesktopXLarge(): boolean {
return this._breakpointObserver.isMatched(MATCH_DESKTOP_XLARGE);
}
matchDesktopXLarge$ = this._breakpointObserver.observe(MATCH_DESKTOP_XLARGE).pipe(map((result) => result.matches));
matchDesktopXXLarge(): boolean {
return this._breakpointObserver.isMatched(MATCH_DESKTOP_XXLARGE);
}
matchDesktopXXLarge$ = this._breakpointObserver.observe(MATCH_DESKTOP_XXLARGE).pipe(map((result) => result.matches));
/**
* @deprecated Use `matchDesktopSmall` or 'matchDesktop' instead.
*/
isDesktop(): boolean {
return !this.isTablet();
}
/**
* @deprecated Use `matchTablet` instead.
*/
isTablet(): boolean {
return this.isNative() || this.isSafari();
}
isNative(): boolean {
return this._nativeContainer.isNative;
}
isSafari(): boolean {
return this._platform.IOS && this._platform.SAFARI;
}
}

View File

@@ -0,0 +1,2 @@
export * from './environment.module';
export * from './environment.service';

View File

@@ -0,0 +1,51 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator';
import { ConsoleLogProvider } from './console-log.provider';
import { LogLevel } from './log-level';
describe('ConsoleLogProvider', () => {
let spectator: SpectatorService<ConsoleLogProvider>;
const createService = createServiceFactory({
service: ConsoleLogProvider,
});
beforeEach(() => {
spectator = createService();
});
it('should create', () => {
expect(spectator.service).toBeTruthy();
});
describe('log', () => {
it('should call console.debug', () => {
spyOn(console, 'debug');
spectator.service.log(LogLevel.DEBUG, 'test');
expect(console.debug).toHaveBeenCalledWith('test');
});
it('should call console.info', () => {
spyOn(console, 'info');
spectator.service.log(LogLevel.INFO, 'test');
expect(console.info).toHaveBeenCalledWith('test');
});
it('should call console.warn', () => {
spyOn(console, 'warn');
spectator.service.log(LogLevel.WARN, 'test');
expect(console.warn).toHaveBeenCalledWith('test');
});
it('should call console.error', () => {
spyOn(console, 'error');
spectator.service.log(LogLevel.ERROR, 'test');
expect(console.error).toHaveBeenCalledWith('test');
});
it('should not call console.log', () => {
spyOn(console, 'log');
spectator.service.log(LogLevel.OFF, 'test');
expect(console.log).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { LogLevel } from './log-level';
import { LogProvider } from './log.provider';
@Injectable()
export class ConsoleLogProvider implements LogProvider {
log(logLevel: LogLevel, message: string, ...optionalParams: any[]): void {
switch (logLevel) {
case LogLevel.DEBUG:
console.debug(message, ...optionalParams);
break;
case LogLevel.INFO:
console.info(message, ...optionalParams);
break;
case LogLevel.WARN:
console.warn(message, ...optionalParams);
break;
case LogLevel.ERROR:
console.error(message, ...optionalParams);
break;
case LogLevel.OFF:
break;
}
}
}

View File

@@ -0,0 +1,6 @@
export * from './console-log.provider';
export * from './log-level';
export * from './log.provider';
export * from './logger.module';
export * from './logger.service';
export * from './tokens';

View File

@@ -0,0 +1,14 @@
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
OFF = 4,
// aliases
debug = 0,
info = 1,
warn = 2,
error = 3,
off = 4,
}

View File

@@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
import { LogLevel } from './log-level';
export interface LogProvider {
log(logLevel: LogLevel, message: string, ...optionalParams: any[]): void;
}

View File

@@ -0,0 +1,31 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { Config } from '@core/config';
import { ConsoleLogProvider } from './console-log.provider';
import { Logger } from './logger.service';
import { LOG_PROVIDER, LOG_LEVEL } from './tokens';
export function _logLevelProviderFactory(config: Config) {
return config.get('@core/logger.logLevel');
}
@NgModule({})
export class CoreLoggerModule {
static forRoot(): ModuleWithProviders<CoreLoggerModule> {
return {
ngModule: CoreLoggerModule,
providers: [
Logger,
{
provide: LOG_PROVIDER,
useClass: ConsoleLogProvider,
multi: true,
},
{
provide: LOG_LEVEL,
useFactory: _logLevelProviderFactory,
deps: [Config],
},
],
};
}
}

View File

@@ -0,0 +1,140 @@
import { SpectatorService, createServiceFactory } from '@ngneat/spectator';
import { LogLevel } from './log-level';
import { Logger } from './logger.service';
import { LOG_PROVIDER } from './tokens';
import { LOG_LEVEL } from '.';
describe('LoggerService', () => {
let spectator: SpectatorService<Logger>;
let logProviderMock1 = jasmine.createSpyObj('LogProvider', ['log']);
let logProviderMock2 = jasmine.createSpyObj('LogProvider', ['log']);
const createService = createServiceFactory({
service: Logger,
mocks: [Logger],
providers: [
{
provide: LOG_PROVIDER,
useValue: logProviderMock1,
multi: true,
},
{
provide: LOG_PROVIDER,
useValue: logProviderMock2,
multi: true,
},
{
provide: LOG_LEVEL,
useValue: LogLevel.DEBUG,
},
],
});
beforeEach(() => {
spectator = createService();
});
it('should create', () => {
expect(spectator.service).toBeTruthy();
});
const testData: {
serviceLogLevel: LogLevel;
callLogLevel: LogLevel;
shoudCall: boolean;
}[] = [
{ serviceLogLevel: LogLevel.DEBUG, callLogLevel: LogLevel.DEBUG, shoudCall: true },
{ serviceLogLevel: LogLevel.DEBUG, callLogLevel: LogLevel.INFO, shoudCall: true },
{ serviceLogLevel: LogLevel.DEBUG, callLogLevel: LogLevel.WARN, shoudCall: true },
{ serviceLogLevel: LogLevel.DEBUG, callLogLevel: LogLevel.ERROR, shoudCall: true },
{ serviceLogLevel: LogLevel.INFO, callLogLevel: LogLevel.DEBUG, shoudCall: false },
{ serviceLogLevel: LogLevel.INFO, callLogLevel: LogLevel.INFO, shoudCall: true },
{ serviceLogLevel: LogLevel.INFO, callLogLevel: LogLevel.WARN, shoudCall: true },
{ serviceLogLevel: LogLevel.INFO, callLogLevel: LogLevel.ERROR, shoudCall: true },
{ serviceLogLevel: LogLevel.WARN, callLogLevel: LogLevel.DEBUG, shoudCall: false },
{ serviceLogLevel: LogLevel.WARN, callLogLevel: LogLevel.INFO, shoudCall: false },
{ serviceLogLevel: LogLevel.WARN, callLogLevel: LogLevel.WARN, shoudCall: true },
{ serviceLogLevel: LogLevel.WARN, callLogLevel: LogLevel.ERROR, shoudCall: true },
{ serviceLogLevel: LogLevel.ERROR, callLogLevel: LogLevel.DEBUG, shoudCall: false },
{ serviceLogLevel: LogLevel.ERROR, callLogLevel: LogLevel.INFO, shoudCall: false },
{ serviceLogLevel: LogLevel.ERROR, callLogLevel: LogLevel.WARN, shoudCall: false },
{ serviceLogLevel: LogLevel.ERROR, callLogLevel: LogLevel.ERROR, shoudCall: true },
{ serviceLogLevel: LogLevel.OFF, callLogLevel: LogLevel.DEBUG, shoudCall: false },
{ serviceLogLevel: LogLevel.OFF, callLogLevel: LogLevel.INFO, shoudCall: false },
{ serviceLogLevel: LogLevel.OFF, callLogLevel: LogLevel.WARN, shoudCall: false },
{ serviceLogLevel: LogLevel.OFF, callLogLevel: LogLevel.ERROR, shoudCall: false },
];
describe('log()', () => {
beforeEach(() => {
logProviderMock1.log.calls.reset();
logProviderMock2.log.calls.reset();
});
for (const test of testData) {
if (test.shoudCall) {
it(`should call logProvider if logLevel is ${LogLevel[test.callLogLevel]} and serviceLogLevel is ${
LogLevel[test.serviceLogLevel]
}`, () => {
spectator.service['_logLevel'] = test.serviceLogLevel;
spectator.service.log(test.callLogLevel, 'test');
expect(logProviderMock1.log).toHaveBeenCalledTimes(1);
expect(logProviderMock2.log).toHaveBeenCalledTimes(1);
});
} else {
it(`should not call logProvider if logLevel is ${LogLevel[test.callLogLevel]} and serviceLogLevel is ${
LogLevel[test.serviceLogLevel]
}`, () => {
spectator.service['_logLevel'] = test.serviceLogLevel;
spectator.service.log(test.callLogLevel, 'test');
expect(logProviderMock1.log).not.toHaveBeenCalled();
expect(logProviderMock2.log).not.toHaveBeenCalled();
});
}
}
});
describe('warn()', () => {
it('should call log and logLevel should be WARN', () => {
spyOn(spectator.service, 'log');
spectator.service.warn('test', 'data');
expect(spectator.service.log).toHaveBeenCalledWith(LogLevel.WARN, 'test', 'data');
});
});
describe('info()', () => {
it('should call log and logLevel should be INFO', () => {
spyOn(spectator.service, 'log');
spectator.service.info('test', 'data');
expect(spectator.service.log).toHaveBeenCalledWith(LogLevel.INFO, 'test', 'data');
});
});
describe('debug()', () => {
it('should call log and logLevel should be DEBUG', () => {
spyOn(spectator.service, 'log');
spectator.service.debug('test', 'data');
expect(spectator.service.log).toHaveBeenCalledWith(LogLevel.DEBUG, 'test', 'data');
});
});
describe('error()', () => {
it('should call log and logLevel should be ERROR', () => {
spyOn(spectator.service, 'log');
spectator.service.error('test', 'data');
expect(spectator.service.log).toHaveBeenCalledWith(LogLevel.ERROR, 'test', 'data');
});
});
});

View File

@@ -0,0 +1,42 @@
import { Inject, Injectable } from '@angular/core';
import { LogLevel } from './log-level';
import { LogProvider } from './log.provider';
import { LOG_LEVEL, LOG_PROVIDER } from './tokens';
@Injectable()
export class Logger {
constructor(
@Inject(LOG_PROVIDER) private readonly _loggerProviders: LogProvider[],
@Inject(LOG_LEVEL) private _logLevel: LogLevel,
) {}
log(logLevel: LogLevel, message: string, ...optionalParams: any[]): void {
if (this._logLevel === LogLevel.OFF) {
return;
}
if (this._logLevel <= logLevel) {
console.log(this._logLevel, logLevel, this._logLevel <= logLevel);
this._loggerProviders.forEach((provider) => {
provider.log(logLevel, message, ...optionalParams);
});
}
}
warn(message: string, ...optionalParams: any[]): void {
this.log(LogLevel.WARN, message, ...optionalParams);
}
info(message: string, ...optionalParams: any[]): void {
this.log(LogLevel.INFO, message, ...optionalParams);
}
debug(message: string, ...optionalParams: any[]): void {
this.log(LogLevel.DEBUG, message, ...optionalParams);
}
error(message: string, ...optionalParams: any[]): void {
this.log(LogLevel.ERROR, message, ...optionalParams);
}
}

View File

@@ -0,0 +1,6 @@
import { LogProvider } from './log.provider';
import { InjectionToken } from '@angular/core';
import { LogLevel } from './log-level';
export const LOG_PROVIDER = new InjectionToken<LogProvider[]>('LOG_PROVIDER');
export const LOG_LEVEL = new InjectionToken<LogLevel>('LOG_LEVEL');

View File

@@ -0,0 +1,2 @@
export * from './signalr-hub-options';
export * from './signalr.hub';

Some files were not shown because too many files have changed in this diff Show More