UNPKG

@ssv/ngx.command

Version:

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

404 lines (394 loc) 18.6 kB
import * as i0 from '@angular/core'; import { InjectionToken, assertInInjectionContext, inject, Injector, signal, computed, isSignal, Renderer2, ElementRef, ChangeDetectorRef, input, effect, Directive, NgModule } from '@angular/core'; import { isObservable, lastValueFrom, concat, defer, of, filter, map, distinctUntilChanged, combineLatest } from 'rxjs'; import { toSignal } from '@angular/core/rxjs-interop'; import { PristineChangeEvent, StatusChangeEvent } from '@angular/forms'; const DEFAULT_OPTIONS = Object.freeze({ executingCssClass: "executing", handleDisabled: true, }); const COMMAND_OPTIONS = new InjectionToken("SSV_COMMAND_OPTIONS", { factory: () => DEFAULT_OPTIONS, }); function provideSsvCommandOptions(options) { return [ { provide: COMMAND_OPTIONS, useFactory: () => { let opts = typeof options === "function" ? options(DEFAULT_OPTIONS) : options; opts = opts ? { ...DEFAULT_OPTIONS, ...opts, } : DEFAULT_OPTIONS; return opts; }, }, ]; } // todo: remove /** Creates an async {@link Command}. Must be used within an injection context. * @deprecated Use {@link command} instead, as it handles both sync and async execute functions. */ const commandAsync = command; /** Creates a {@link Command}. Must be used within an injection context (or the injector must be provided in the options). */ function command(execute, canExecute, opts) { if (!opts?.injector) { assertInInjectionContext(command); } const injector = opts?.injector ?? inject(Injector); const cmd = new Command(execute, canExecute, injector); return cmd; } /** * Command object used to encapsulate information which is needed to perform an action. */ class Command { _execute; get isExecuting() { return this.$isExecuting(); } get canExecute() { return this.$canExecute(); } $isExecuting = signal(false, ...(ngDevMode ? [{ debugName: "$isExecuting" }] : /* istanbul ignore next */ [])); $canExecute = computed(() => !this.$isExecuting() && this.#canExecute(), ...(ngDevMode ? [{ debugName: "$canExecute" }] : /* istanbul ignore next */ [])); #canExecute; /** * Creates an instance of Command. * * @param execute Execute function to invoke. * @param canExecute Observable which determines whether it can execute or not. * @deprecated Use {@link command} or {@link commandAsync} instead for creating instances. */ constructor(_execute, canExecute, injector) { this._execute = _execute; this.#canExecute = this.#buildCanExecuteSignal(canExecute, injector); } /** Execute function to invoke. Returns Promise if the execute function returns Observable, otherwise returns the original type. */ execute(...args) { if (!this.$canExecute()) { throw new Error("Command cannot execute in its current state."); // return Promise.reject() as ReturnType<TExecute>; } this.$isExecuting.set(true); // console.warn("[command::execute]", args); try { const result = args.length > 0 ? this._execute(...args) : this._execute(); if (isObservable(result)) { // Convert observable to promise using lastValueFrom // This ensures fire-and-forget execution without requiring manual subscription // Use defaultValue to handle empty observables (those that complete without emitting) const promise = lastValueFrom(result, { defaultValue: undefined }) .finally(() => this.$isExecuting.set(false)); return promise; } else if (result instanceof Promise) { // Return promise with proper cleanup return result .finally(() => this.$isExecuting.set(false)); } // Sync execution this.$isExecuting.set(false); return result; } catch (err) { this.$isExecuting.set(false); throw err; } } #buildCanExecuteSignal(canExecute, injector) { if (canExecute === undefined) { return computed(() => true); } if (isSignal(canExecute)) { return canExecute; } if (typeof canExecute === "function") { return computed(canExecute); } if (typeof canExecute === "boolean") { return computed(() => canExecute); } return toSignal(canExecute, { initialValue: false, injector }); } } /** 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"; } const NAME_CAMEL$1 = "ssvCommand"; // let nextUniqueId = 0; /** * 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 (direct) * * ```html * <button [ssvCommand]="saveCmd" [ssvCommandParams]="hero">Save</button> * ``` * * #### With single param (array) * * ```html * <button [ssvCommand]="saveCmd" [ssvCommandParams]="[hero]">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 get `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> * ``` * */ class SsvCommand { // readonly id = `${NAME_CAMEL}-${nextUniqueId++}`; #options = inject(COMMAND_OPTIONS); #renderer = inject(Renderer2); #element = inject(ElementRef); #cdr = inject(ChangeDetectorRef); #injector = inject(Injector); commandOrCreator = input.required({ ...(ngDevMode ? { debugName: "commandOrCreator" } : /* istanbul ignore next */ {}), alias: `ssvCommand` }); ssvCommandOptions = input(this.#options, ...(ngDevMode ? [{ debugName: "ssvCommandOptions" }] : /* istanbul ignore next */ [])); commandOptions = computed(() => { const value = this.ssvCommandOptions(); if (value === this.#options) { return this.#options; } return { ...this.#options, ...value, }; }, ...(ngDevMode ? [{ debugName: "commandOptions" }] : /* istanbul ignore next */ [])); ssvCommandParams = input(...(ngDevMode ? [undefined, { debugName: "ssvCommandParams" }] : /* istanbul ignore next */ [])); commandParams = computed(() => { const params = this.ssvCommandParams(); if (params === undefined) { return this.#creatorParams(); } // Normalize single param to array format for consistent handling return this.#normalizeParams(params); }, ...(ngDevMode ? [{ debugName: "commandParams" }] : /* istanbul ignore next */ [])); _hostClasses = computed(() => ["ssv-command", this.#executingClass()], ...(ngDevMode ? [{ debugName: "_hostClasses" }] : /* istanbul ignore next */ [])); #executingClass = computed(() => this.#command().$isExecuting() ? this.commandOptions().executingCssClass : "", ...(ngDevMode ? [{ debugName: "#executingClass" }] : /* istanbul ignore next */ [])); #creatorParams = signal(undefined, ...(ngDevMode ? [{ debugName: "#creatorParams" }] : /* istanbul ignore next */ [])); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion #command = signal(undefined, ...(ngDevMode ? [{ debugName: "#command" }] : /* istanbul ignore next */ [])); command = this.#command.asReadonly(); constructor() { effect(() => { const canExecute = this.#command().$canExecute(); this.trySetDisabled(!canExecute); // console.log("[ssvCommand::canExecute$]", { canExecute: x }); this.#cdr.markForCheck(); }); } // todo: afterNextRender ngOnInit() { const commandOrCreator = this.commandOrCreator(); // console.log("[ssvCommand::init]", this.#options); if (isCommand(commandOrCreator)) { this.#command.set(commandOrCreator); } else if (isCommandCreator(commandOrCreator)) { this.#creatorParams.set(this.#normalizeParams(commandOrCreator.params)); // todo: find something like this for ivy (or angular10+) // const hostComponent = (this.viewContainer as any)._view.component; const execFn = commandOrCreator.execute.bind(commandOrCreator.host); const params = this.commandParams(); let canExec; if (commandOrCreator.canExecute instanceof Function) { const boundFn = commandOrCreator.canExecute.bind(commandOrCreator.host); const result = Array.isArray(params) ? boundFn(...params) : boundFn(); canExec = result; } else { canExec = commandOrCreator.canExecute; } // console.log("[ssvCommand::init] command creator", { // firstParam: params ? params[0] : null, // params // }); const cmd = command(execFn, canExec, { injector: this.#injector }); this.#command.set(cmd); } else { throw new Error(`${NAME_CAMEL$1}: [${NAME_CAMEL$1}] is not defined properly!`); } } _handleClick() { const commandParams = this.commandParams(); // console.log("[ssvCommand::onClick]", commandParams); if (Array.isArray(commandParams)) { this.#command().execute(...commandParams); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any this.#command().execute(); } } trySetDisabled(disabled) { if (this.commandOptions().handleDisabled) { // console.warn(">>>> disabled", { id: this.id, disabled }); this.#renderer.setProperty(this.#element.nativeElement, "disabled", disabled); } } /** Normalizes params to array format for consistent execution */ #normalizeParams(params) { // If params is already an array, return as-is if (Array.isArray(params)) { return params; } // Single non-array param - wrap it return [params]; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SsvCommand, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.4", type: SsvCommand, isStandalone: true, selector: "[ssvCommand]", inputs: { commandOrCreator: { classPropertyName: "commandOrCreator", publicName: "ssvCommand", isSignal: true, isRequired: true, transformFunction: null }, ssvCommandOptions: { classPropertyName: "ssvCommandOptions", publicName: "ssvCommandOptions", isSignal: true, isRequired: false, transformFunction: null }, ssvCommandParams: { classPropertyName: "ssvCommandParams", publicName: "ssvCommandParams", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "click": "_handleClick()" }, properties: { "class": "_hostClasses()" } }, exportAs: ["ssvCommand"], ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SsvCommand, decorators: [{ type: Directive, args: [{ selector: `[${NAME_CAMEL$1}]`, host: { "[class]": "_hostClasses()", "(click)": "_handleClick()", }, // todo: handle keydown/enter? exportAs: NAME_CAMEL$1, standalone: true, }] }], ctorParameters: () => [], propDecorators: { commandOrCreator: [{ type: i0.Input, args: [{ isSignal: true, alias: "ssvCommand", required: true }] }], ssvCommandOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "ssvCommandOptions", required: false }] }], ssvCommandParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "ssvCommandParams", required: false }] }] } }); 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 SsvCommandRef { #injector = inject(Injector); commandCreator = input.required({ ...(ngDevMode ? { debugName: "commandCreator" } : /* istanbul ignore next */ {}), alias: `ssvCommandRef` }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion #command = signal(undefined, ...(ngDevMode ? [{ debugName: "#command" }] : /* istanbul ignore next */ [])); command = this.#command.asReadonly(); // todo: use afterNextRender ngOnInit() { const commandOrCreator = this.commandCreator(); if (isCommandCreator(commandOrCreator)) { const commandCreator = commandOrCreator; const execFn = commandCreator.execute.bind(commandCreator.host); const cmd = command(execFn, commandCreator.canExecute, { injector: this.#injector }); this.#command.set(cmd); } else { throw new Error(`${NAME_CAMEL}: [${NAME_CAMEL}] is not defined properly!`); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SsvCommandRef, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.4", type: SsvCommandRef, isStandalone: true, selector: "[ssvCommandRef]", inputs: { commandCreator: { classPropertyName: "commandCreator", publicName: "ssvCommandRef", isSignal: true, isRequired: true, transformFunction: null } }, exportAs: ["ssvCommandRef"], ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SsvCommandRef, decorators: [{ type: Directive, args: [{ selector: `[${NAME_CAMEL}]`, exportAs: NAME_CAMEL, standalone: true, }] }], propDecorators: { commandCreator: [{ type: i0.Input, args: [{ isSignal: true, alias: "ssvCommandRef", required: true }] }] } }); const EXPORTED_IMPORTS = [ SsvCommand, SsvCommandRef ]; /** @deprecated Use standalone instead. */ class SsvCommandModule { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SsvCommandModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.2.4", ngImport: i0, type: SsvCommandModule, imports: [SsvCommand, SsvCommandRef], exports: [SsvCommand, SsvCommandRef] }); static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SsvCommandModule }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SsvCommandModule, decorators: [{ type: NgModule, args: [{ imports: [EXPORTED_IMPORTS], exports: [EXPORTED_IMPORTS] }] }] }); const VERSION = "5.1.0"; /** * Generated bundle index. Do not edit. */ export { COMMAND_OPTIONS, Command, SsvCommand, SsvCommandModule, SsvCommandRef, VERSION, canExecuteFromNgForm, canExecuteFromSignals, command, commandAsync, isCommand, isCommandCreator, provideSsvCommandOptions }; //# sourceMappingURL=ssv-ngx.command.mjs.map