Skip to content

Commit 561bdf5

Browse files
committed
feat: tap once
1 parent 648f7c6 commit 561bdf5

File tree

7 files changed

+224
-0
lines changed

7 files changed

+224
-0
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
````markdown
2+
---
3+
title: tapOnce / tapOnceOnFirstTruthy
4+
description: Standalone RxJS operators for executing functions conditionally on emitted values.
5+
entryPoint: ngxtension/tap-once
6+
badge: stable
7+
contributors: ['andreas-dorner']
8+
---
9+
10+
## Import
11+
12+
```typescript
13+
import { tapOnce, tapOnceOnFirstTruthy } from 'ngxtension/tap-once';
14+
```
15+
````
16+
17+
## Usage
18+
19+
### tapOnce
20+
21+
Executes the provided function only once when the value at the specified index is emitted.
22+
23+
```typescript
24+
import { from } from 'rxjs';
25+
import { tapOnce } from 'ngxtension/tap-once';
26+
27+
const in$ = from([1, 2, 3, 4, 5]);
28+
const out$ = in$.pipe(tapOnce((value) => console.log(value), 2));
29+
30+
out$.subscribe(); // logs: 3
31+
```
32+
33+
#### Parameters
34+
35+
- `tapFn`: Function to execute on the value at the specified index.
36+
- `tapIndex`: Index at which to execute the function (default is 0).
37+
38+
### tapOnceOnFirstTruthy
39+
40+
Executes the provided function only once when the first truthy value is emitted.
41+
42+
```typescript
43+
import { from } from 'rxjs';
44+
import { tapOnceOnFirstTruthy } from 'ngxtension/tap-once';
45+
46+
const in$ = from([0, null, false, 3, 4, 5]);
47+
const out$ = in$.pipe(tapOnceOnFirstTruthy((value) => console.log(value)));
48+
49+
out$.subscribe(); // logs: 3
50+
```
51+
52+
#### Parameters
53+
54+
- `tapFn`: Function to execute on the first truthy value.
55+
56+
## API
57+
58+
### tapOnce
59+
60+
- `tapFn: (t: T) => void`
61+
- `tapIndex: number = 0`
62+
63+
### tapOnceOnFirstTruthy
64+
65+
- `tapFn: (t: T) => void`
66+
67+
### Validation
68+
69+
- Throws an error if `tapIndex` is negative.
70+
71+
```
72+
73+
```

