Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Angular: Toaster interceptor #19532

Draft
wants to merge 5 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions npm/ng-packs/packages/core/src/lib/models/rest.ts
Expand Up @@ -5,6 +5,7 @@ export namespace Rest {
apiName: string;
skipHandleError: boolean;
skipAddingHeader: boolean;
skipToasterInterceptor: boolean;
observe: Observe;
httpParamEncoder?: HttpParameterCodec;
}>;
Expand Down
26 changes: 22 additions & 4 deletions npm/ng-packs/packages/core/src/lib/services/rest.service.ts
@@ -1,4 +1,10 @@
import { HttpClient, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http';
import {
HttpClient,
HttpHeaders,
HttpParameterCodec,
HttpParams,
HttpRequest,
} from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
Expand All @@ -20,7 +26,7 @@ export class RestService {
protected externalHttp: ExternalHttpClient,
protected environment: EnvironmentService,
protected httpErrorReporter: HttpErrorReporterService,
) { }
) {}

protected getApiFromStore(apiName: string | undefined): string {
return this.environment.getApiUrl(apiName);
Expand All @@ -39,10 +45,22 @@ export class RestService {
config = config || ({} as Rest.Config);
api = api || this.getApiFromStore(config.apiName);
const { method, params, ...options } = request;
const { observe = Rest.Observe.Body, skipHandleError } = config;
const {
observe = Rest.Observe.Body,
skipHandleError,
skipAddingHeader,
skipToasterInterceptor,
} = config;
const url = this.removeDuplicateSlashes(api + request.url);

const httpClient: HttpClient = this.getHttpClient(config.skipAddingHeader);
const httpClient: HttpClient = this.getHttpClient(skipAddingHeader);

if (skipToasterInterceptor) {
options.headers = options.headers
? (options.headers as HttpHeaders).set('X-Abp-Skip-Toaster-Interceptor', 'true')
: new HttpHeaders().set('X-Abp-Skip-Toaster-Interceptor', 'true');
}

return httpClient
.request<R>(method, url, {
observe,
Expand Down
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';

import { toasterInterceptor } from './toaster.interceptor';

describe('toasterInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => toasterInterceptor(req, next));

beforeEach(() => {
TestBed.configureTestingModule({});
});

it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});
@@ -0,0 +1,59 @@
import { Injectable } from '@angular/core';
import {
HttpErrorResponse,
HttpHandler,
HttpInterceptor,
HttpRequest,
HttpResponse,
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { inject } from '@angular/core';
import { HTTP_TOASTER_INTERCEPTOR_CONFIG } from '../tokens/toaster-interceptor.token';
import { Toaster, HttpResponseEvent } from '../models';
import { ToasterService } from '../services';

@Injectable()
export class ToasterInterceptor implements HttpInterceptor {
protected readonly toasterService = inject(ToasterService);

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<any> {
const interceptorKey = Toaster.SKIP_TOASTER_INTERCEPTOR;

if (req.headers.has(interceptorKey)) {
req.headers.delete(interceptorKey);
return next.handle(req);
}

const { defaults, methods } = inject(HTTP_TOASTER_INTERCEPTOR_CONFIG);

if (!methods.includes(req.method as Toaster.HttpMethod)) {
return next.handle(req);
}

return next.handle(req).pipe(
tap(event => {
if (event instanceof HttpResponse) {
this.showToaster(event, defaults);
}
}),
catchError((error: HttpErrorResponse) => {
this.showToaster(error, defaults);
return throwError(error);
}),
);
}

private showToaster(event: HttpResponseEvent, defaults: Partial<Toaster.ToasterDefaults>) {
const { status } = event;

const toasterParams = defaults[status];
if (!toasterParams) {
console.error(`Toaster params not found for status code: ${status}`);
return;
}

const { message, title, severity, options } = toasterParams;
this.toasterService.show(message, title, severity, options);
}
}
11 changes: 9 additions & 2 deletions npm/ng-packs/packages/theme-shared/src/lib/models/common.ts
@@ -1,13 +1,15 @@
import { HttpErrorResponse } from '@angular/common/http';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { Injector, Type } from '@angular/core';
import { Validation } from '@ngx-validate/core';
import { Observable } from 'rxjs';
import { ConfirmationIcons } from '../tokens/confirmation-icons.token';
import { Toaster } from './toaster';

export interface RootParams {
httpErrorConfig?: HttpErrorConfig;
validation?: Partial<Validation.Config>;
confirmationIcons?: Partial<ConfirmationIcons>;
toastInterceptorConfig?: Partial<Toaster.ToasterInterceptorConfig>;
}

export type ErrorScreenErrorCodes = 0 | 401 | 403 | 404 | 500;
Expand All @@ -20,11 +22,16 @@ export interface HttpErrorConfig {
hideCloseIcon?: boolean;
};
}
export type HttpErrorHandler<T = any> = (injector: Injector, httpError: HttpErrorResponse) => Observable<T>;
export type HttpErrorHandler<T = any> = (
injector: Injector,
httpError: HttpErrorResponse,
) => Observable<T>;
export type LocaleDirection = 'ltr' | 'rtl';

export interface CustomHttpErrorHandlerService {
readonly priority: number;
canHandle(error: unknown): boolean;
execute(): void;
}

export type HttpResponseEvent = HttpResponse<unknown> | HttpErrorResponse;
22 changes: 22 additions & 0 deletions npm/ng-packs/packages/theme-shared/src/lib/models/toaster.ts
@@ -1,6 +1,10 @@
import { LocalizationParam } from '@abp/ng.core';
import { HttpInterceptor } from '@angular/common/http';
import { Type } from '@angular/core';

export namespace Toaster {
export const SKIP_TOASTER_INTERCEPTOR = 'X-Abp-Skip-Toaster-Interceptor';

export interface ToastOptions {
life?: number;
sticky?: boolean;
Expand All @@ -20,9 +24,27 @@ export namespace Toaster {
options?: ToastOptions;
}

export interface ToasterInterceptorConfig {
methods: HttpMethod[];
defaults: Partial<ToasterDefaults>;
customInterceptor: Type<HttpInterceptor>;
enabled: boolean;
}

export type HttpMethod = 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export type StatusCode = 200 | 201 | 204 | 400 | 401 | 403 | 404 | 500 | 503 | 504 | 0;

export type Severity = 'neutral' | 'success' | 'info' | 'warning' | 'error';
export type ToasterId = string | number;

export type ToasterDefaults = {
[key in StatusCode]: {
message: LocalizationParam;
title: LocalizationParam | undefined;
severity: Toaster.Severity;
options: Partial<Toaster.ToastOptions>;
};
};
export interface Service {
show: (
message: LocalizationParam,
Expand Down
Expand Up @@ -2,3 +2,4 @@ export * from './ng-bootstrap-config.provider';
export * from './route.provider';
export * from './tenant-not-found.provider';
export * from './error-handlers.provider';
export * from './toaster-interceptor.provider';
@@ -0,0 +1,119 @@
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { Provider } from '@angular/core';
import { ToasterInterceptor } from '../interceptors/toaster.interceptor';
import { HTTP_TOASTER_INTERCEPTOR_CONFIG } from '../tokens/toaster-interceptor.token';
import { Toaster } from '../models';

const methods = ['POST', 'PUT', 'PATCH', 'DELETE'];
const defaults: Partial<Toaster.ToasterDefaults> = {
'200': {
message: 'AbpUi::SavedSuccessfully',
title: 'AbpUi::Saved',
severity: 'success',
options: {
life: 5000,
titleLocalizationParams: ['AbpUi::Saved'],
messageLocalizationParams: ['AbpUi::SavedSuccessfully'],
},
},
'201': {
message: 'AbpUi::CreatedSuccessfully',
title: 'AbpUi::Created',
severity: 'success',
options: {
life: 5000,
titleLocalizationParams: ['AbpUi::Created'],
messageLocalizationParams: ['AbpUi::CreatedSuccessfully'],
},
},
'204': {
message: 'AbpUi::CompletedSuccessfully',
title: 'AbpUi::Completed',
severity: 'info',
options: {
life: 5000,
titleLocalizationParams: ['AbpUi::Completed'],
messageLocalizationParams: ['AbpUi::CompletedSuccessfully'],
},
},
'400': {
message: 'AbpUi::BadRequest',
title: 'AbpUi::BadRequest',
severity: 'error',
options: {
life: 5000,
titleLocalizationParams: ['AbpUi::BadRequest'],
messageLocalizationParams: ['AbpUi::BadRequest'],
},
},
'401': {
message: 'AbpUi::Unauthorized',
title: 'AbpUi::Unauthorized',
severity: 'error',
options: {
life: 5000,
titleLocalizationParams: ['AbpUi::Unauthorized'],
messageLocalizationParams: ['AbpUi::Unauthorized'],
},
},
'403': {
message: 'AbpUi::Forbidden',
title: 'AbpUi::Forbidden',
severity: 'error',
options: {
life: 5000,
titleLocalizationParams: ['AbpUi::Forbidden'],
messageLocalizationParams: ['AbpUi::Forbidden'],
},
},
'500': {
message: 'AbpUi::InternalServerError',
title: 'AbpUi::Error',
severity: 'error',
options: {
life: 5000,
titleLocalizationParams: ['AbpUi::Error'],
messageLocalizationParams: ['AbpUi::InternalServerError'],
},
},
};

export const DEFAULT_HTTP_TOASTER_INTERCEPTOR_CONFIG: Provider = {
provide: HTTP_TOASTER_INTERCEPTOR_CONFIG,
useValue: {
methods,
defaults,
},
};

export const DEFAULT_HTTP_TOASTER_INTERCEPTOR_PROVIDER: Provider = {
provide: HTTP_INTERCEPTORS,
useClass: ToasterInterceptor,
multi: true,
};

export const provideToasterInterceptor = (config?: Partial<Toaster.ToasterInterceptorConfig>) => {
if (!config || (!config?.enabled && !config?.customInterceptor)) {
return [];
}

if (config.enabled && !config.defaults && !config.methods) {
console.warn(
'ToasterInterceptor is enabled but "defaults" and "methods" are not provided. Using default configuration.',
);

return [DEFAULT_HTTP_TOASTER_INTERCEPTOR_CONFIG, DEFAULT_HTTP_TOASTER_INTERCEPTOR_PROVIDER];
}

return [
{
provide: HTTP_TOASTER_INTERCEPTOR_CONFIG,
useValue: config,
},
{
provide: HTTP_INTERCEPTORS,
useClass: config?.customInterceptor || ToasterInterceptor,
multi: true,
},
];
};
Expand Up @@ -41,6 +41,7 @@ import { AbpVisibleDirective, DisabledDirective } from './directives';
import { FormInputComponent } from './components/form-input/form-input.component';
import { FormCheckboxComponent } from './components/checkbox/checkbox.component';
import { tenantNotFoundProvider } from './providers/tenant-not-found.provider';
import { provideToasterInterceptor } from './providers/toaster-interceptor.provider';

const declarationsWithExports = [
BreadcrumbComponent,
Expand Down Expand Up @@ -93,7 +94,12 @@ export class BaseThemeSharedModule {}
})
export class ThemeSharedModule {
static forRoot(
{ httpErrorConfig, validation = {}, confirmationIcons = {} } = {} as RootParams,
{
httpErrorConfig,
validation = {},
confirmationIcons = {},
toastInterceptorConfig,
} = {} as RootParams,
): ModuleWithProviders<ThemeSharedModule> {
return {
ngModule: ThemeSharedModule,
Expand Down Expand Up @@ -145,6 +151,7 @@ export class ThemeSharedModule {
},
tenantNotFoundProvider,
DEFAULT_HANDLERS_PROVIDERS,
...provideToasterInterceptor(toastInterceptorConfig),
],
};
}
Expand Down
@@ -0,0 +1,6 @@
import { InjectionToken } from '@angular/core';
import { Toaster } from '@abp/ng.theme.shared';

export const HTTP_TOASTER_INTERCEPTOR_CONFIG = new InjectionToken<Toaster.ToasterInterceptorConfig>(
'HTTP_TOASTER_INTERCEPTOR_CONFIG',
);