diff --git a/libs/ngxtension/debounce-signal/README.md b/libs/ngxtension/debounce-signal/README.md new file mode 100644 index 000000000..38c8523de --- /dev/null +++ b/libs/ngxtension/debounce-signal/README.md @@ -0,0 +1,3 @@ +# ngxtension/debounce-signal + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/debounce-signal`. diff --git a/libs/ngxtension/debounce-signal/ng-package.json b/libs/ngxtension/debounce-signal/ng-package.json new file mode 100644 index 000000000..b3e53d699 --- /dev/null +++ b/libs/ngxtension/debounce-signal/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/debounce-signal/project.json b/libs/ngxtension/debounce-signal/project.json new file mode 100644 index 000000000..776f6c628 --- /dev/null +++ b/libs/ngxtension/debounce-signal/project.json @@ -0,0 +1,20 @@ +{ + "name": "ngxtension/debounce-signal", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/debounce-signal/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["debounce-signal"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/libs/ngxtension/debounce-signal/src/debounce-signal.spec.ts b/libs/ngxtension/debounce-signal/src/debounce-signal.spec.ts new file mode 100644 index 000000000..3f2e672b4 --- /dev/null +++ b/libs/ngxtension/debounce-signal/src/debounce-signal.spec.ts @@ -0,0 +1,178 @@ +import { TestBed } from '@angular/core/testing'; +import { debounceSignal } from './debounce-signal'; + +describe(debounceSignal.name, () => { + describe('data types', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('number', () => { + TestBed.runInInjectionContext(() => { + const s = debounceSignal(1, 300); + + expect(s()).toEqual(1); + s.set(2); + expect(s()).toEqual(1); + + jest.advanceTimersByTime(301); + expect(s()).toEqual(2); + }); + }); + + it('multiple changes number', () => { + TestBed.runInInjectionContext(() => { + const s = debounceSignal(1, 300); + + expect(s()).toEqual(1); + s.set(2); + s.set(3); + s.set(4); + expect(s()).toEqual(1); + + jest.advanceTimersByTime(301); + expect(s()).toEqual(4); + }); + }); + + it('string', () => { + TestBed.runInInjectionContext(() => { + const s = debounceSignal('1', 300); + + expect(s()).toEqual('1'); + s.set('2'); + expect(s()).toEqual('1'); + + jest.advanceTimersByTime(301); + expect(s()).toEqual('2'); + }); + }); + + it('multiple changes string', () => { + TestBed.runInInjectionContext(() => { + const s = debounceSignal('1', 300); + + expect(s()).toEqual('1'); + s.set('2'); + s.set('3'); + s.set('4'); + expect(s()).toEqual('1'); + + jest.advanceTimersByTime(301); + expect(s()).toEqual('4'); + }); + }); + + it('should handle immediate updates followed by debounced updates', () => { + TestBed.runInInjectionContext(() => { + const s = debounceSignal(1, 300); + expect(s()).toBe(1); + + s.set(2); + expect(s()).toBe(1); + + jest.advanceTimersByTime(150); + s.set(3); + expect(s()).toBe(1); + + jest.advanceTimersByTime(151); + expect(s()).toBe(1); + + jest.advanceTimersByTime(150); + expect(s()).toBe(3); + }); + }); + + it('object', () => { + TestBed.runInInjectionContext(() => { + const initialObject = { a: 1, b: { c: 2 } }; + const s = debounceSignal(initialObject, 300); + + expect(s()).toEqual(initialObject); + + const updatedObject = { a: 2, b: { c: 3 } }; + s.set(updatedObject); + expect(s()).toEqual(initialObject); + + jest.advanceTimersByTime(301); + expect(s()).toEqual(updatedObject); + }); + }); + + it('array', () => { + TestBed.runInInjectionContext(() => { + const initialArray = [1, 2, 3]; + const s = debounceSignal(initialArray, 300); + + expect(s()).toEqual(initialArray); + + const updatedArray = [4, 5, 6]; + s.set(updatedArray); + expect(s()).toEqual(initialArray); + + jest.advanceTimersByTime(301); + expect(s()).toEqual(updatedArray); + }); + }); + + it('nested object property', () => { + TestBed.runInInjectionContext(() => { + const initialObject = { a: 1, b: { c: 2 } }; + const s = debounceSignal(initialObject, 300); + + expect(s().b.c).toEqual(2); + + s.update((val) => ({ ...val, b: { ...val.b, c: 3 } })); + + expect(s().b.c).toEqual(2); + + jest.advanceTimersByTime(301); + expect(s().b.c).toEqual(3); + }); + }); + + it('multiple changes to nested object property', () => { + TestBed.runInInjectionContext(() => { + const initialObject = { a: 1, b: { c: 2 } }; + const s = debounceSignal(initialObject, 300); + + expect(s().b.c).toEqual(2); + + s.update((val) => ({ ...val, b: { ...val.b, c: 3 } })); + s.update((val) => ({ ...val, b: { ...val.b, c: 4 } })); + s.update((val) => ({ ...val, b: { ...val.b, c: 5 } })); + + expect(s().b.c).toEqual(2); + + jest.advanceTimersByTime(301); + expect(s().b.c).toEqual(5); + }); + }); + + it('No support change the internal object but changes work', () => { + TestBed.runInInjectionContext(() => { + const initialObject = { a: 1, b: { c: 2 } }; + const s = debounceSignal(initialObject, 300); + + initialObject.b.c = 5; + expect(s().b.c).toEqual(5); + + s.update((x) => { + x.b.c = 100; + return x; + }); + + expect(s().b.c).toEqual(5); + + jest.advanceTimersByTime(301); + + expect(s().b.c).toEqual(100); + }); + }); + }); +}); diff --git a/libs/ngxtension/debounce-signal/src/debounce-signal.ts b/libs/ngxtension/debounce-signal/src/debounce-signal.ts new file mode 100644 index 000000000..8b8ac3020 --- /dev/null +++ b/libs/ngxtension/debounce-signal/src/debounce-signal.ts @@ -0,0 +1,37 @@ +import { CreateSignalOptions, WritableSignal } from '@angular/core'; +import { + SIGNAL, + SignalGetter, + signalSetFn, + signalUpdateFn, +} from '@angular/core/primitives/signals'; +import { createSignal } from 'ngxtension/create-signal'; + +export function debounceSignal( + initialValue: T, + time: number, + options?: CreateSignalOptions, +): WritableSignal { + const signalFn = createSignal(initialValue) as SignalGetter & + WritableSignal; + const node = signalFn[SIGNAL]; + if (options?.equal) { + node.equal = options.equal; + } + + let timeoutId: ReturnType | undefined; + + signalFn.set = (newValue: T) => { + clearTimeout(timeoutId); + + timeoutId = setTimeout(() => signalSetFn(node, newValue), time); + }; + + signalFn.update = (updateFn: (value: T) => T) => { + clearTimeout(timeoutId); + + timeoutId = setTimeout(() => signalUpdateFn(node, updateFn), time); + }; + + return signalFn; +} diff --git a/libs/ngxtension/debounce-signal/src/index.ts b/libs/ngxtension/debounce-signal/src/index.ts new file mode 100644 index 000000000..43db21723 --- /dev/null +++ b/libs/ngxtension/debounce-signal/src/index.ts @@ -0,0 +1 @@ +export * from './debounce-signal';