@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
JavaScript
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