diff --git a/npm/ng-packs/packages/core/src/lib/models/config.ts b/npm/ng-packs/packages/core/src/lib/models/config.ts index e6c9a05f35c..321a5420978 100644 --- a/npm/ng-packs/packages/core/src/lib/models/config.ts +++ b/npm/ng-packs/packages/core/src/lib/models/config.ts @@ -35,4 +35,6 @@ export namespace Config { key: string; defaultValue: string; } + + export type LocalizationParam = string | LocalizationWithDefault; } diff --git a/npm/ng-packs/packages/identity/src/lib/components/users/users.component.ts b/npm/ng-packs/packages/identity/src/lib/components/users/users.component.ts index 14a58f2345d..3992b358d87 100644 --- a/npm/ng-packs/packages/identity/src/lib/components/users/users.component.ts +++ b/npm/ng-packs/packages/identity/src/lib/components/users/users.component.ts @@ -163,7 +163,7 @@ export class UsersComponent implements OnInit { } save() { - if (!this.form.valid) return; + if (!this.form.valid || this.modalBusy) return; this.modalBusy = true; const { roleNames } = this.form.value; diff --git a/npm/ng-packs/packages/theme-shared/src/lib/abstracts/toaster.ts b/npm/ng-packs/packages/theme-shared/src/lib/abstracts/toaster.ts index c911be1e43f..d66c29fc3bc 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/abstracts/toaster.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/abstracts/toaster.ts @@ -1,6 +1,7 @@ import { MessageService } from 'primeng/components/common/messageservice'; import { Observable, Subject } from 'rxjs'; import { Toaster } from '../models/toaster'; +import { Config } from '@abp/ng.core'; export abstract class AbstractToaster { status$: Subject; @@ -11,23 +12,28 @@ export abstract class AbstractToaster { constructor(protected messageService: MessageService) {} - info(message: string, title: string, options?: T): Observable { + info(message: Config.LocalizationParam, title: Config.LocalizationParam, options?: T): Observable { return this.show(message, title, 'info', options); } - success(message: string, title: string, options?: T): Observable { + success(message: Config.LocalizationParam, title: Config.LocalizationParam, options?: T): Observable { return this.show(message, title, 'success', options); } - warn(message: string, title: string, options?: T): Observable { + warn(message: Config.LocalizationParam, title: Config.LocalizationParam, options?: T): Observable { return this.show(message, title, 'warn', options); } - error(message: string, title: string, options?: T): Observable { + error(message: Config.LocalizationParam, title: Config.LocalizationParam, options?: T): Observable { return this.show(message, title, 'error', options); } - protected show(message: string, title: string, severity: Toaster.Severity, options?: T): Observable { + protected show( + message: Config.LocalizationParam, + title: Config.LocalizationParam, + severity: Toaster.Severity, + options?: T, + ): Observable { this.messageService.clear(this.key); this.messageService.add({ @@ -36,7 +42,7 @@ export abstract class AbstractToaster { summary: title || '', ...options, key: this.key, - ...(typeof (options || ({} as any)).sticky === 'undefined' && { sticky: this.sticky }) + ...(typeof (options || ({} as any)).sticky === 'undefined' && { sticky: this.sticky }), }); this.status$ = new Subject(); return this.status$; diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts index d54e9b181fd..610b22bc5d0 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts @@ -7,7 +7,7 @@ import { ABP } from '@abp/ng.core'; template: ` diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/error/error.component.html b/npm/ng-packs/packages/theme-shared/src/lib/components/error/error.component.html index f367d49e897..ce1137bc203 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/error/error.component.html +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/error/error.component.html @@ -1,13 +1,12 @@ -
- -
+ +
-

- {{ title | abpLocalization }} -

+

{{ statusText }} {{ title | abpLocalization }}

{{ details | abpLocalization }}
diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/error/error.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/error/error.component.ts index b86eb78c6b5..7ccc5c97bd5 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/error/error.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/error/error.component.ts @@ -1,23 +1,65 @@ -import { Component, Renderer2, ElementRef } from '@angular/core'; -import { Config } from '@abp/ng.core'; +import { Config, takeUntilDestroy } from '@abp/ng.core'; +import { + AfterViewInit, + Component, + ComponentFactoryResolver, + ElementRef, + EmbeddedViewRef, + OnDestroy, + Type, + ViewChild, +} from '@angular/core'; +import { fromEvent, Subject } from 'rxjs'; +import { debounceTime, filter } from 'rxjs/operators'; @Component({ selector: 'abp-error', templateUrl: './error.component.html', styleUrls: ['error.component.scss'], }) -export class ErrorComponent { - title: string | Config.LocalizationWithDefault = 'Oops!'; +export class ErrorComponent implements AfterViewInit, OnDestroy { + cfRes: ComponentFactoryResolver; - details: string | Config.LocalizationWithDefault = 'Sorry, an error has occured.'; + status = 0; - renderer: Renderer2; + title: Config.LocalizationParam = 'Oops!'; - elementRef: ElementRef; + details: Config.LocalizationParam = 'Sorry, an error has occured.'; - host: any; + customComponent: Type = null; + + destroy$: Subject; + + @ViewChild('container', { static: false }) + containerRef: ElementRef; + + get statusText(): string { + return this.status ? `[${this.status}]` : ''; + } + + ngAfterViewInit() { + if (this.customComponent) { + const customComponentRef = this.cfRes.resolveComponentFactory(this.customComponent).create(null); + customComponentRef.instance.errorStatus = this.status; + this.containerRef.nativeElement.appendChild((customComponentRef.hostView as EmbeddedViewRef).rootNodes[0]); + customComponentRef.changeDetectorRef.detectChanges(); + } + + fromEvent(document, 'keyup') + .pipe( + takeUntilDestroy(this), + debounceTime(150), + filter((key: KeyboardEvent) => key && key.key === 'Escape'), + ) + .subscribe(() => { + this.destroy(); + }); + } + + ngOnDestroy() {} destroy() { - this.renderer.removeChild(this.host, this.elementRef.nativeElement); + this.destroy$.next(); + this.destroy$.complete(); } } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/handlers/error.handler.ts b/npm/ng-packs/packages/theme-shared/src/lib/handlers/error.handler.ts index 18efbc2b605..5f75b2ecd53 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/handlers/error.handler.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/handlers/error.handler.ts @@ -1,21 +1,25 @@ -import { RestOccurError } from '@abp/ng.core'; +import { Config, RestOccurError } from '@abp/ng.core'; import { HttpErrorResponse } from '@angular/common/http'; import { ApplicationRef, ComponentFactoryResolver, EmbeddedViewRef, + Inject, Injectable, Injector, - NgZone, RendererFactory2, + Type, + ComponentRef, } from '@angular/core'; -import { Router } from '@angular/router'; +import { Navigate, RouterError, RouterState, RouterDataResolved } from '@ngxs/router-plugin'; import { Actions, ofActionSuccessful, Store } from '@ngxs/store'; -import { Observable } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; import snq from 'snq'; import { ErrorComponent } from '../components/error/error.component'; +import { HttpErrorConfig, ErrorScreenErrorCodes } from '../models/common'; import { Toaster } from '../models/toaster'; import { ConfirmationService } from '../services/confirmation.service'; +import { HTTP_ERROR_CONFIG } from '../tokens/error-pages.token'; export const DEFAULT_ERROR_MESSAGES = { defaultError: { @@ -36,82 +40,142 @@ export const DEFAULT_ERROR_MESSAGES = { }, defaultError500: { title: '500', - details: { key: 'AbpAccount::InternalServerErrorMessage', defaultValue: 'Error detail not sent by server.' }, - }, - defaultErrorUnknown: { - title: 'Unknown Error', - details: { key: 'AbpAccount::InternalServerErrorMessage', defaultValue: 'Error detail not sent by server.' }, + details: 'Error detail not sent by server.', }, }; @Injectable({ providedIn: 'root' }) export class ErrorHandler { + componentRef: ComponentRef; + constructor( private actions: Actions, - private router: Router, - private ngZone: NgZone, private store: Store, private confirmationService: ConfirmationService, private appRef: ApplicationRef, private cfRes: ComponentFactoryResolver, private rendererFactory: RendererFactory2, private injector: Injector, + @Inject(HTTP_ERROR_CONFIG) private httpErrorConfig: HttpErrorConfig, ) { - actions.pipe(ofActionSuccessful(RestOccurError)).subscribe(res => { - const { payload: err = {} as HttpErrorResponse | any } = res; - const body = snq(() => (err as HttpErrorResponse).error.error, DEFAULT_ERROR_MESSAGES.defaultError.title); + this.actions.pipe(ofActionSuccessful(RestOccurError, RouterError, RouterDataResolved)).subscribe(res => { + if (res instanceof RestOccurError) { + const { payload: err = {} as HttpErrorResponse | any } = res; + const body = snq(() => (err as HttpErrorResponse).error.error, DEFAULT_ERROR_MESSAGES.defaultError.title); - if (err instanceof HttpErrorResponse && err.headers.get('_AbpErrorFormat')) { - const confirmation$ = this.showError(null, null, body); + if (err instanceof HttpErrorResponse && err.headers.get('_AbpErrorFormat')) { + const confirmation$ = this.showError(null, null, body); - if (err.status === 401) { - confirmation$.subscribe(() => { - this.navigateToLogin(); - }); - } - } else { - switch ((err as HttpErrorResponse).status) { - case 401: - this.showError( - DEFAULT_ERROR_MESSAGES.defaultError401.details, - DEFAULT_ERROR_MESSAGES.defaultError401.title, - ).subscribe(() => this.navigateToLogin()); - break; - case 403: - this.createErrorComponent({ - title: DEFAULT_ERROR_MESSAGES.defaultError403.title, - details: DEFAULT_ERROR_MESSAGES.defaultError403.details, + if (err.status === 401) { + confirmation$.subscribe(() => { + this.navigateToLogin(); }); - break; - case 404: - this.showError( - DEFAULT_ERROR_MESSAGES.defaultError404.details, - DEFAULT_ERROR_MESSAGES.defaultError404.title, - ); - break; - case 500: - this.createErrorComponent({ - title: DEFAULT_ERROR_MESSAGES.defaultError500.title, - details: DEFAULT_ERROR_MESSAGES.defaultError500.details, - }); - break; - case 0: - if ((err as HttpErrorResponse).statusText === 'Unknown Error') { + } + } else { + switch ((err as HttpErrorResponse).status) { + case 401: + this.canCreateCustomError(401) + ? this.show401Page() + : this.showError( + { + key: 'AbpAccount::DefaultErrorMessage401', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError401.title, + }, + { + key: 'AbpAccount::DefaultErrorMessage401Detail', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError401.details, + }, + ).subscribe(() => this.navigateToLogin()); + break; + case 403: + this.createErrorComponent({ + title: { + key: 'AbpAccount::DefaultErrorMessage403', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError403.title, + }, + details: { + key: 'AbpAccount::DefaultErrorMessage403Detail', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError403.details, + }, + status: 403, + }); + break; + case 404: + this.canCreateCustomError(404) + ? this.show404Page() + : this.showError( + { + key: 'AbpAccount::DefaultErrorMessage404', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError404.title, + }, + { + key: 'AbpAccount::DefaultErrorMessage404Detail', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError404.details, + }, + ); + break; + case 500: this.createErrorComponent({ - title: DEFAULT_ERROR_MESSAGES.defaultErrorUnknown.title, - details: DEFAULT_ERROR_MESSAGES.defaultErrorUnknown.details, + title: { + key: 'AbpAccount::500Message', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError500.title, + }, + details: { + key: 'AbpAccount::InternalServerErrorMessage', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError500.details, + }, + status: 500, }); - } - break; - default: - this.showError(DEFAULT_ERROR_MESSAGES.defaultError.details, DEFAULT_ERROR_MESSAGES.defaultError.title); - break; + break; + case 0: + if ((err as HttpErrorResponse).statusText === 'Unknown Error') { + this.createErrorComponent({ + title: { + key: 'AbpAccount::DefaultErrorMessage', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError.title, + }, + }); + } + break; + default: + this.showError(DEFAULT_ERROR_MESSAGES.defaultError.details, DEFAULT_ERROR_MESSAGES.defaultError.title); + break; + } } + } else if (res instanceof RouterError && snq(() => res.event.error.indexOf('Cannot match') > -1, false)) { + this.show404Page(); + } else if (res instanceof RouterDataResolved && this.componentRef) { + this.componentRef.destroy(); + this.componentRef = null; } }); } - private showError(message?: string, title?: string, body?: any): Observable { + private show401Page() { + this.createErrorComponent({ + title: { + key: 'AbpAccount::401Message', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError401.title, + }, + status: 401, + }); + } + + private show404Page() { + this.createErrorComponent({ + title: { + key: 'AbpAccount::404Message', + defaultValue: DEFAULT_ERROR_MESSAGES.defaultError404.title, + }, + status: 404, + }); + } + + private showError( + message?: Config.LocalizationParam, + title?: Config.LocalizationParam, + body?: any, + ): Observable { if (body) { if (body.details) { message = body.details; @@ -123,35 +187,50 @@ export class ErrorHandler { return this.confirmationService.error(message, title, { hideCancelBtn: true, - yesCopy: 'OK', + yesText: 'AbpAccount::Close', }); } private navigateToLogin() { - this.ngZone.run(() => { - this.router.navigate(['/account/login'], { - state: { redirectUrl: this.router.url }, - }); - }); + console.warn(this.store.selectSnapshot(RouterState.url)); + this.store.dispatch( + new Navigate(['/account/login'], null, { state: { redirectUrl: this.store.selectSnapshot(RouterState.url) } }), + ); } createErrorComponent(instance: Partial) { const renderer = this.rendererFactory.createRenderer(null, null); const host = renderer.selectRootElement(document.body, true); - const componentRef = this.cfRes.resolveComponentFactory(ErrorComponent).create(this.injector); + this.componentRef = this.cfRes.resolveComponentFactory(ErrorComponent).create(this.injector); - for (const key in componentRef.instance) { - if (componentRef.instance.hasOwnProperty(key)) { - componentRef.instance[key] = instance[key]; + for (const key in this.componentRef.instance) { + if (this.componentRef.instance.hasOwnProperty(key)) { + this.componentRef.instance[key] = instance[key]; } } - this.appRef.attachView(componentRef.hostView); - renderer.appendChild(host, (componentRef.hostView as EmbeddedViewRef).rootNodes[0]); + if (this.canCreateCustomError(instance.status as ErrorScreenErrorCodes)) { + this.componentRef.instance.cfRes = this.cfRes; + this.componentRef.instance.customComponent = this.httpErrorConfig.errorScreen.component; + } + + this.appRef.attachView(this.componentRef.hostView); + renderer.appendChild(host, (this.componentRef.hostView as EmbeddedViewRef).rootNodes[0]); + + const destroy$ = new Subject(); + this.componentRef.instance.destroy$ = destroy$; + destroy$.subscribe(() => { + this.componentRef.destroy(); + this.componentRef = null; + }); + } - componentRef.instance.renderer = renderer; - componentRef.instance.elementRef = componentRef.location; - componentRef.instance.host = host; + canCreateCustomError(status: ErrorScreenErrorCodes): boolean { + return snq( + () => + this.httpErrorConfig.errorScreen.component && + this.httpErrorConfig.errorScreen.forWhichErrors.indexOf(status) > -1, + ); } } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/models/common.ts b/npm/ng-packs/packages/theme-shared/src/lib/models/common.ts new file mode 100644 index 00000000000..f0cd03d6e72 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/src/lib/models/common.ts @@ -0,0 +1,18 @@ +import { Type } from '@angular/core'; + +export interface RootParams { + httpErrorConfig: HttpErrorConfig; +} + +export type ErrorScreenErrorCodes = 401 | 403 | 404 | 500; + +export interface HttpErrorConfig { + errorScreen?: { + component: Type; + forWhichErrors?: + | [ErrorScreenErrorCodes] + | [ErrorScreenErrorCodes, ErrorScreenErrorCodes] + | [ErrorScreenErrorCodes, ErrorScreenErrorCodes, ErrorScreenErrorCodes] + | [ErrorScreenErrorCodes, ErrorScreenErrorCodes, ErrorScreenErrorCodes, ErrorScreenErrorCodes]; + }; +} diff --git a/npm/ng-packs/packages/theme-shared/src/lib/models/confirmation.ts b/npm/ng-packs/packages/theme-shared/src/lib/models/confirmation.ts index 35b91b5f682..c3e203cdedc 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/models/confirmation.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/models/confirmation.ts @@ -1,18 +1,19 @@ import { Toaster } from './toaster'; +import { Config } from '@abp/ng.core'; export namespace Confirmation { export interface Options extends Toaster.Options { hideCancelBtn?: boolean; hideYesBtn?: boolean; - cancelText?: string; - yesText?: string; + cancelText?: Config.LocalizationParam; + yesText?: Config.LocalizationParam; /** * @deprecated to be deleted in v2 */ - cancelCopy?: string; + cancelCopy?: Config.LocalizationParam; /** * @deprecated to be deleted in v2 */ - yesCopy?: string; + yesCopy?: Config.LocalizationParam; } } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/models/index.ts b/npm/ng-packs/packages/theme-shared/src/lib/models/index.ts index e42b6ccfa55..f82171101f9 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/models/index.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/models/index.ts @@ -1,3 +1,4 @@ +export * from './common'; export * from './confirmation'; export * from './setting-management'; export * from './statistics'; diff --git a/npm/ng-packs/packages/theme-shared/src/lib/theme-shared.module.ts b/npm/ng-packs/packages/theme-shared/src/lib/theme-shared.module.ts index cae8baeea66..28c3926367a 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/theme-shared.module.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/theme-shared.module.ts @@ -18,6 +18,8 @@ import styles from './contants/styles'; import { TableSortDirective } from './directives/table-sort.directive'; import { ErrorHandler } from './handlers/error.handler'; import { chartJsLoaded$ } from './utils/widget-utils'; +import { RootParams } from './models/common'; +import { HTTP_ERROR_CONFIG, httpErrorConfigFactory } from './tokens/error-pages.token'; export function appendScript(injector: Injector) { const fn = () => { @@ -69,7 +71,7 @@ export function appendScript(injector: Injector) { entryComponents: [ErrorComponent], }) export class ThemeSharedModule { - static forRoot(): ModuleWithProviders { + static forRoot(options = {} as RootParams): ModuleWithProviders { return { ngModule: ThemeSharedModule, providers: [ @@ -80,6 +82,12 @@ export class ThemeSharedModule { useFactory: appendScript, }, { provide: MessageService, useClass: MessageService }, + { provide: HTTP_ERROR_CONFIG, useValue: options.httpErrorConfig }, + { + provide: 'HTTP_ERROR_CONFIG', + useFactory: httpErrorConfigFactory, + deps: [HTTP_ERROR_CONFIG], + }, ], }; } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/tokens/error-pages.token.ts b/npm/ng-packs/packages/theme-shared/src/lib/tokens/error-pages.token.ts new file mode 100644 index 00000000000..3df2b3fe2d5 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/src/lib/tokens/error-pages.token.ts @@ -0,0 +1,15 @@ +import { InjectionToken } from '@angular/core'; +import { HttpErrorConfig } from '../models/common'; + +export function httpErrorConfigFactory(config = {} as HttpErrorConfig) { + if (config.errorScreen && config.errorScreen.component && !config.errorScreen.forWhichErrors) { + config.errorScreen.forWhichErrors = [401, 403, 404, 500]; + } + + return { + errorScreen: {}, + ...config, + } as HttpErrorConfig; +} + +export const HTTP_ERROR_CONFIG = new InjectionToken('HTTP_ERROR_CONFIG'); diff --git a/templates/app/angular/package.json b/templates/app/angular/package.json index 80eafa71748..3ccd5cb5db2 100644 --- a/templates/app/angular/package.json +++ b/templates/app/angular/package.json @@ -18,23 +18,23 @@ "@abp/ng.setting-management": "^1.0.2", "@abp/ng.tenant-management": "^1.0.2", "@abp/ng.theme.basic": "^1.0.2", - "@angular/animations": "~8.2.12", - "@angular/common": "~8.2.12", - "@angular/compiler": "~8.2.12", - "@angular/core": "~8.2.12", - "@angular/forms": "~8.2.12", - "@angular/platform-browser": "~8.2.12", - "@angular/platform-browser-dynamic": "~8.2.12", - "@angular/router": "~8.2.12", + "@angular/animations": "~8.2.13", + "@angular/common": "~8.2.13", + "@angular/compiler": "~8.2.13", + "@angular/core": "~8.2.13", + "@angular/forms": "~8.2.13", + "@angular/platform-browser": "~8.2.13", + "@angular/platform-browser-dynamic": "~8.2.13", + "@angular/router": "~8.2.13", "rxjs": "~6.4.0", "tslib": "^1.10.0", "zone.js": "~0.9.1" }, "devDependencies": { - "@angular-devkit/build-angular": "~0.803.15", - "@angular/cli": "~8.3.15", - "@angular/compiler-cli": "~8.2.12", - "@angular/language-service": "~8.2.12", + "@angular-devkit/build-angular": "~0.803.17", + "@angular/cli": "~8.3.17", + "@angular/compiler-cli": "~8.2.13", + "@angular/language-service": "~8.2.13", "@angularclass/hmr": "^2.1.3", "@ngxs/hmr-plugin": "^3.5.1", "@ngxs/logger-plugin": "^3.5.1",