UNPKG

ng-lock

Version:

Angular decorator for lock a function and user interface while a task running.

496 lines (482 loc) 19.3 kB
import * as i0 from '@angular/core'; import { signal, Injectable, Input, Directive, NgModule, makeEnvironmentProviders } from '@angular/core'; import { BehaviorSubject, tap } from 'rxjs'; import { finalize } from 'rxjs/operators'; import { HttpContextToken, HttpContext, HTTP_INTERCEPTORS } from '@angular/common/http'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; const NG_UNLOCK_CALLBACK = 'ngUnlockCallback'; const NG_IS_LOCK_CALLBACK = 'ngIsLockCallback'; const NG_LOCK_SIGNAL = 'ngLockSignal'; const NG_LOCK_SUBJECT = 'ngLockSubject'; const NG_LOCK_OPTION = 'ngLockOption'; const NG_LOCK_LOCKED_CLASS = 'ng-lock-locked'; /** * Unlock a locked function by ngLock() decorator * @param {NgLockFunction} methodToUnlock The function to unlock * @param {string} reason The reason for the log * @return {void} * @throws Error */ function ngUnlock(methodToUnlock, reason) { const callback = ngCallbacks(methodToUnlock, NG_UNLOCK_CALLBACK); callback(reason); } /** * Unlock all locked functions by ngLock() decorator * @param {any} component The component instance (this) * @return {void} */ function ngUnlockAll(component) { Object.getOwnPropertyNames(Object.getPrototypeOf(component)).forEach(key => { const prop = component[key]; if (typeof prop === 'function' && typeof prop['ngLock'] === 'object' && typeof prop['ngLock'][NG_UNLOCK_CALLBACK] === 'function') { if (prop['ngLock'][NG_IS_LOCK_CALLBACK]()) { prop['ngLock'][NG_UNLOCK_CALLBACK]('ngUnlockAll'); } } }); } /** * Return true if the provided function is locked * @param {NgLockFunction} methodToCheck The method to check * @return {boolean} * @throws Error */ function ngIsLock(methodToCheck) { const callback = ngCallbacks(methodToCheck, NG_IS_LOCK_CALLBACK); return callback(); } /** * Return a Signal for the given function on the lock status (locked/unlocked) * @param {NgLockFunction} method The function * @return {Signal<boolean>} */ function ngLockSignal(method) { const callback = ngCallbacks(method, NG_LOCK_SIGNAL); return callback(); } /** * Return the option for the given function * @param {NgLockFunction} method The function * @return {NgLockAllOption} */ function ngLockOption(method) { const callback = ngCallbacks(method, NG_LOCK_OPTION); return callback(); } /** * Return an Observable for the given function on the lock status (locked/unlocked) * @param {NgLockFunction} method - The function * @return {Observable<boolean>} */ function ngLockObservable(method) { const callback = ngCallbacks(method, NG_LOCK_SUBJECT); return callback(); } /** * Return the provided NG_CALLBACKS * @param {NgLockFunction} method The function to return the unlock callback * @param {NG_CALLBACKS} callback The NG_CALLBACKS * @return {NgLockFunction} Return the NG_CALLBACKS * @throws Error */ function ngCallbacks(method, callback) { if (!(method instanceof Function)) { throw new Error('"method" param must be a function.'); } if (callback !== NG_UNLOCK_CALLBACK && callback !== NG_IS_LOCK_CALLBACK && callback !== NG_LOCK_SIGNAL && callback !== NG_LOCK_SUBJECT && callback !== NG_LOCK_OPTION) { throw new Error(`"callback" param "${callback}" must be a NG_CALLBACKS.`); } if (typeof method['ngLock'] !== 'object') { throw new Error(`"method" param (function ${method.name}) must be a @ngLock() decorated function.`); } if (typeof method['ngLock'][callback] !== 'function') { throw new Error(`"method" param (function ${method.name}) must be a @ngLock() decorated function with "${callback}".`); } return method['ngLock'][callback]; } /** * Add class and attribute on HTML element * @param {Element} elementToLock * @param {NgLockOption} options */ const ngLockHtmlElement = (elementToLock, options) => { if (!elementToLock) { return; } if (options?.lockClass) elementToLock.classList.add(options.lockClass); elementToLock.setAttribute('disabled', 'disabled'); elementToLock.setAttribute('aria-disabled', 'true'); }; /** * Remove class and attribute from HTML element * @param {Element} elementToLock * @param {NgLockOption} options */ const ngUnLockHtmlElement = (elementToLock, options) => { if (!elementToLock) { return; } if (options?.lockClass) elementToLock.classList.remove(options.lockClass); elementToLock.removeAttribute('disabled'); elementToLock.removeAttribute('aria-disabled'); }; /** * Check if value is a Promise */ const isPromise = (value) => value && typeof value.finally === 'function' && typeof value.then === 'function' && value[Symbol.toStringTag] === 'Promise'; /** * Check if value is a destination partialObserver */ const isObserver = (value) => value && typeof value?.destination?.partialObserver === 'object'; /** * Uses the provided "selector" to find with "querySelector()" and apply the lockClass on the founded element. * @param {string} selector A DOMString containing a selector to match. * @returns {NgLockElementFinder} Return a NgLockElementFinder function * @throws Error */ const ngLockElementByQuerySelector = (selector) => { return (_self, _args) => { if (!selector) { throw new Error('selector is required'); } const el = document.querySelector(selector); if (el) { return el; } else { throw new Error('Element not found'); } }; }; /** * Uses a function argument for apply the lockClass. If provided a argsIndex use the specific argument, otherwise * search an argument with a target property that is a HTMLElement * @param {number} argsIndex (optional) index of the argument that is HTMLElement or contains target property (also a HTMLElement) * @returns {NgLockElementFinder} Return a NgLockElementFinder function * @throws Error */ const ngLockElementByTargetEventArgument = (argsIndex) => { return (_self, args) => { if (!args || args.length <= 0) { throw new Error('Method without arguments'); } let arg; if (typeof argsIndex === 'number') { if (argsIndex < 0) { throw new Error('argsIndex must be grater than or equal 0'); } else { if (args.length - 1 < argsIndex) { throw new Error('argsIndex grater than arguments length'); } arg = args[argsIndex]; if (arg.currentTarget instanceof HTMLElement) { return arg.currentTarget; } if (arg.target instanceof HTMLElement) { return arg.target; } if (arg instanceof HTMLElement) { return arg; } throw new Error('Argument not an HTMLElement or with target, currentTarget property'); } } else { arg = args.find(_arg => _arg.currentTarget instanceof HTMLElement); if (arg) { return arg.currentTarget; } arg = args.find(_arg => _arg.target instanceof HTMLElement); if (arg) { return arg.target; } arg = args.find(_arg => _arg instanceof HTMLElement); if (arg) { return arg; } throw new Error('Argument not found'); } }; }; /** * Apply lockClass to a component property that must be a HTMLElement or element with Angular nativeElement (also a HTMLElement) * @param {string} property The property name of the component * @returns {NgLockElementFinder} Return a NgLockElementFinder function * @throws Error */ const ngLockElementByComponentProperty = (property) => { return (self, _args) => { if (!property) { throw new Error('Property is required'); } const prop = self[property]; if (prop) { if (prop instanceof HTMLElement) { return prop; } if (prop.nativeElement instanceof HTMLElement) { return prop.nativeElement; } throw new Error('Property must be a HTMLElement or object with nativeElement (also HTMLElement)'); } else { throw new Error('Property not found'); } }; }; /** * ngLock default options * @see NgLockOption */ const NgLockDefaultOption = { maxCall: 1, unlockTimeout: null, lockElementFunction: ngLockElementByTargetEventArgument(), lockClass: NG_LOCK_LOCKED_CLASS, returnLastResultWhenLocked: false, unlockOnPromiseResolve: true, unlockOnObservableChanges: true, debug: false }; /** * Lock the decorated function * @param {NgLockOption} options (optional) NgLockOption * @return {MethodDecorator} Return a MethodDecorator */ function ngLock(options) { return function (_target, key, descriptor) { let _options; if (!options) { _options = { ...NgLockDefaultOption }; } else { _options = { ...NgLockDefaultOption, ...options }; } let callCounter = 0; let timeoutHandle = null; let elementToLock = null; let lastResult; const originalMethod = descriptor.value; const ngLockSignal = signal(false); const ngLockSubject = new BehaviorSubject(false); const ngLockLog = (...args) => { if (_options.debug) { console.log(...args); } }; const ngUnlockCallback = (log) => { let message = `NgLock: unlock method "${key.toString()}"`; message += `, reason: ${log ?? 'ngUnlock'}`; if (!ngIsLockCallback()) { message += ' (warning! the method is already unlocked)'; } ngLockLog(message); callCounter = 0; ngUnLockHtmlElement(elementToLock, _options); if (timeoutHandle) { clearTimeout(timeoutHandle); } ngLockSignal.set(false); ngLockSubject.next(false); }; const ngIsLockCallback = () => callCounter >= _options?.maxCall; ngLockLog(`NgLock: decorate method "${key.toString()}"`); descriptor.value = function (...args) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; if (ngIsLockCallback()) { ngLockLog(`NgLock: method "${key.toString()}" locked`); if (_options.returnLastResultWhenLocked) { return lastResult; } return; } callCounter++; if (typeof _options.lockElementFunction === 'function') { elementToLock = _options.lockElementFunction(self, args); } if (ngIsLockCallback()) { ngLockHtmlElement(elementToLock, _options); ngLockSignal.set(true); ngLockSubject.next(true); if (_options.unlockTimeout && _options.unlockTimeout > 0) { timeoutHandle = setTimeout(() => ngUnlockCallback('unlockTimeout reached'), _options.unlockTimeout); } } lastResult = originalMethod.apply(self, args); ngLockLog(`NgLock: execute method "${key.toString()}"`); if (_options.unlockOnPromiseResolve && isPromise(lastResult)) { lastResult.finally(() => ngUnlockCallback('Promise resolved')); } else if (_options.unlockOnObservableChanges && isObserver(lastResult)) { const obsNext = lastResult.destination.partialObserver.next; if (typeof obsNext === 'function') { lastResult.destination.partialObserver.next = (...args) => { ngUnlockCallback('Subscription changes'); obsNext(args); }; } const obsError = lastResult.destination.partialObserver.error; if (typeof obsError === 'function') { lastResult.destination.partialObserver.error = (...args) => { ngUnlockCallback('Subscription error'); obsError(args); }; } const obsComplete = lastResult.destination.partialObserver.complete; if (typeof obsComplete === 'function') { lastResult.destination.partialObserver.complete = () => { ngUnlockCallback('Subscription complete'); obsComplete(); }; } } return lastResult; }; Object.defineProperty(descriptor.value, 'ngLock', { value: { [NG_LOCK_OPTION]: () => _options, [NG_UNLOCK_CALLBACK]: ngUnlockCallback, [NG_IS_LOCK_CALLBACK]: ngIsLockCallback, [NG_LOCK_SIGNAL]: () => ngLockSignal.asReadonly(), [NG_LOCK_SUBJECT]: () => ngLockSubject.asObservable(), }, enumerable: true, writable: false }); return descriptor; }; } /** * Unlock the given method on HTTP response */ const NG_LOCK_CONTEXT = new HttpContextToken(() => (function () { })); /** * Return HttpContextToken for unlock the given method on HTTP response * @param {NgLockFunction} methodToUnlock the method to unlock * @param {HttpContext} context current context * @returns {HttpContext} */ const withNgLockContext = (methodToUnlock, context = new HttpContext()) => context.set(NG_LOCK_CONTEXT, methodToUnlock); class NgLockInterceptorService { intercept(req, next) { return next.handle(req).pipe(finalize(() => { const context = req.context.get(NG_LOCK_CONTEXT); if (typeof context === 'function') { ngUnlock(context, 'HTTP response'); } })); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgLockInterceptorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgLockInterceptorService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgLockInterceptorService, decorators: [{ type: Injectable }] }); /** * @ngModule NgLockModule * * @description * * The `ngLock` directive it's a Angular directive lock html element when a decorated method with `@ngLock` is running a task. * * @usageNotes * * ### Usage * * ```html * <input [ngLock]="myMethod" /><button (click)="myMethod($event)">Send</button> * ``` * ```ts * @ngLock() * myMethod(event: MouseEvent){ * return new Promise(resolve => setTimeout(resolve, 5000)); * } * ``` */ class NgLockDirective { constructor(eleRef, destroyRef) { this.eleRef = eleRef; this.destroyRef = destroyRef; } ngOnInit() { const option = ngLockOption(this.ngLock); ngLockObservable(this.ngLock).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(status => { if (status) { ngLockHtmlElement(this.eleRef.nativeElement, option); } else { ngUnLockHtmlElement(this.eleRef.nativeElement, option); } }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgLockDirective, deps: [{ token: i0.ElementRef }, { token: i0.DestroyRef }], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.0.2", type: NgLockDirective, isStandalone: true, selector: "[ngLock]", inputs: { ngLock: "ngLock" }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgLockDirective, decorators: [{ type: Directive, args: [{ selector: '[ngLock]', standalone: true }] }], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.DestroyRef }], propDecorators: { ngLock: [{ type: Input, args: [{ required: true }] }] } }); class NgLockModule { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgLockModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.0.2", ngImport: i0, type: NgLockModule, imports: [NgLockDirective], exports: [NgLockDirective] }); } static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgLockModule, providers: [ NgLockInterceptorService, { provide: HTTP_INTERCEPTORS, useClass: NgLockInterceptorService, multi: true, }, ] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgLockModule, decorators: [{ type: NgModule, args: [{ imports: [NgLockDirective], exports: [NgLockDirective], providers: [ NgLockInterceptorService, { provide: HTTP_INTERCEPTORS, useClass: NgLockInterceptorService, multi: true, }, ] }] }] }); /** * Unlock the method when Observable changes * @param {NgLockFunction} methodToUnlock method to unlock * @returns {MonoTypeOperatorFunction<T>} */ const ngLockChanges = (methodToUnlock) => { return (source$) => { return source$.pipe(tap(() => ngUnlock(methodToUnlock, 'Observable changes'))); }; }; function provideNgLock() { const providers = [makeEnvironmentProviders([{ provide: HTTP_INTERCEPTORS, useClass: NgLockInterceptorService, multi: true, }])]; return providers; } /* * Public API Surface of ng-lock */ /** * Generated bundle index. Do not edit. */ export { NG_IS_LOCK_CALLBACK, NG_LOCK_CONTEXT, NG_LOCK_LOCKED_CLASS, NG_LOCK_OPTION, NG_LOCK_SIGNAL, NG_LOCK_SUBJECT, NG_UNLOCK_CALLBACK, NgLockDefaultOption, NgLockDirective, NgLockInterceptorService, NgLockModule, isObserver, isPromise, ngCallbacks, ngIsLock, ngLock, ngLockChanges, ngLockElementByComponentProperty, ngLockElementByQuerySelector, ngLockElementByTargetEventArgument, ngLockHtmlElement, ngLockObservable, ngLockOption, ngLockSignal, ngUnLockHtmlElement, ngUnlock, ngUnlockAll, provideNgLock, withNgLockContext }; //# sourceMappingURL=ng-lock.mjs.map