diff --git a/README.md b/README.md index 5623ce32..683f9607 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ nx generate ngxtension:init | `computed-from` | [README](./libs/ngxtension/computed-from/README.md) | | `inject-destroy` | [README](./libs/ngxtension/inject-destroy/README.md) | | `connect` | [README](./libs/ngxtension/connect/README.md) | +| `createEffect` | [README](./libs/ngxtension/create-effect/README.md) | diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 516e288e..6797ac97 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -48,6 +48,7 @@ export default defineConfig({ }, { label: 'repeat', link: '/utilities/repeat' }, { label: 'resize', link: '/utilities/resize' }, + { label: 'createEffect', link: '/utilities/create-effect' }, ], }, ], diff --git a/docs/src/content/docs/utilities/create-effect.md b/docs/src/content/docs/utilities/create-effect.md new file mode 100644 index 00000000..110f2e84 --- /dev/null +++ b/docs/src/content/docs/utilities/create-effect.md @@ -0,0 +1,70 @@ +--- +title: createEffect +description: ngxtension/create-effect +--- + +`createEffect` is a standalone version of [NgRx ComponentStore Effect](https://ngrx.io/guide/component-store/effect) + +:::tip[From ComponentStore documentation] + +- Effects isolate side effects from components, allowing for more pure components that select state and trigger updates and/or effects in ComponentStore(s). +- Effects are Observables listening for the inputs and piping them through the "prescription". +- Those inputs can either be values or Observables of values. +- Effects perform tasks, which are synchronous or asynchronous. + +::: + +In short, `createEffect` creates a callable function that accepts some data (imperative) or some stream of data (declarative), or none at all. + +```ts +import { createEffect } from 'ngxtension/create-effect'; +``` + +## Usage + +```ts +@Component({}) +export class Some { + log = createEffect( + pipe( + map((value) => value * 2), + tap(console.log.bind(console, 'double is -->')) + ) + ); + + ngOnInit() { + // start the effect + this.log(interval(1000)); + } +} +``` + +### Injection Context + +`createEffect` accepts an optional `Injector` so we can call `createEffect` outside of an Injection Context. + +```ts +@Component({}) +export class Some { + // 1. setup an Input; we know that Input isn't resolved in constructor + @Input() multiplier = 2; + + // 2. grab the Injector + private injector = inject(Injector); + + ngOnInit() { + // 3. create log effect in ngOnInit; where Input is resolved + const log = createEffect( + pipe( + map((value) => value * this.multiplier), + tap(console.log.bind(console, 'multiply is -->')) + ), + // 4. pass in the injector + this.injector + ); + + // 5. start the effect + log(interval(1000)); + } +} +``` diff --git a/libs/local-plugin/.eslintrc.json b/libs/local-plugin/.eslintrc.json index fe6da7cc..41b3143a 100644 --- a/libs/local-plugin/.eslintrc.json +++ b/libs/local-plugin/.eslintrc.json @@ -18,14 +18,24 @@ "files": ["*.json"], "parser": "jsonc-eslint-parser", "rules": { - "@nx/dependency-checks": "error" + "@nx/nx-plugin-checks": [ + "error", + { + "ignoredDependencies": ["@nx/angular"] + } + ] } }, { "files": ["./package.json", "./generators.json"], "parser": "jsonc-eslint-parser", "rules": { - "@nx/nx-plugin-checks": "error" + "@nx/nx-plugin-checks": [ + "error", + { + "ignoredDependencies": ["@nx/angular"] + } + ] } } ] diff --git a/libs/local-plugin/generators.json b/libs/local-plugin/generators.json index bf11f091..e959efb1 100644 --- a/libs/local-plugin/generators.json +++ b/libs/local-plugin/generators.json @@ -4,6 +4,11 @@ "factory": "./src/generators/convert-entry-point-to-project/generator", "schema": "./src/generators/convert-entry-point-to-project/schema.json", "description": "convert-entry-point-to-project generator" + }, + "entry-point": { + "factory": "./src/generators/entry-point/generator", + "schema": "./src/generators/entry-point/schema.json", + "description": "entry-point generator" } } } diff --git a/libs/local-plugin/src/generators/entry-point/generator.spec.ts b/libs/local-plugin/src/generators/entry-point/generator.spec.ts new file mode 100644 index 00000000..55101e83 --- /dev/null +++ b/libs/local-plugin/src/generators/entry-point/generator.spec.ts @@ -0,0 +1,11 @@ +describe('entry-point generator', () => { + // let tree: Tree; + // + // beforeEach(() => { + // tree = createTreeWithEmptyWorkspace(); + // }); + + it('should run successfully', async () => { + expect(true).toBeTruthy(); + }); +}); diff --git a/libs/local-plugin/src/generators/entry-point/generator.ts b/libs/local-plugin/src/generators/entry-point/generator.ts new file mode 100644 index 00000000..7d803825 --- /dev/null +++ b/libs/local-plugin/src/generators/entry-point/generator.ts @@ -0,0 +1,18 @@ +import { librarySecondaryEntryPointGenerator } from '@nx/angular/generators'; +import type { GeneratorOptions as SecondaryEntryPointGeneratorOptions } from '@nx/angular/src/generators/library-secondary-entry-point/schema'; +import { formatFiles, Tree } from '@nx/devkit'; +import convertEntryPointToProjectGenerator from '../convert-entry-point-to-project/generator'; + +export async function entryPointGenerator( + tree: Tree, + options: SecondaryEntryPointGeneratorOptions +) { + await librarySecondaryEntryPointGenerator(tree, options); + await convertEntryPointToProjectGenerator(tree, { + name: options.name, + project: options.library, + }); + await formatFiles(tree); +} + +export default entryPointGenerator; diff --git a/libs/local-plugin/src/generators/entry-point/schema.json b/libs/local-plugin/src/generators/entry-point/schema.json new file mode 100644 index 00000000..3425095b --- /dev/null +++ b/libs/local-plugin/src/generators/entry-point/schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "LocalPluginEntryPoint", + "title": "Creates a secondary entry point for a library", + "description": "Creates a secondary entry point for an Angular publishable library.", + "type": "object", + "cli": "nx", + "properties": { + "name": { + "type": "string", + "description": "The name of the secondary entry point.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the secondary entry point?", + "pattern": "^[a-zA-Z].*$", + "x-priority": "important" + }, + "library": { + "type": "string", + "description": "The name of the library to create the secondary entry point for.", + "x-prompt": "What library would you like to create the secondary entry point for?", + "pattern": "^[a-zA-Z].*$", + "x-dropdown": "projects", + "x-priority": "important" + }, + "skipModule": { + "type": "boolean", + "description": "Skip generating a module for the secondary entry point.", + "default": false + } + }, + "additionalProperties": false, + "required": ["name", "library"] +} diff --git a/libs/local-plugin/src/index.ts b/libs/local-plugin/src/index.ts index e69de29b..7c84a196 100644 --- a/libs/local-plugin/src/index.ts +++ b/libs/local-plugin/src/index.ts @@ -0,0 +1,2 @@ +export { default as convertEntryPointToProjectGenerator } from './generators/convert-entry-point-to-project/generator'; +export { default as entryPointGenerator } from './generators/entry-point/generator'; diff --git a/libs/ngxtension/assert-injector/project.json b/libs/ngxtension/assert-injector/project.json index 9f6325e3..abf9b960 100644 --- a/libs/ngxtension/assert-injector/project.json +++ b/libs/ngxtension/assert-injector/project.json @@ -9,6 +9,7 @@ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["assert-injector"], "passWithNoTests": true }, "configurations": { diff --git a/libs/ngxtension/computed-from/project.json b/libs/ngxtension/computed-from/project.json index 6bc24105..715c252b 100644 --- a/libs/ngxtension/computed-from/project.json +++ b/libs/ngxtension/computed-from/project.json @@ -9,6 +9,7 @@ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["computed-from"], "passWithNoTests": true }, "configurations": { diff --git a/libs/ngxtension/connect/project.json b/libs/ngxtension/connect/project.json index 7c526dd8..3d757adb 100644 --- a/libs/ngxtension/connect/project.json +++ b/libs/ngxtension/connect/project.json @@ -9,6 +9,7 @@ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["connect"], "passWithNoTests": true }, "configurations": { diff --git a/libs/ngxtension/create-effect/README.md b/libs/ngxtension/create-effect/README.md new file mode 100644 index 00000000..825bdac0 --- /dev/null +++ b/libs/ngxtension/create-effect/README.md @@ -0,0 +1,3 @@ +# ngxtension/create-effect + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/create-effect`. diff --git a/libs/ngxtension/create-effect/ng-package.json b/libs/ngxtension/create-effect/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/create-effect/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/create-effect/project.json b/libs/ngxtension/create-effect/project.json new file mode 100644 index 00000000..8e115b03 --- /dev/null +++ b/libs/ngxtension/create-effect/project.json @@ -0,0 +1,33 @@ +{ + "name": "ngxtension/create-effect", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/create-effect/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["create-effect"], + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/ngxtension/create-effect/**/*.ts", + "libs/ngxtension/create-effect/**/*.html" + ] + } + } + } +} diff --git a/libs/ngxtension/create-effect/src/create-effect.spec.ts b/libs/ngxtension/create-effect/src/create-effect.spec.ts new file mode 100644 index 00000000..6b3da2df --- /dev/null +++ b/libs/ngxtension/create-effect/src/create-effect.spec.ts @@ -0,0 +1,36 @@ +import { Component } from '@angular/core'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { interval, tap } from 'rxjs'; +import { createEffect } from './create-effect'; + +describe(createEffect.name, () => { + @Component({ + standalone: true, + template: '', + }) + class Foo { + count = 0; + log = createEffect(tap(() => (this.count += 1))); + + ngOnInit() { + this.log(interval(1000)); + } + } + + it('should run until component is destroyed', fakeAsync(() => { + const fixture = TestBed.createComponent(Foo); + const component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.count).toEqual(0); + + tick(1000); + expect(component.count).toEqual(1); + + tick(1000); + expect(component.count).toEqual(2); + + fixture.destroy(); + tick(1000); + expect(component.count).toEqual(2); + })); +}); diff --git a/libs/ngxtension/create-effect/src/create-effect.ts b/libs/ngxtension/create-effect/src/create-effect.ts new file mode 100644 index 00000000..e0deb383 --- /dev/null +++ b/libs/ngxtension/create-effect/src/create-effect.ts @@ -0,0 +1,68 @@ +import { + DestroyRef, + Injector, + inject, + runInInjectionContext, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { assertInjector } from 'ngxtension/assert-injector'; +import { + Observable, + Subject, + Subscription, + isObservable, + of, + retry, +} from 'rxjs'; + +/** + * This code is a copied `ComponentStore.effect()` method from NgRx and edited to: + * 1) be a standalone function; + * 2) use `takeUntilDestroyed()` with an injected `DestroyRef`; + * 3) resubscribe on errors. + * + * Credits: NgRx Team + * https://ngrx.io/ + * Source: https://github.com/ngrx/platform/blob/main/modules/component-store/src/component-store.ts#L382 + * Docs: + * https://ngrx.io/guide/component-store/effect#effect-method + */ +export function createEffect< + ProvidedType = void, + OriginType extends + | Observable + | unknown = Observable, + ObservableType = OriginType extends Observable ? A : never, + ReturnType = ProvidedType | ObservableType extends void + ? ( + observableOrValue?: ObservableType | Observable + ) => Subscription + : ( + observableOrValue: ObservableType | Observable + ) => Subscription +>( + generator: (origin$: OriginType) => Observable, + injector?: Injector +): ReturnType { + injector = assertInjector(createEffect, injector); + return runInInjectionContext(injector, () => { + const destroyRef = inject(DestroyRef); + const origin$ = new Subject(); + generator(origin$ as OriginType) + .pipe(retry(), takeUntilDestroyed(destroyRef)) + .subscribe(); + + return (( + observableOrValue?: ObservableType | Observable + ): Subscription => { + const observable$ = isObservable(observableOrValue) + ? observableOrValue.pipe(retry()) + : of(observableOrValue); + return observable$ + .pipe(takeUntilDestroyed(destroyRef)) + .subscribe((value) => { + origin$.next(value as ObservableType); + }); + }) as unknown as ReturnType; + }); +} diff --git a/libs/ngxtension/create-effect/src/index.ts b/libs/ngxtension/create-effect/src/index.ts new file mode 100644 index 00000000..77878cbd --- /dev/null +++ b/libs/ngxtension/create-effect/src/index.ts @@ -0,0 +1 @@ +export * from './create-effect'; diff --git a/libs/ngxtension/create-injection-token/project.json b/libs/ngxtension/create-injection-token/project.json index 24d2925c..ae2caf9d 100644 --- a/libs/ngxtension/create-injection-token/project.json +++ b/libs/ngxtension/create-injection-token/project.json @@ -9,6 +9,7 @@ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["create-injection-token"], "passWithNoTests": true }, "configurations": { diff --git a/libs/ngxtension/inject-destroy/project.json b/libs/ngxtension/inject-destroy/project.json index 35cd5547..2cc2d592 100644 --- a/libs/ngxtension/inject-destroy/project.json +++ b/libs/ngxtension/inject-destroy/project.json @@ -9,6 +9,7 @@ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["inject-destroy"], "passWithNoTests": true }, "configurations": { diff --git a/libs/ngxtension/repeat/project.json b/libs/ngxtension/repeat/project.json index 90a76fdc..dfd0bc04 100644 --- a/libs/ngxtension/repeat/project.json +++ b/libs/ngxtension/repeat/project.json @@ -9,6 +9,7 @@ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["repeat"], "passWithNoTests": true }, "configurations": { diff --git a/libs/ngxtension/resize/project.json b/libs/ngxtension/resize/project.json index 596cb97a..c7c06aa6 100644 --- a/libs/ngxtension/resize/project.json +++ b/libs/ngxtension/resize/project.json @@ -9,6 +9,7 @@ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["resize"], "passWithNoTests": true }, "configurations": { diff --git a/tsconfig.base.json b/tsconfig.base.json index b067fce0..948e5b92 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,9 @@ "libs/ngxtension/computed-from/src/index.ts" ], "ngxtension/connect": ["libs/ngxtension/connect/src/index.ts"], + "ngxtension/create-effect": [ + "libs/ngxtension/create-effect/src/index.ts" + ], "ngxtension/create-injection-token": [ "libs/ngxtension/create-injection-token/src/index.ts" ],