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