Reputation: 117
I have a button which makes an API call on keyboard enter. If they enter instantly multiple times, it would make 'n' no. of calls.
How to avoid this with a clean generic solution, so that it can be used everywhere?
<button click="initiateBulkPayment()(keyup.enter)="initiateBulkPayment">
initiateBulkPayment = (orderId:any, payment_methods:any) => {
let postParams = payment_methods;
console.log('this is payment_method', payment_methods);
return this.http.post(Constants.API_ENDPOINT + '/oms/api/orders/' +
orderId + '/payments/bulk_create/', postParams, this.auth.returnHeaderHandler())
.pipe(map((data: any) => {
return data;
}),
catchError((err)=>{
return throwError(err);
}));
}
Upvotes: 3
Views: 5892
Reputation: 11
Make an attribute directive firstly,you can set the time for blocking aswell,
@Directive({
selector: '[preventMultipleCalls]'
})
export class PreventApiCallsDirective implements OnInit,OnDestroy {
@Input('time') throttleTimeValue = 10000
@Output('emit') fireCallEventEmitter :EventEmitter<boolean>= new EventEmitter<boolean>();
clickObservable = new Observable<Event>();
clickSubscription: Subscription;
constructor(private elementRef: ElementRef) {
this.clickObserable = fromEvent(this.elementRef.nativeElement,'keyup')
}
ngOnInit(): void {
this.clickSubscription = this.clickObserable.pipe(filter((e :any) => e.keyCode === 13),
throttleTime(this.throttleTimeValue))
.subscribe(event => {
this.fireCallEventEmitter.emit(true)
});
}
ngOnDestroy(): void {
this.clickSubscription?.unsubscribe();
}
}
And place that directive like this on the button:
<button preventMultipleCalls (emit)="initiateBulkPayment()"> </button>
Upvotes: 1
Reputation: 28434
The most self-contained approach that I could think of is using a directive to extend the functionality of a button element.
The idea is that the button can map its click event into an inner stream, and ignore all subsequent click events until the inner stream completes.
This can be done as follows:
import { Directive, ElementRef, AfterViewInit, Input } from '@angular/core';
import { Observable, isObservable, of, fromEvent, Subscription, empty } from 'rxjs';
import { exhaustMap, tap, take, finalize } from 'rxjs/operators';
export type ButtonHandler = (e?: MouseEvent) => Observable<unknown> | Promise<unknown>;
const defaultHandler: ButtonHandler = (e) => empty();
@Directive({
selector: 'button[serial-btn]',
exportAs: 'serialBtn',
host: {
'[disabled]': 'disableWhenProcessing && _processing'
}
})
export class SerialButtonDirective implements AfterViewInit {
private _processing = false;
private _sub = Subscription.EMPTY;
@Input()
disableWhenProcessing = false;
@Input()
handler: ButtonHandler = defaultHandler;
get processing(): boolean { return this._processing };
constructor(private readonly btnElement: ElementRef<HTMLButtonElement>) {
}
ngAfterViewInit() {
this._sub = fromEvent<MouseEvent>(this.btnElement.nativeElement, 'click')
.pipe(
exhaustMap(e => this.wrapHandlerInObservable(e))
).subscribe();
}
ngOnDestroy() {
this._sub.unsubscribe();
}
private wrapHandlerInObservable(e: MouseEvent) {
this._processing = true;
const handleResult = this.handler(e);
let obs: Observable<unknown>;
if (isObservable(handleResult)) {
obs = handleResult;
} else {
obs = of(handleResult);
}
return obs.pipe(take(1), finalize(() => this._processing = false));
}
}
You could them use it as:
<button serial-btn [handler]="handler">Handle</button>
import {timer} from 'rxjs';
import {ButtonHandle} from './directive-file';
handler: ButtonHandler = (e) => {
console.log(e);
return timer(3000);
}
A live demo can be found in this stackblitz
Upvotes: 1
Reputation: 40612
There are two solutions:
Disable the button while the call is being executed:
<button [disabled]="paymentRequest.inProgress$ | async" (click)="onPayButtonClick()">
export class ProgressRequest {
private _inProgress$ = new UniqueBehaviorSubject(false);
execute<TResult>(call: () => Observable<TResult>): Observable<TResult> {
if (!this._inProgress$.value) {
this._inProgress$.next(true);
return call().pipe(
finalize(() => {
this._inProgress$.next(false);
})
);
} else {
throw new Error("the request is currently being executed");
}
}
get inProgress$(): Observable<boolean> {
return this._inProgress$;
}
}
@Component({ ... })
export class MyComponent {
readonly paymentRequest = new ProgressRequest();
onPayButtonClick() {
this.paymentRequest.execute(() => {
return this.http.post(
Constants.API_ENDPOINT + '/oms/api/orders/' + orderId + '/payments/bulk_create/',
postParams,
this.auth.returnHeaderHandler()
).pipe(map((data: any) => {
return data;
});
}).subscribe(data => {
console.log("done!", data);
});
}
}
Skip the excess calls:
You can use exhaustMap to skip requests while the previoius one is being executed. Note that switchMap
and shareReplay
, which was suggested in other answers won't prevent excess http calls.
<button #paymentButton>
@Component({ ... })
export class MyComponent implements OnInit {
@ViewChild('paymentButton', { static: true })
readonly paymentButton!: ElementRef<HTMLElement>;
ngOnInit() {
merge(
fromEvent(this.paymentButton.nativeElement, 'click'),
fromEvent<KeyboardEvent>(this.paymentButton.nativeElement, 'keyup').pipe(
filter(event => event.key === "Enter")
)
).pipe(
exhaustMap(() => {
return this.http.post(
Constants.API_ENDPOINT + '/oms/api/orders/' + orderId + '/payments/bulk_create/',
postParams,
this.auth.returnHeaderHandler()
).pipe(map((data: any) => {
return data;
});
})
).subscribe(result => {
console.log(result);
});
}
}
Note that click
event is fired also when you press the enter
key, so it isn't necessary to listen 'keyup'.
// You can replace
merge(
fromEvent(this.paymentButton.nativeElement, 'click'),
fromEvent<KeyboardEvent>(this.paymentButton.nativeElement, 'keyup').pipe(
filter(event => event.key === "Enter")
)
)
// just by
fromEvent(this.paymentButton.nativeElement, 'click')
Upvotes: 1
Reputation: 2632
You can add a Directive that disables the button for a given amount of time.
// debounce.directive.ts
import { Directive, OnInit, HostListener, ElementRef, Input } from '@angular/core';
@Directive({
selector: '[appDebounce]'
})
export class DebounceDirective {
constructor(
private el: ElementRef<HTMLButtonElement>,
) { }
@Input() appDebounce: number;
@HostListener('click') onMouseEnter() {
this.el.nativeElement.disabled = true;
setTimeout(() => this.el.nativeElement.disabled = false, this.appDebounce)
}
}
// component.ts
<button [appDebounce]="1000" click="initiateBulkPayment()(keyup.enter)="initiateBulkPayment">
See this live demo
Upvotes: 0
Reputation: 1
You can disable/enable the button to prevent click event or use shareReplay rxjs operator
return this.http.post(Constants.API_ENDPOINT + '/oms/api/orders/' +
orderId + '/payments/bulk_create/', postParams,
this.auth.returnHeaderHandler())
.pipe(map((data: any) => {
return data;
}
),
shareReplay(1)
catchError((err)=>{
return throwError(err);
}));
Docs link: https://www.learnrxjs.io/operators/multicasting/sharereplay.html
Upvotes: -1