diff --git a/docs/src/content/docs/utilities/RxJS-Operators/tap-once.md b/docs/src/content/docs/utilities/RxJS-Operators/tap-once.md new file mode 100644 index 000000000..d606f90ef --- /dev/null +++ b/docs/src/content/docs/utilities/RxJS-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: ['endlacer'] +--- + +## 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..ed44d0b63 --- /dev/null +++ b/libs/ngxtension/tapOnce/src/tap-once.spec.ts @@ -0,0 +1,122 @@ +import { from, toArray } from 'rxjs'; +import { tapOnce, tapOnceOnFirstTruthy } from './tap-once'; + +describe(tapOnce.name, () => { + 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 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 throw an error if tapIndex is negative', () => { + expect(() => tapOnce(() => void 0, -1)).toThrow( + 'tapIndex must be a non-negative integer', + ); + }); + + it('should not share state across multiple subscriptions', (done) => { + const tapFn = jest.fn(); + const in$ = from([1, 2, 3, 4, 5]); + const out$ = in$.pipe(tapOnce(tapFn, 1)); + + // First subscription + out$.pipe(toArray()).subscribe((r1) => { + expect(r1).toEqual([1, 2, 3, 4, 5]); + expect(tapFn).toHaveBeenCalledTimes(1); + expect(tapFn).toHaveBeenCalledWith(2); + + tapFn.mockClear(); + + // Second subscription - should also trigger the tap function + out$.pipe(toArray()).subscribe((r2) => { + expect(r2).toEqual([1, 2, 3, 4, 5]); + expect(tapFn).toHaveBeenCalledTimes(1); + expect(tapFn).toHaveBeenCalledWith(2); + done(); + }); + }); + }); +}); + +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(); + }); + }); + + it('should not share state across multiple subscriptions', (done) => { + const tapFn = jest.fn(); + const in$ = from([0, null, false, 3, 4, 5]); + const out$ = in$.pipe(tapOnceOnFirstTruthy(tapFn)); + + // First subscription + out$.pipe(toArray()).subscribe((r1) => { + expect(r1).toEqual([0, null, false, 3, 4, 5]); + expect(tapFn).toHaveBeenCalledTimes(1); + expect(tapFn).toHaveBeenCalledWith(3); + + tapFn.mockClear(); + + // Second subscription - should also trigger the tap function + out$.pipe(toArray()).subscribe((r2) => { + expect(r2).toEqual([0, null, false, 3, 4, 5]); + expect(tapFn).toHaveBeenCalledTimes(1); + expect(tapFn).toHaveBeenCalledWith(3); + 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..3d098e8f3 --- /dev/null +++ b/libs/ngxtension/tapOnce/src/tap-once.ts @@ -0,0 +1,55 @@ +import { defer, MonoTypeOperatorFunction, Observable, tap } from 'rxjs'; + +/** + * Executes the provided function only once when the first truthy value is emitted. + * Uses `defer` to ensure state is unique per subscription. + * + * @param tapFn - Function to execute on the first truthy value. + * @returns MonoTypeOperatorFunction + */ +export function tapOnceOnFirstTruthy( + tapFn: (t: T) => void, +): MonoTypeOperatorFunction { + return (source$: Observable) => + defer(() => { + let firstTruthy = true; + return 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. + * Uses `defer` to track index without the overhead of `concatMap`. + * + * @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) => + defer(() => { + let currentIndex = 0; + return source$.pipe( + tap((value) => { + if (currentIndex === tapIndex) { + tapFn(value); + } + currentIndex++; + }), + ); + }); +}