UNPKG

@ssv/ngx.command

Version:

Command pattern implementation for angular. Command used to encapsulate information which is needed to perform an action.

471 lines (461 loc) 19.6 kB
import * as i0 from '@angular/core'; import { InjectionToken, makeEnvironmentProviders, assertInInjectionContext, inject, Injector, DestroyRef, computed, isSignal, Renderer2, ElementRef, ChangeDetectorRef, Directive, Input, HostListener, NgModule } from '@angular/core'; import { BehaviorSubject, Subject, Subscription, combineLatest, map, tap, filter, switchMap, of, finalize, take, catchError, EMPTY, concat, defer, distinctUntilChanged, delay, takeUntil } from 'rxjs'; import { toObservable } from '@angular/core/rxjs-interop'; import { PristineChangeEvent, StatusChangeEvent } from '@angular/forms'; const DEFAULT_OPTIONS = Object.freeze({ executingCssClass: "executing", handleDisabled: true, hasDisabledDelay: false, }); const COMMAND_OPTIONS = new InjectionToken("SSV_COMMAND_OPTIONS", { factory: () => DEFAULT_OPTIONS, }); function provideSsvCommandOptions(options) { return makeEnvironmentProviders([ { provide: COMMAND_OPTIONS, useFactory: () => { let opts = typeof options === "function" ? options(DEFAULT_OPTIONS) : options; opts = opts ? { ...DEFAULT_OPTIONS, ...opts, } : DEFAULT_OPTIONS; return opts; }, }, ]); } /* eslint-disable @typescript-eslint/no-explicit-any */ const COMMAND_ASYNC_DEFAULT_OPTIONS = { isAsync: true }; /** Creates an async {@link Command}. Must be used within an injection context. * NOTE: this auto injects `DestroyRef` and handles auto destroy. {@link ICommand.autoDestroy} should not be used. */ function commandAsync(execute, canExecute$, opts) { return command(execute, canExecute$, opts ? { ...opts, ...COMMAND_ASYNC_DEFAULT_OPTIONS } : COMMAND_ASYNC_DEFAULT_OPTIONS); } /** Creates a {@link Command}. Must be used within an injection context. * NOTE: this auto injects `DestroyRef` and handles auto destroy. {@link ICommand.autoDestroy} should not be used. */ function command(execute, canExecute$, opts) { if (!opts?.injector) { assertInInjectionContext(command); } const injector = opts?.injector ?? inject(Injector); const isAsync = opts?.isAsync ?? false; const destroyRef = injector.get(DestroyRef); const cmd = new Command(execute, canExecute$, isAsync); cmd.autoDestroy = false; destroyRef.onDestroy(() => { // console.warn("[command::destroy]"); cmd.destroy(); }); return cmd; } /** * Command object used to encapsulate information which is needed to perform an action. * @deprecated Use {@link command} or {@link commandAsync} instead for creating instances. */ class Command { /** Determines whether the command is currently executing, as a snapshot value. */ get isExecuting() { return this._isExecuting; } /** Determines whether the command can execute or not, as a snapshot value. */ get canExecute() { return this._canExecute; } /** Determines whether the command is currently executing, as an observable. */ get isExecuting$() { return this._isExecuting$.asObservable(); } /** Determines whether to auto destroy when having 0 subscribers. */ autoDestroy = true; /** Determines whether the command can execute or not, as an observable. */ canExecute$; _isExecuting$ = new BehaviorSubject(false); _isExecuting = false; _canExecute = true; executionPipe$ = new Subject(); isExecuting$$ = Subscription.EMPTY; canExecute$$ = Subscription.EMPTY; executionPipe$$ = Subscription.EMPTY; subscribersCount = 0; /** * Creates an instance of Command. * * @param execute Execute function to invoke - use `isAsync: true` when `Observable<any>`. * @param canExecute Observable which determines whether it can execute or not. * @param isAsync Indicates that the execute function is async e.g. Observable. */ constructor(execute, canExecute$, isAsync, injector) { if (canExecute$) { const canExecute = typeof canExecute$ === "function" ? computed(canExecute$) : canExecute$; this.canExecute$ = combineLatest([ this._isExecuting$, isSignal(canExecute) ? toObservable(canExecute, { injector }) : canExecute ]).pipe(map(([isExecuting, canExecuteResult]) => { // console.log("[command::combineLatest$] update!", { isExecuting, canExecuteResult }); this._isExecuting = isExecuting; this._canExecute = !isExecuting && !!canExecuteResult; return this._canExecute; })); this.canExecute$$ = this.canExecute$.subscribe(); } else { this.canExecute$ = this._isExecuting$.pipe(map(x => { const canExecute = !x; this._canExecute = canExecute; return canExecute; })); this.isExecuting$$ = this._isExecuting$ .pipe(tap(x => this._isExecuting = x)) .subscribe(); } this.executionPipe$$ = this.buildExecutionPipe(execute, isAsync).subscribe(); } /** Execute function to invoke. */ execute(...args) { // console.warn("[command::execute]", args); this.executionPipe$.next(args); } /** Disposes all resources held by subscriptions. */ destroy() { // console.warn("[command::destroy]"); this.executionPipe$$.unsubscribe(); this.canExecute$$.unsubscribe(); this.isExecuting$$.unsubscribe(); } subscribe() { this.subscribersCount++; } unsubscribe() { this.subscribersCount--; // console.log("[command::unsubscribe]", { autoDestroy: this.autoDestroy, subscribersCount: this.subscribersCount }); if (this.autoDestroy && this.subscribersCount <= 0) { this.destroy(); } } buildExecutionPipe(execute, isAsync) { let pipe$ = this.executionPipe$.pipe( // tap(x => console.warn(">>>> executionPipe", this._canExecute)), filter(() => this._canExecute), tap(() => { // console.log("[command::executionPipe$] do#1 - set execute", { args: x }); this._isExecuting$.next(true); })); const execFn = isAsync ? switchMap(args => { if (args) { return execute(...args); } return execute(); }) : tap((args) => { if (args) { execute(...args); return; } execute(); }); pipe$ = pipe$.pipe(switchMap(args => of(args).pipe(execFn, finalize(() => { // console.log("[command::executionPipe$] finalize inner#1 - set idle"); this._isExecuting$.next(false); }), take(1), catchError(error => { console.error("Unhandled execute error", error); return EMPTY; }))), tap(() => { // console.log("[command::executionPipe$] tap#2 - set idle"); this._isExecuting$.next(false); })); return pipe$; } } /** * Async Command object used to encapsulate information which is needed to perform an action, * which takes an execute function as Observable/Promise. * @deprecated Use {@link commandAsync} instead. */ class CommandAsync extends Command { constructor(execute, canExecute$) { super(execute, canExecute$, true); } } /** Determines whether the arg object is of type `Command`. */ function isCommand(arg) { return arg instanceof Command; } /** Determines whether the arg object is of type `CommandCreator`. */ function isCommandCreator(arg) { if (arg instanceof Command) { return false; } else if (isAssumedType(arg) && arg.execute && arg.host) { return true; } return false; } const CAN_EXECUTE_FORM_OPTIONS_DEFAULTS = Object.freeze({ validity: true, dirty: true, }); /** Get can execute from form validity/pristine as an observable. */ function canExecuteFromNgForm(form, options) { const opts = options ? { ...CAN_EXECUTE_FORM_OPTIONS_DEFAULTS, ...options } : CAN_EXECUTE_FORM_OPTIONS_DEFAULTS; const pristine$ = opts.dirty ? concat(defer(() => of(form.pristine)), form.events.pipe(filter(x => x instanceof PristineChangeEvent), map(x => x.pristine))).pipe(distinctUntilChanged()) : of(true); const valid$ = opts.validity ? concat(defer(() => of(form.valid)), form.events.pipe(filter(x => x instanceof StatusChangeEvent), map(x => x.status === "VALID"))).pipe(distinctUntilChanged()) : of(true); return combineLatest([pristine$, valid$]).pipe(map(([pristine, valid]) => !!(!opts.validity || valid) && !!(!opts.dirty || !pristine)), distinctUntilChanged()); } /** Can executed based on valid/dirty signal inputs. */ function canExecuteFromSignals(signals, options) { const opts = options ? { ...CAN_EXECUTE_FORM_OPTIONS_DEFAULTS, ...options } : CAN_EXECUTE_FORM_OPTIONS_DEFAULTS; return computed(() => !!(!opts.validity || signals.valid()) && !!(!opts.dirty || signals.dirty())); } function isAssumedType(x) { return x !== null && typeof x === "object"; } /** * Controls the state of a component in sync with `Command`. * * @example * ### Most common usage * ```html * <button [ssvCommand]="saveCmd">Save</button> * ``` * * * ### Usage with options * ```html * <button [ssvCommand]="saveCmd" [ssvCommandOptions]="{executingCssClass: 'in-progress'}">Save</button> * ``` * * * ### Usage with params * This is useful for collections (loops) or using multiple actions with different args. * *NOTE: This will share the `isExecuting` when used with multiple controls.* * * #### With single param * * ```html * <button [ssvCommand]="saveCmd" [ssvCommandParams]="{id: 1}">Save</button> * ``` * *NOTE: if you have only 1 argument as an array, it should be enclosed within an array e.g. `[['apple', 'banana']]`, * else it will spread and you will `arg1: "apple", arg2: "banana"`* * * #### With multi params * ```html * <button [ssvCommand]="saveCmd" [ssvCommandParams]="[{id: 1}, 'hello', hero]">Save</button> * ``` * * ### Usage with Command Creator * This is useful for collections (loops) or using multiple actions with different args, whilst not sharing `isExecuting`. * * * ```html * <button [ssvCommand]="{host: this, execute: removeHero$, canExecute: isValid$, params: [hero, 1337, 'xx']}">Save</button> * ``` * */ const NAME_CAMEL$1 = "ssvCommand"; // let nextUniqueId = 0; class CommandDirective { // readonly id = `${NAME_CAMEL}-${nextUniqueId++}`; globalOptions = inject(COMMAND_OPTIONS); renderer = inject(Renderer2); element = inject(ElementRef); cdr = inject(ChangeDetectorRef); commandOrCreator; get commandOptions() { return this._commandOptions; } set commandOptions(value) { if (value === this._commandOptions) { return; } this._commandOptions = { ...this.globalOptions, ...value, }; } commandParams; get command() { return this._command; } _command; _commandOptions = this.globalOptions; _destroy$ = new Subject(); ngOnInit() { // console.log("[ssvCommand::init]", this.globalOptions); if (!this.commandOrCreator) { throw new Error(`${NAME_CAMEL$1}: [${NAME_CAMEL$1}] should be defined!`); } else if (isCommand(this.commandOrCreator)) { this._command = this.commandOrCreator; } else if (isCommandCreator(this.commandOrCreator)) { const isAsync = this.commandOrCreator.isAsync || this.commandOrCreator.isAsync === undefined; // todo: find something like this for ivy (or angular10+) // const hostComponent = (this.viewContainer as any)._view.component; const execFn = this.commandOrCreator.execute.bind(this.commandOrCreator.host); this.commandParams = this.commandParams || this.commandOrCreator.params; const canExec = this.commandOrCreator.canExecute instanceof Function ? this.commandOrCreator.canExecute.bind(this.commandOrCreator.host, this.commandParams)() : this.commandOrCreator.canExecute; // console.log("[ssvCommand::init] command creator", { // firstParam: this.commandParams ? this.commandParams[0] : null, // params: this.commandParams // }); this._command = new Command(execFn, canExec, isAsync); } else { throw new Error(`${NAME_CAMEL$1}: [${NAME_CAMEL$1}] is not defined properly!`); } this._command.subscribe(); this._command.canExecute$.pipe(this.commandOptions.hasDisabledDelay ? delay(1) : tap(() => { }), tap(x => { this.trySetDisabled(!x); // console.log("[ssvCommand::canExecute$]", { canExecute: x }); this.cdr.markForCheck(); }), takeUntil(this._destroy$)).subscribe(); if (this._command.isExecuting$) { this._command.isExecuting$.pipe(tap(x => { // console.log("[ssvCommand::isExecuting$]", x, this.commandOptions); if (x) { this.renderer.addClass(this.element.nativeElement, this.commandOptions.executingCssClass); } else { this.renderer.removeClass(this.element.nativeElement, this.commandOptions.executingCssClass); } }), takeUntil(this._destroy$)).subscribe(); } } onClick() { // console.log("[ssvCommand::onClick]", this.commandParams); if (Array.isArray(this.commandParams)) { this._command.execute(...this.commandParams); } else { this._command.execute(this.commandParams); } } ngOnDestroy() { // console.log("[ssvCommand::destroy]"); this._destroy$.next(); this._destroy$.complete(); if (this._command) { this._command.unsubscribe(); } } trySetDisabled(disabled) { if (this.commandOptions.handleDisabled) { // console.warn(">>>> disabled", { id: this.id, disabled }); this.renderer.setProperty(this.element.nativeElement, "disabled", disabled); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: CommandDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.7", type: CommandDirective, isStandalone: true, selector: "[ssvCommand]", inputs: { commandOrCreator: ["ssvCommand", "commandOrCreator"], commandOptions: ["ssvCommandOptions", "commandOptions"], commandParams: ["ssvCommandParams", "commandParams"] }, host: { listeners: { "click": "onClick()" } }, exportAs: ["ssvCommand"], ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: CommandDirective, decorators: [{ type: Directive, args: [{ selector: `[${NAME_CAMEL$1}]`, exportAs: NAME_CAMEL$1, standalone: true, }] }], propDecorators: { commandOrCreator: [{ type: Input, args: [NAME_CAMEL$1] }], commandOptions: [{ type: Input, args: [`${NAME_CAMEL$1}Options`] }], commandParams: [{ type: Input, args: [`${NAME_CAMEL$1}Params`] }], onClick: [{ type: HostListener, args: ["click"] }] } }); const NAME_CAMEL = "ssvCommandRef"; /** * Command creator ref, directive which allows creating Command in the template * and associate it to a command (in order to share executions). * @example * ### Most common usage * ```html * <div #actionCmd="ssvCommandRef" [ssvCommandRef]="{host: this, execute: removeHero$, canExecute: isValid$}"> * <button [ssvCommand]="actionCmd.command" [ssvCommandParams]="hero"> * Remove * </button> * <button [ssvCommand]="actionCmd.command" [ssvCommandParams]="hero"> * Remove * </button> * </div> * ``` * */ class CommandRefDirective { commandCreator; get command() { return this._command; } _command; ngOnInit() { if (isCommandCreator(this.commandCreator)) { const isAsync = this.commandCreator.isAsync || this.commandCreator.isAsync === undefined; const execFn = this.commandCreator.execute.bind(this.commandCreator.host); this._command = new Command(execFn, this.commandCreator.canExecute, isAsync); } else { throw new Error(`${NAME_CAMEL}: [${NAME_CAMEL}] is not defined properly!`); } } ngOnDestroy() { // console.log("[commandRef::destroy]"); if (this._command) { this._command.destroy(); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: CommandRefDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.7", type: CommandRefDirective, isStandalone: true, selector: "[ssvCommandRef]", inputs: { commandCreator: ["ssvCommandRef", "commandCreator"] }, exportAs: ["ssvCommandRef"], ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: CommandRefDirective, decorators: [{ type: Directive, args: [{ selector: `[${NAME_CAMEL}]`, exportAs: NAME_CAMEL, standalone: true, }] }], propDecorators: { commandCreator: [{ type: Input, args: [NAME_CAMEL] }] } }); const EXPORTED_IMPORTS = [ CommandDirective, CommandRefDirective ]; class SsvCommandModule { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: SsvCommandModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: SsvCommandModule, imports: [CommandDirective, CommandRefDirective], exports: [CommandDirective, CommandRefDirective] }); static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: SsvCommandModule }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: SsvCommandModule, decorators: [{ type: NgModule, args: [{ imports: [EXPORTED_IMPORTS], exports: [EXPORTED_IMPORTS] }] }] }); const VERSION = "3.3.0-dev.76"; /** * Generated bundle index. Do not edit. */ export { COMMAND_OPTIONS, Command, CommandAsync, CommandDirective, CommandRefDirective, SsvCommandModule, VERSION, canExecuteFromNgForm, canExecuteFromSignals, command, commandAsync, isCommand, isCommandCreator, provideSsvCommandOptions }; //# sourceMappingURL=ssv-ngx.command.mjs.map