diff --git a/docs/src/content/contributors/lorenzo-dianni.json b/docs/src/content/contributors/lorenzo-dianni.json new file mode 100644 index 00000000..faafa44a --- /dev/null +++ b/docs/src/content/contributors/lorenzo-dianni.json @@ -0,0 +1,6 @@ +{ + "name": "Lorenzo D'Ianni", + "twitter": "https://x.com/lorenzodianni", + "linkedin": "https://www.linkedin.com/in/lorenzo-d-ianni-3990bba6/", + "github": "https://github.com/lorenzodianni" +} diff --git a/docs/src/content/docs/utilities/Signals/effect-once-if.md b/docs/src/content/docs/utilities/Signals/effect-once-if.md new file mode 100644 index 00000000..faef50da --- /dev/null +++ b/docs/src/content/docs/utilities/Signals/effect-once-if.md @@ -0,0 +1,38 @@ +--- +title: effectOnceIf +description: ngxtension/effect-once-if +entryPoint: effect-once-if +badge: stable +contributors: ['lorenzo-dianni'] +--- + +`effectOnceIf` is a helper function that allows you to create an effect that will be executed only once if a certain condition occurs. + +## Usage + +```ts +@Component({}) +class Example { + count = signal(0); + + effectOnceIfRef = effectOnceIf( + // condition function: if it returns a truly value, the execution function will run + () => this.count() > 3, + // execution function: will run only once + (valueReturnedFromCondition, onCleanup) => { + console.log( + `triggered with value returned: ${valueReturnedFromCondition}`, + ); + onCleanup(() => console.log('cleanup')); + }, + ); +} + +// example.count.set(1); +// -> nothing happens +// example.count.set(4); +// -> log: triggered with value returned: true +// -> log: cleanup +// example.count.set(6); +// -> nothing happens +``` diff --git a/libs/ngxtension/effect-once-if/README.md b/libs/ngxtension/effect-once-if/README.md new file mode 100644 index 00000000..c9afcb9e --- /dev/null +++ b/libs/ngxtension/effect-once-if/README.md @@ -0,0 +1,3 @@ +# ngxtension/effect-once-if + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/effect-once-if`. diff --git a/libs/ngxtension/effect-once-if/ng-package.json b/libs/ngxtension/effect-once-if/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/effect-once-if/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/effect-once-if/project.json b/libs/ngxtension/effect-once-if/project.json new file mode 100644 index 00000000..3066336f --- /dev/null +++ b/libs/ngxtension/effect-once-if/project.json @@ -0,0 +1,27 @@ +{ + "name": "ngxtension/effect-once-if", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/effect-once-if/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["effect-once-if"], + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/libs/ngxtension/effect-once-if/src/effect-once-if.spec.ts b/libs/ngxtension/effect-once-if/src/effect-once-if.spec.ts new file mode 100644 index 00000000..9af97d03 --- /dev/null +++ b/libs/ngxtension/effect-once-if/src/effect-once-if.spec.ts @@ -0,0 +1,63 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { effectOnceIf } from './effect-once-if'; + +function createTestComponent(triggerValue: number) { + const log: string[] = []; + const logCleanup: string[] = []; + + @Component({ standalone: true, template: '' }) + class Example { + count = signal(0); + + ref = effectOnceIf( + () => this.count() === triggerValue, + (value, onCleanup) => { + log.push(`received ${triggerValue}: ${value}`); + onCleanup(() => { + logCleanup.push(`cleaning effect with condition ${triggerValue}`); + }); + }, + ); + } + + return { component: Example, log, logCleanup }; +} + +describe(effectOnceIf.name, () => { + it('should run effect once and cleanup', () => { + const test = createTestComponent(2); + const fixture = TestBed.createComponent(test.component); + fixture.detectChanges(); + expect(test.log).toEqual([]); + expect(test.logCleanup).toEqual([]); + + fixture.componentInstance.count.set(1); + fixture.detectChanges(); + expect(test.log).toEqual([]); + expect(test.logCleanup).toEqual([]); + + fixture.componentInstance.count.set(2); + fixture.detectChanges(); + expect(test.log).toEqual(['received 2: true']); + expect(test.logCleanup).toEqual(['cleaning effect with condition 2']); + + fixture.componentInstance.count.set(3); + fixture.detectChanges(); + expect(test.log).toEqual(['received 2: true']); + expect(test.logCleanup).toEqual(['cleaning effect with condition 2']); + }); + + it('should run effect once and cleanup on init', () => { + const test = createTestComponent(0); + const fixture = TestBed.createComponent(test.component); + fixture.detectChanges(); + expect(test.log).toEqual(['received 0: true']); + expect(test.logCleanup).toEqual(['cleaning effect with condition 0']); + + fixture.componentInstance.count.set(1); + fixture.detectChanges(); + expect(test.log).toEqual(['received 0: true']); + expect(test.logCleanup).toEqual(['cleaning effect with condition 0']); + }); +}); diff --git a/libs/ngxtension/effect-once-if/src/effect-once-if.ts b/libs/ngxtension/effect-once-if/src/effect-once-if.ts new file mode 100644 index 00000000..eaccb85f --- /dev/null +++ b/libs/ngxtension/effect-once-if/src/effect-once-if.ts @@ -0,0 +1,34 @@ +import { + CreateEffectOptions, + effect, + EffectCleanupRegisterFn, + EffectRef, + runInInjectionContext, + untracked, +} from '@angular/core'; +import { assertInjector } from 'ngxtension/assert-injector'; + +export function effectOnceIf( + condition: () => T, + execution: ( + valueFromCondition: NonNullable, + onCleanup: EffectCleanupRegisterFn, + ) => void, + options?: Omit, +): EffectRef { + const assertedInjector = assertInjector(effectOnceIf, options?.injector); + return runInInjectionContext(assertedInjector, () => { + const effectRef = effect((onCleanup) => { + const hasCondition = condition(); + if (hasCondition) { + untracked(() => execution(hasCondition, onCleanup)); + effectRef.destroy(); + } + }, options); + return effectRef; + }); +} + +export type EffectOnceIfConditionFn = Parameters>[0]; +export type EffectOnceIfExecutionFn = Parameters>[1]; +export type EffectOnceIfOptions = Parameters>[2]; diff --git a/libs/ngxtension/effect-once-if/src/index.ts b/libs/ngxtension/effect-once-if/src/index.ts new file mode 100644 index 00000000..a6c5950f --- /dev/null +++ b/libs/ngxtension/effect-once-if/src/index.ts @@ -0,0 +1 @@ +export * from './effect-once-if'; diff --git a/tsconfig.base.json b/tsconfig.base.json index d973a266..277fd787 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -68,6 +68,9 @@ "libs/ngxtension/derived-async/src/index.ts" ], "ngxtension/derived-from": ["libs/ngxtension/derived-from/src/index.ts"], + "ngxtension/effect-once-if": [ + "libs/ngxtension/effect-once-if/src/index.ts" + ], "ngxtension/filter-array": ["libs/ngxtension/filter-array/src/index.ts"], "ngxtension/filter-nil": ["libs/ngxtension/filter-nil/src/index.ts"], "ngxtension/gestures": ["libs/ngxtension/gestures/src/index.ts"],