From 624804ae29b8b42868ddbf853eebed199393f21e Mon Sep 17 00:00:00 2001 From: Andreas Dorner Date: Tue, 27 Aug 2024 10:50:20 +0200 Subject: [PATCH] feat: tap once --- .../docs/utilities/Operators/tap-once.md | 73 ++++++++++++++++++ libs/ngxtension/tapOnce/README.md | 3 + libs/ngxtension/tapOnce/ng-package.json | 5 ++ libs/ngxtension/tapOnce/project.json | 20 +++++ libs/ngxtension/tapOnce/src/index.ts | 1 + libs/ngxtension/tapOnce/src/tap-once.spec.ts | 76 +++++++++++++++++++ libs/ngxtension/tapOnce/src/tap-once.ts | 46 +++++++++++ 7 files changed, 224 insertions(+) create mode 100644 docs/src/content/docs/utilities/Operators/tap-once.md create mode 100644 libs/ngxtension/tapOnce/README.md create mode 100644 libs/ngxtension/tapOnce/ng-package.json create mode 100644 libs/ngxtension/tapOnce/project.json create mode 100644 libs/ngxtension/tapOnce/src/index.ts create mode 100644 libs/ngxtension/tapOnce/src/tap-once.spec.ts create mode 100644 libs/ngxtension/tapOnce/src/tap-once.ts diff --git a/docs/src/content/docs/utilities/Operators/tap-once.md b/docs/src/content/docs/utilities/Operators/tap-once.md new file mode 100644 index 000000000..141dcde64 --- /dev/null +++ b/docs/src/content/docs/utilities/Operators/tap-once.md @@ -0,0 +1,73 @@ +````markdown +--- +title: tapOnce / tapOnceOnFirstTruthy +description: Standalone RxJS operators for executing functions conditionally on emitted values. +entryPoint: ngxtension/tap-once +badge: stable +contributors: ['andreas-dorner'] +--- + +## Import + +```typescript +import { tapOnce, tapOnceOnFirstTruthy } from 'ngxtension/tap-once'; +``` +```` + +## Usage + +### tapOnce + +Executes the provided function only once when the value at the specified index is emitted. + +```typescript +import { from } from 'rxjs'; +import { tapOnce } from 'ngxtension/tap-once'; + +const in$ = from([1, 2, 3, 4, 5]); +const out$ = in$.pipe(tapOnce((value) => console.log(value), 2)); + +out$.subscribe(); // logs: 3 +``` + +#### Parameters + +- `tapFn`: Function to execute on the value at the specified index. +- `tapIndex`: Index at which to execute the function (default is 0). + +### tapOnceOnFirstTruthy + +Executes the provided function only once when the first truthy value is emitted. + +```typescript +import { from } from 'rxjs'; +import { tapOnceOnFirstTruthy } from 'ngxtension/tap-once'; + +const in$ = from([0, null, false, 3, 4, 5]); +const out$ = in$.pipe(tapOnceOnFirstTruthy((value) => console.log(value))); + +out$.subscribe(); // logs: 3 +``` + +#### Parameters + +- `tapFn`: Function to execute on the first truthy value. + +## API + +### tapOnce + +- `tapFn: (t: T) => void` +- `tapIndex: number = 0` + +### tapOnceOnFirstTruthy + +- `tapFn: (t: T) => void` + +### Validation + +- Throws an error if `tapIndex` is negative. + +``` + +``` diff --git a/libs/ngxtension/tapOnce/README.md b/libs/ngxtension/tapOnce/README.md new file mode 100644 index 000000000..007b7b40d --- /dev/null +++ b/libs/ngxtension/tapOnce/README.md @@ -0,0 +1,3 @@ +# ngxtension/tap-once + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/tap-once`. diff --git a/libs/ngxtension/tapOnce/ng-package.json b/libs/ngxtension/tapOnce/ng-package.json new file mode 100644 index 000000000..b3e53d699 --- /dev/null +++ b/libs/ngxtension/tapOnce/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/tapOnce/project.json b/libs/ngxtension/tapOnce/project.json new file mode 100644 index 000000000..ca0276ca7 --- /dev/null +++ b/libs/ngxtension/tapOnce/project.json @@ -0,0 +1,20 @@ +{ + "name": "ngxtension/tap-once", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/tap-once/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["tap-once"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/libs/ngxtension/tapOnce/src/index.ts b/libs/ngxtension/tapOnce/src/index.ts new file mode 100644 index 000000000..fca05424c --- /dev/null +++ b/libs/ngxtension/tapOnce/src/index.ts @@ -0,0 +1 @@ +export * from './tap-once'; diff --git a/libs/ngxtension/tapOnce/src/tap-once.spec.ts b/libs/ngxtension/tapOnce/src/tap-once.spec.ts new file mode 100644 index 000000000..a8006a5c4 --- /dev/null +++ b/libs/ngxtension/tapOnce/src/tap-once.spec.ts @@ -0,0 +1,76 @@ +import { from, toArray } from 'rxjs'; +import { tapOnce, tapOnceOnFirstTruthy } from './tap-once'; + +describe(tapOnce.name, () => { + it('should execute the function only once at the specified index', (done) => { + const tapFn = jest.fn(); + const in$ = from([1, 2, 3, 4, 5]); + const out$ = in$.pipe(tapOnce(tapFn, 2)); + + out$.pipe(toArray()).subscribe((r) => { + expect(r).toEqual([1, 2, 3, 4, 5]); + expect(tapFn).toHaveBeenCalledTimes(1); + expect(tapFn).toHaveBeenCalledWith(3); + done(); + }); + }); + + it('should execute the function only once at the default index 0', (done) => { + const tapFn = jest.fn(); + const in$ = from([1, 2, 3, 4, 5]); + const out$ = in$.pipe(tapOnce(tapFn)); + + out$.pipe(toArray()).subscribe((r) => { + expect(r).toEqual([1, 2, 3, 4, 5]); + expect(tapFn).toHaveBeenCalledTimes(1); + expect(tapFn).toHaveBeenCalledWith(1); + done(); + }); + }); + + it('should throw an error if tapIndex is negative', () => { + expect(() => tapOnce(() => {}, -1)).toThrow( + 'tapIndex must be a non-negative integer', + ); + }); +}); + +describe(tapOnceOnFirstTruthy.name, () => { + it('should execute the function only once on the first truthy value', (done) => { + const tapFn = jest.fn(); + const in$ = from([0, null, false, 3, 4, 5]); + const out$ = in$.pipe(tapOnceOnFirstTruthy(tapFn)); + + out$.pipe(toArray()).subscribe((r) => { + expect(r).toEqual([0, null, false, 3, 4, 5]); + expect(tapFn).toHaveBeenCalledTimes(1); + expect(tapFn).toHaveBeenCalledWith(3); + done(); + }); + }); + + it('should not execute the function if there are no truthy values', (done) => { + const tapFn = jest.fn(); + const in$ = from([0, null, false, undefined]); + const out$ = in$.pipe(tapOnceOnFirstTruthy(tapFn)); + + out$.pipe(toArray()).subscribe((r) => { + expect(r).toEqual([0, null, false, undefined]); + expect(tapFn).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should execute the function only once even if there are multiple truthy values', (done) => { + const tapFn = jest.fn(); + const in$ = from([1, 2, 3, 4, 5]); + const out$ = in$.pipe(tapOnceOnFirstTruthy(tapFn)); + + out$.pipe(toArray()).subscribe((r) => { + expect(r).toEqual([1, 2, 3, 4, 5]); + expect(tapFn).toHaveBeenCalledTimes(1); + expect(tapFn).toHaveBeenCalledWith(1); + done(); + }); + }); +}); diff --git a/libs/ngxtension/tapOnce/src/tap-once.ts b/libs/ngxtension/tapOnce/src/tap-once.ts new file mode 100644 index 000000000..480ebda32 --- /dev/null +++ b/libs/ngxtension/tapOnce/src/tap-once.ts @@ -0,0 +1,46 @@ +import { MonoTypeOperatorFunction, concatMap, of, type Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +/** + * Executes the provided function only once when the first truthy value is emitted. + * @param tapFn - Function to execute on the first truthy value. + * @returns MonoTypeOperatorFunction + */ +export function tapOnceOnFirstTruthy( + tapFn: (t: T) => void, +): MonoTypeOperatorFunction { + let firstTruthy = true; + return (source$: Observable) => + source$.pipe( + tap((value) => { + if (firstTruthy && !!value) { + tapFn(value); + firstTruthy = false; + } + }), + ); +} + +/** + * Executes the provided function only once when the value at the specified index is emitted. + * @param tapFn - Function to execute on the value at the specified index. + * @param tapIndex - Index at which to execute the function (default is 0). + * @returns MonoTypeOperatorFunction + */ +export function tapOnce( + tapFn: (t: T) => void, + tapIndex = 0, +): MonoTypeOperatorFunction { + if (tapIndex < 0) { + throw new Error('tapIndex must be a non-negative integer'); + } + return (source$: Observable) => + source$.pipe( + concatMap((value, index) => { + if (index === tapIndex) { + tapFn(value); + } + return of(value); + }), + ); +}