Skip to content

Commit

Permalink
#151 feat(rx-effect): implement the function to handle rxjs effects (#…
Browse files Browse the repository at this point in the history
…163)

Co-authored-by: lgarcia <lgarcia@kwfrance.com>
  • Loading branch information
LcsGa and lgarcia authored Nov 19, 2023
1 parent 7df93d3 commit ede1c48
Show file tree
Hide file tree
Showing 8 changed files with 277 additions and 0 deletions.
113 changes: 113 additions & 0 deletions docs/src/content/docs/utilities/Operators/rx-effect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
title: rxEffect
description: ngxtension/rx-effect
---

`rxEffect` is a utility function that helps you create a side effect with rxjs, returning an already well handled `Subscription` with `takeUntilDestroyed` within it.

The effect logic can either:

- be set as the second argument as a [`TapObserver`](https://rxjs.dev/api/index/interface/TapObserver) or `next` function
- or be handled directly within the source (less encouraged but you could need to do that if your effect needs to be within a `switchMap` or similar)

| arguments | type | description |
| ----------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `source` | `Observable<T>` | Any `Observable` that will be subscribed to, to execute the side effect |
| `effectOrOptions` | `TapObserver<T> \| ((value: T) => void) \| { destroyRef: DestroyRef }` | Optional. Default is `undefined`.<br>A next handler, a partial observer or an options object. |
| `options` | `{ destroyRef: DestroyRef }` | Optional. Default is `undefined`.<br>An options object there to provide a `DestroyRef` if need. |

```ts
import { rxEffect } from 'ngxtension/rx-effect';
```

## Usage

With the `next` function

```ts
@Component({
standalone: true,
imports: [ReactiveFormsModule],
selector: 'app-root',
template: `
<form [formGroup]="user">
<input type="text" formControlName="firstName" />
<input type="text" formControlName="lastName" />
</form>
`,
})
export class Form {
readonly user = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl({ value: '', disabled: true }),
});

readonly #toggleLastNameAccess = rxEffect(this.user.controls.firstName.valueChanges, (firstName) => {
if (firstName) this.user.controls.lastName.enable();
else this.user.controls.lastName.disable();
});
}
```

With a `TapObserver`

```ts
@Component({
standalone: true,
imports: [ReactiveFormsModule],
selector: 'app-root',
template: `
<form [formGroup]="user">
<input type="text" formControlName="firstName" />
<input type="text" formControlName="lastName" />
</form>
`,
})
export class Form {
readonly user = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl({ value: '', disabled: true }),
});

readonly #toggleLastNameAccess = rxEffect(this.user.controls.firstName.valueChanges, {
next: (firstName) => {
if (firstName) this.user.controls.lastName.enable();
else this.user.controls.lastName.disable();
},
});
}
```

With the effect handled directly within the source

```ts
@Component({
standalone: true,
imports: [ReactiveFormsModule],
selector: 'app-root',
template: `
<form [formGroup]="user">
<input type="text" formControlName="firstName" />
<input type="text" formControlName="lastName" />
</form>
`,
})
export class Form {
readonly #userService = injec(UserService);

readonly user = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl(''),
});

readonly #saveChangesOnTheFly = rxEffect(
this.user.valueChanges.pipe(
debounceTime(500),
switchMap((user) => this.userService.save(user))
)
);
}
```
3 changes: 3 additions & 0 deletions libs/ngxtension/rx-effect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/rx-effect

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/rx-effect`.
5 changes: 5 additions & 0 deletions libs/ngxtension/rx-effect/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
33 changes: 33 additions & 0 deletions libs/ngxtension/rx-effect/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "ngxtension/rx-effect",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/ngxtension/rx-effect/src",
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ngxtension/jest.config.ts",
"testPathPattern": ["rx-effect"],
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/ngxtension/rx-effect/**/*.ts",
"libs/ngxtension/rx-effect/**/*.html"
]
}
}
}
}
1 change: 1 addition & 0 deletions libs/ngxtension/rx-effect/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './rx-effect';
80 changes: 80 additions & 0 deletions libs/ngxtension/rx-effect/src/rx-effect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NEVER, of } from 'rxjs';
import { rxEffect } from './rx-effect';

describe(rxEffect.name, () => {
it('should unsubscribe when the component gets destroyed', () => {
let status: `${'' | 'un'}subscribed`;

@Component({
standalone: true,
selector: 'child',
template: '',
})
class Child {
readonly effect = rxEffect(NEVER, {
subscribe: () => (status = 'subscribed'),
unsubscribe: () => (status = 'unsubscribed'),
});
}

@Component({
standalone: true,
imports: [Child],
template: `
@if (display()) {
<child />
}
`,
})
class Parent {
readonly display = signal(true);
}

const fixture = TestBed.createComponent(Parent);
fixture.detectChanges();

expect(status!).toEqual('subscribed');

fixture.componentInstance.display.set(false);
fixture.detectChanges();

expect(status!).toEqual('unsubscribed');
});

it('should manually unsubsribe from the source', () => {
@Component({
standalone: true,
template: '',
})
class Elem {
readonly effect = rxEffect(NEVER);
}

const fixture = TestBed.createComponent(Elem);
const effect = fixture.componentInstance.effect;

expect(effect.closed).toEqual(false);

effect.unsubscribe();
expect(effect.closed).toEqual(true);
});

it('should execute the side effect, then complete', () => {
let result: string;
const expected = 'hello world';

@Component({
standalone: true,
template: '',
})
class Elem {
readonly effect = rxEffect(of(expected), (value) => (result = value));
}

const fixture = TestBed.createComponent(Elem);
expect(result!).toEqual(expected);
expect(fixture.componentInstance.effect.closed).toEqual(true);
});
});
41 changes: 41 additions & 0 deletions libs/ngxtension/rx-effect/src/rx-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
identity,
Observable,
Subscription,
tap,
type TapObserver,
} from 'rxjs';

type Effect<T> = Partial<TapObserver<T>> | ((value: T) => void);
type RxEffectOptions = { destroyRef: DestroyRef };

export function rxEffect<T>(
source: Observable<T>,
effect: Effect<T>,
options?: RxEffectOptions
): Subscription;
export function rxEffect<T>(
source: Observable<T>,
options?: RxEffectOptions
): Subscription;
export function rxEffect<T>(
source: Observable<T>,
effectOrOptions?: Effect<T> | RxEffectOptions,
options?: RxEffectOptions
) {
const effect =
effectOrOptions && 'destroyRef' in effectOrOptions
? undefined
: effectOrOptions;

options ??= effect ? options : (effectOrOptions as RxEffectOptions);

return source
.pipe(
effect ? tap(effect) : identity,
takeUntilDestroyed(options?.destroyRef)
)
.subscribe();
}
1 change: 1 addition & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
],
"ngxtension/repeat": ["libs/ngxtension/repeat/src/index.ts"],
"ngxtension/resize": ["libs/ngxtension/resize/src/index.ts"],
"ngxtension/rx-effect": ["libs/ngxtension/rx-effect/src/index.ts"],
"ngxtension/signal-slice": ["libs/ngxtension/signal-slice/src/index.ts"],
"ngxtension/singleton-proxy": [
"libs/ngxtension/singleton-proxy/src/index.ts"
Expand Down

0 comments on commit ede1c48

Please sign in to comment.