libs/ngxtension/tapOnce/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# ngxtension/tap-once
2+
3+
Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/tap-once`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "src/index.ts"
4+
}
5+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "ngxtension/tap-once",
3+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
4+
"projectType": "library",
5+
"sourceRoot": "libs/ngxtension/tap-once/src",
6+
"targets": {
7+
"test": {
8+
"executor": "@nx/jest:jest",
9+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
10+
"options": {
11+
"jestConfig": "libs/ngxtension/jest.config.ts",
12+
"testPathPattern": ["tap-once"]
13+
}
14+
},
15+
"lint": {
16+
"executor": "@nx/eslint:lint",
17+
"outputs": ["{options.outputFile}"]
18+
}
19+
}
20+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './tap-once';
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { from, toArray } from 'rxjs';
2+
import { tapOnce, tapOnceOnFirstTruthy } from './tap-once';
3+
4+
describe(tapOnce.name, () => {
5+
it('should execute the function only once at the specified index', (done) => {
6+
const tapFn = jest.fn();
7+
const in$ = from([1, 2, 3, 4, 5]);
8+
const out$ = in$.pipe(tapOnce(tapFn, 2));
9+
10+
out$.pipe(toArray()).subscribe((r) => {
11+
expect(r).toEqual([1, 2, 3, 4, 5]);
12+
expect(tapFn).toHaveBeenCalledTimes(1);
13+
expect(tapFn).toHaveBeenCalledWith(3);
14+
done();
15+
});
16+
});
17+
18+
it('should execute the function only once at the default index 0', (done) => {
19+
const tapFn = jest.fn();
20+
const in$ = from([1, 2, 3, 4, 5]);
21+
const out$ = in$.pipe(tapOnce(tapFn));
22+
23+
out$.pipe(toArray()).subscribe((r) => {
24+
expect(r).toEqual([1, 2, 3, 4, 5]);
25+
expect(tapFn).toHaveBeenCalledTimes(1);
26+
expect(tapFn).toHaveBeenCalledWith(1);
27+
done();
28+
});
29+
});
30+
31+
it('should throw an error if tapIndex is negative', () => {
32+
expect(() => tapOnce(() => {}, -1)).toThrow(
33+
'tapIndex must be a non-negative integer',
34+
);
35+
});
36+
});
37+
38+
describe(tapOnceOnFirstTruthy.name, () => {
39+
it('should execute the function only once on the first truthy value', (done) => {
40+
const tapFn = jest.fn();
41+
const in$ = from([0, null, false, 3, 4, 5]);
42+
const out$ = in$.pipe(tapOnceOnFirstTruthy(tapFn));
43+
44+
out$.pipe(toArray()).subscribe((r) => {
45+
expect(r).toEqual([0, null, false, 3, 4, 5]);
46+
expect(tapFn).toHaveBeenCalledTimes(1);
47+
expect(tapFn).toHaveBeenCalledWith(3);
48+
done();
49+
});
50+
});
51+
52+
it('should not execute the function if there are no truthy values', (done) => {
53+
const tapFn = jest.fn();
54+
const in$ = from([0, null, false, undefined]);
55+
const out$ = in$.pipe(tapOnceOnFirstTruthy(tapFn));
56+
57+
out$.pipe(toArray()).subscribe((r) => {
58+
expect(r).toEqual([0, null, false, undefined]);
59+
expect(tapFn).not.toHaveBeenCalled();
60+
done();
61+
});
62+
});
63+
64+
it('should execute the function only once even if there are multiple truthy values', (done) => {
65+
const tapFn = jest.fn();
66+
const in$ = from([1, 2, 3, 4, 5]);
67+
const out$ = in$.pipe(tapOnceOnFirstTruthy(tapFn));
68+
69+
out$.pipe(toArray()).subscribe((r) => {
70+
expect(r).toEqual([1, 2, 3, 4, 5]);
71+
expect(tapFn).toHaveBeenCalledTimes(1);
72+
expect(tapFn).toHaveBeenCalledWith(1);
73+
done();
74+
});
75+
});
76+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { MonoTypeOperatorFunction, concatMap, of, type Observable } from 'rxjs';
2+
import { tap } from 'rxjs/operators';
3+
4+
/**
5+
* Executes the provided function only once when the first truthy value is emitted.
6+
* @param tapFn - Function to execute on the first truthy value.
7+
* @returns MonoTypeOperatorFunction
8+
*/
9+
export function tapOnceOnFirstTruthy<T>(
10+
tapFn: (t: T) => void,
11+
): MonoTypeOperatorFunction<T> {
12+
let firstTruthy = true;
13+
return (source$: Observable<T>) =>
14+
source$.pipe(
15+
tap((value) => {
16+
if (firstTruthy && !!value) {
17+
tapFn(value);
18+
firstTruthy = false;
19+
}
20+
}),
21+
);
22+
}
23+
24+
/**
25+
* Executes the provided function only once when the value at the specified index is emitted.
26+
* @param tapFn - Function to execute on the value at the specified index.
27+
* @param tapIndex - Index at which to execute the function (default is 0).
28+
* @returns MonoTypeOperatorFunction
29+
*/
30+
export function tapOnce<T>(
31+
tapFn: (t: T) => void,
32+
tapIndex = 0,
33+
): MonoTypeOperatorFunction<T> {
34+
if (tapIndex < 0) {
35+
throw new Error('tapIndex must be a non-negative integer');
36+
}
37+
return (source$: Observable<T>) =>
38+
source$.pipe(
39+
concatMap((value, index) => {
40+
if (index === tapIndex) {
41+
tapFn(value);
42+
}
43+
return of(value);
44+
}),
45+
);
46+
}

0 commit comments

Comments
 (0)