From 27f6bff9ab2222a4698329c56d66e5c6b2947f65 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Sun, 10 Mar 2024 14:29:52 +0100 Subject: [PATCH 01/10] feat: immerPatchState --- package.json | 2 +- src/package.json | 2 +- src/shared/index.ts | 2 +- src/signals/immer-patch-state.ts | 19 +++++++++++++++++++ src/signals/index.ts | 4 +--- src/signals/tests/immer-patch-state.spec.ts | 5 +++++ src/signals/tests/placeholder.test.ts | 9 --------- 7 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 src/signals/immer-patch-state.ts create mode 100644 src/signals/tests/immer-patch-state.spec.ts delete mode 100644 src/signals/tests/placeholder.test.ts diff --git a/package.json b/package.json index 98bf336..ec8679a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@angular/compiler-cli": "^17.0.0", "@angular/core": "^17.0.0", "@ngrx/component-store": "^17.0.0", - "@ngrx/signals": "^17.0.0", + "@ngrx/signals": "^17.1.1", "@ngrx/store": "^17.0.0", "cpy-cli": "^5.0.0", "immer": "^10.0.3", diff --git a/src/package.json b/src/package.json index 5677c01..5369c09 100644 --- a/src/package.json +++ b/src/package.json @@ -30,7 +30,7 @@ "immer": ">=7.0.0", "@ngrx/component-store": ">=13.0.0", "@ngrx/store": ">=13.0.0", - "@ngrx/signals": ">=17.0.0" + "@ngrx/signals": ">=17.1.1" }, "peerDependenciesMeta": { "@ngrx/component-store": { diff --git a/src/shared/index.ts b/src/shared/index.ts index e8fd86c..ae81653 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -4,7 +4,7 @@ import { produce } from 'immer'; * Helper method that wraps a reducer with the Immer `produce` method * Kudos to Alex Okrushko {@link https://lookout.dev/rules/simple-immer-base-function-to-be-used-in-ngrx-store-or-componentstore-for-transforming-data-%22mutably%22} */ -export function immerReducer( +export function immerReducer( callback: (state: State, next: Next) => State | void, ) { return (state: State | undefined, next: Next) => { diff --git a/src/signals/immer-patch-state.ts b/src/signals/immer-patch-state.ts new file mode 100644 index 0000000..f308940 --- /dev/null +++ b/src/signals/immer-patch-state.ts @@ -0,0 +1,19 @@ +import { PartialStateUpdater, patchState, signalStore, StateSignal, withState } from '@ngrx/signals'; +import { immerReducer } from 'ngrx-immer'; + +const UserState = signalStore(withState({id: 1, name: {firstname: 'Konrad', lastname: 'Schultz'}, address: {city: 'Vienna', zip: '1010'}})); +const userState = new UserState(); + +function toFullStateUpdater(updater: PartialStateUpdater) + +export function immerPatchState(state: StateSignal, ...updaters: Array | PartialStateUpdater>) { + const immerUpdaters = updaters.map(updater => { + if (typeof updater === 'function') { +immerReducer(state, updater); + } + return typeof updater === 'function' ? immerReducer(updater) : updater; + }) +patchState(state, immerUpdaters); +} + +immerPatchState(userState, {nummer: 1}) \ No newline at end of file diff --git a/src/signals/index.ts b/src/signals/index.ts index 6265daa..2140294 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -1,3 +1 @@ -export function placeholder() { - return 'placeholder'; -} \ No newline at end of file +export {immerPatchState} from './immer-patch-state' \ No newline at end of file diff --git a/src/signals/tests/immer-patch-state.spec.ts b/src/signals/tests/immer-patch-state.spec.ts new file mode 100644 index 0000000..2fc67dc --- /dev/null +++ b/src/signals/tests/immer-patch-state.spec.ts @@ -0,0 +1,5 @@ +// normal update +// updater +// partial state updater +// multiple updaters +// test that no changes, do not trigger \ No newline at end of file diff --git a/src/signals/tests/placeholder.test.ts b/src/signals/tests/placeholder.test.ts deleted file mode 100644 index aca2ab3..0000000 --- a/src/signals/tests/placeholder.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { test } from 'uvu'; -import * as assert from 'uvu/assert'; - -import { placeholder } from 'ngrx-immer/signals'; - -test('placeholder', () => { - assert.is(placeholder(), 'placeholder'); -}); -test.run(); From 38bd782098bf4a6dbb826745a9bde1ee83b2d7f4 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 12 Mar 2024 12:28:48 +0100 Subject: [PATCH 02/10] feat: add `immerPatchState` `immerPatchState` adds mutable changes to the Signal Store. It requires at least version 17.1.1 since it needs to have access to `SignalState`. --- README.md | 35 +++++ jest.config.js | 14 ++ package.json | 5 +- setup-jest.ts | 1 + src/shared/index.ts | 2 +- src/signals/immer-patch-state.ts | 19 --- src/signals/index.ts | 25 +++- src/signals/tests/immer-patch-state.jest.ts | 145 ++++++++++++++++++++ src/signals/tests/immer-patch-state.spec.ts | 5 - tsconfig.jest.json | 9 ++ 10 files changed, 233 insertions(+), 27 deletions(-) create mode 100644 jest.config.js create mode 100644 setup-jest.ts delete mode 100644 src/signals/immer-patch-state.ts create mode 100644 src/signals/tests/immer-patch-state.jest.ts delete mode 100644 src/signals/tests/immer-patch-state.spec.ts create mode 100644 tsconfig.jest.json diff --git a/README.md b/README.md index a6c2f66..cd1abef 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,41 @@ export class MoviesStore extends ImmerComponentStore { } ``` +## `immerPatchState` + +Provides an Immer-version of the Signal Store's `patchState`. It adds an additional updater function +which can mutate the state. + +```ts +const UserState = signalStore(withState({ + id: 1, + name: { firstname: 'Konrad', lastname: 'Schultz' }, + address: { city: 'Vienna', zip: '1010' }, +}), withComputed(({ name }) => ({ prettyName: computed(() => `${name.firstname()} ${name.lastname()}`) }))); + +const userState = new UserState(); + +immerPatchState(userState, (state => { + state.name = { firstname: 'Lucy', lastname: 'Sanders' }; + state.address.zip = '1020' +})); +``` + +Please note, that the updater function can only mutate a change without returning it or return an immutable +state without mutable change. + +This one is going to throw a runtime error: + +```ts +// will throw because of both returning and mutable change +immerPatchState(userState, (state) => { + state.name.lastname = 'Sanders'; // mutable change + return state; // returning state +}); +``` + + + ## `immerReducer` Inspired by [Alex Okrushko](https://twitter.com/alexokrushko), `immerReducer` is a reducer method that uses the Immer `produce` method. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..063a35a --- /dev/null +++ b/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('jest').Config} */ +const config = { + preset: 'jest-preset-angular', + setupFilesAfterEnv: ['/setup-jest.ts'], + testMatch: ['**/*.jest.ts'], + globalSetup: 'jest-preset-angular/global-setup', + modulePathIgnorePatterns: ['/src/package.json'], + moduleNameMapper: { + 'ngrx-immer/signals': '/src/signals', + 'ngrx-immer': '/src', + }, +}; + +module.exports = config; diff --git a/package.json b/package.json index ec8679a..c156ebf 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ngrx-immer", "version": "0.0.0-development", "scripts": { - "test": "uvu -r tsm -r tsconfig-paths/register src tests", + "test": "uvu -r tsm -r tsconfig-paths/register -i jest.ts src tests && jest", "build": "ng-packagr -p src/ng-package.json", "postbuild": "cpy README.md LICENSE dist && cpy schematics ../../dist --parents --cwd=src" }, @@ -13,8 +13,11 @@ "@ngrx/component-store": "^17.0.0", "@ngrx/signals": "^17.1.1", "@ngrx/store": "^17.0.0", + "@types/jest": "^29.5.12", "cpy-cli": "^5.0.0", "immer": "^10.0.3", + "jest": "^29.7.0", + "jest-preset-angular": "^14.0.3", "ng-packagr": "^17.0.0", "prettier": "^3.2.5", "rimraf": "^5.0.5", diff --git a/setup-jest.ts b/setup-jest.ts new file mode 100644 index 0000000..1100b3e --- /dev/null +++ b/setup-jest.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/src/shared/index.ts b/src/shared/index.ts index ae81653..e8fd86c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -4,7 +4,7 @@ import { produce } from 'immer'; * Helper method that wraps a reducer with the Immer `produce` method * Kudos to Alex Okrushko {@link https://lookout.dev/rules/simple-immer-base-function-to-be-used-in-ngrx-store-or-componentstore-for-transforming-data-%22mutably%22} */ -export function immerReducer( +export function immerReducer( callback: (state: State, next: Next) => State | void, ) { return (state: State | undefined, next: Next) => { diff --git a/src/signals/immer-patch-state.ts b/src/signals/immer-patch-state.ts deleted file mode 100644 index f308940..0000000 --- a/src/signals/immer-patch-state.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PartialStateUpdater, patchState, signalStore, StateSignal, withState } from '@ngrx/signals'; -import { immerReducer } from 'ngrx-immer'; - -const UserState = signalStore(withState({id: 1, name: {firstname: 'Konrad', lastname: 'Schultz'}, address: {city: 'Vienna', zip: '1010'}})); -const userState = new UserState(); - -function toFullStateUpdater(updater: PartialStateUpdater) - -export function immerPatchState(state: StateSignal, ...updaters: Array | PartialStateUpdater>) { - const immerUpdaters = updaters.map(updater => { - if (typeof updater === 'function') { -immerReducer(state, updater); - } - return typeof updater === 'function' ? immerReducer(updater) : updater; - }) -patchState(state, immerUpdaters); -} - -immerPatchState(userState, {nummer: 1}) \ No newline at end of file diff --git a/src/signals/index.ts b/src/signals/index.ts index 2140294..b38d780 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -1 +1,24 @@ -export {immerPatchState} from './immer-patch-state' \ No newline at end of file +import { PartialStateUpdater, patchState, StateSignal } from '@ngrx/signals'; +import { immerReducer } from 'ngrx-immer'; + +export type MutableStateUpdater = (state: State) => void; + +function toFullStateUpdater(updater: PartialStateUpdater | MutableStateUpdater): (state: State) => State | void { + return (state: State) => { + const patchedState = updater(state); + if (patchedState) { + return ({ ...state, ...patchedState }); + } + return; + }; +} + +export function immerPatchState(state: StateSignal, ...updaters: Array | PartialStateUpdater | MutableStateUpdater>) { + const immerUpdaters = updaters.map(updater => { + if (typeof updater === 'function') { + return immerReducer(toFullStateUpdater(updater)) as unknown as PartialStateUpdater; + } + return updater; + }); + patchState(state, ...immerUpdaters); +} diff --git a/src/signals/tests/immer-patch-state.jest.ts b/src/signals/tests/immer-patch-state.jest.ts new file mode 100644 index 0000000..900d43a --- /dev/null +++ b/src/signals/tests/immer-patch-state.jest.ts @@ -0,0 +1,145 @@ +import { signalStore, withComputed, withState } from '@ngrx/signals'; +import { immerPatchState } from 'ngrx-immer/signals'; +import { computed, effect } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +const UserState = signalStore(withState({ + id: 1, + name: { firstname: 'Konrad', lastname: 'Schultz' }, + address: { city: 'Vienna', zip: '1010' }, +}), withComputed(({ name }) => ({ prettyName: computed(() => `${name.firstname()} ${name.lastname()}`) }))); + + +describe('immerPatchState', () => { + const setup = () => { + return new UserState; + }; + + it('should do a sanity check', () => { + const userState = setup(); + expect(userState.id()).toBe(1); + }); + + it('should be type-safe', () => { + const userState = setup(); + + //@ts-expect-error number is not a property + immerPatchState(userState, { number: 1 }); + + //@ts-expect-error number is not a property + immerPatchState(userState, state => ({ number: 1 })); + }); + + it('should allow patching with object literal', () => { + const userState = setup(); + immerPatchState(userState, { name: { firstname: 'Lucy', lastname: 'Sanders' } }); + expect(userState.prettyName()).toBe('Lucy Sanders'); + }); + + describe('update with return value', () => { + it('should work with the default patch function', () => { + const userState = setup(); + immerPatchState(userState, ({ name }) => ({ name: { firstname: name.firstname, lastname: 'Sanders' } })); + expect(userState.prettyName()).toBe('Konrad Sanders'); + }); + + it('should not emit other signals', () => { + TestBed.runInInjectionContext(() => { + let effectCounter = 0; + const userState = setup(); + effect(() => { + userState.id(); + effectCounter++; + }); + TestBed.flushEffects(); + + expect(effectCounter).toBe(1); + immerPatchState(userState, ({ name }) => ({ name: { firstname: name.firstname, lastname: 'Sanders' } })); + + TestBed.flushEffects(); + expect(effectCounter).toBe(1); + }); + }); + + it('should throw if a mutated patched state is returned', () => { + const userState = setup(); + + expect(() => + immerPatchState(userState, (state) => { + state.name.lastname = 'Sanders'; + return state; + })).toThrow('[Immer] An immer producer returned a new value *and* modified its draft.'); + }); + }); + + describe('update without returning a value', () => { + it('should allow a mutable update', () => { + const userState = setup(); + immerPatchState(userState, (state => { + state.name = { firstname: 'Lucy', lastname: 'Sanders' }; + })); + expect(userState.prettyName()).toBe('Lucy Sanders'); + }); + + it('should not emit other signals', () => { + TestBed.runInInjectionContext(() => { + let effectCounter = 0; + const userState = setup(); + effect(() => { + userState.id(); + effectCounter++; + }); + TestBed.flushEffects(); + + expect(effectCounter).toBe(1); + immerPatchState(userState, (state => { + state.name = { firstname: 'Lucy', lastname: 'Sanders' }; + })); + + TestBed.flushEffects(); + expect(effectCounter).toBe(1); + }); + }); + }); + + it('should check the Signal notification on multiple updates', () => { + TestBed.runInInjectionContext(() => { + // setup effects + let addressEffectCounter = 0; + let nameEffectCounter = 0; + let idEffectCounter = 0; + const userState = setup(); + effect(() => { + userState.id(); + idEffectCounter++; + }); + effect(() => { + userState.address(); + addressEffectCounter++ + }); + effect(() => { + userState.name(); + nameEffectCounter++ + }); + + + // first run + TestBed.flushEffects(); + expect(idEffectCounter).toBe(1); + expect(addressEffectCounter).toBe(1); + expect(nameEffectCounter).toBe(1); + + // change + immerPatchState(userState, (state => { + state.name = { firstname: 'Lucy', lastname: 'Sanders' }; + state.address.zip = '1020' + })); + + // second run + TestBed.flushEffects(); + expect(idEffectCounter).toBe(1); + expect(addressEffectCounter).toBe(2); + expect(nameEffectCounter).toBe(2); + }); + }); +}); diff --git a/src/signals/tests/immer-patch-state.spec.ts b/src/signals/tests/immer-patch-state.spec.ts deleted file mode 100644 index 2fc67dc..0000000 --- a/src/signals/tests/immer-patch-state.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -// normal update -// updater -// partial state updater -// multiple updaters -// test that no changes, do not trigger \ No newline at end of file diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 0000000..1b7ed79 --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,9 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jest"] + }, + "include": ["**/tests/*.jest.ts"] +} From aeea16b238009cb1005c8c60eee65b2eda5a1e4f Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 12 Mar 2024 17:08:52 +0100 Subject: [PATCH 03/10] Update README.md Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> --- README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cd1abef..7d15e53 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,16 @@ Provides an Immer-version of the Signal Store's `patchState`. It adds an additio which can mutate the state. ```ts -const UserState = signalStore(withState({ - id: 1, - name: { firstname: 'Konrad', lastname: 'Schultz' }, - address: { city: 'Vienna', zip: '1010' }, -}), withComputed(({ name }) => ({ prettyName: computed(() => `${name.firstname()} ${name.lastname()}`) }))); +const UserState = signalStore( + withState({ + id: 1, + name: { firstname: 'Konrad', lastname: 'Schultz' }, + address: { city: 'Vienna', zip: '1010' }, + }), + withComputed(({ name }) => ({ + prettyName: computed(() => `${name.firstname()} ${name.lastname()}`), + })) +); const userState = new UserState(); From df8a2f0c5a3cbd317a7882284d83258ac157471a Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 12 Mar 2024 17:32:25 +0100 Subject: [PATCH 04/10] Update src/signals/index.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Marko Stanimirović --- src/signals/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/signals/index.ts b/src/signals/index.ts index b38d780..680f7f1 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -1,7 +1,7 @@ import { PartialStateUpdater, patchState, StateSignal } from '@ngrx/signals'; import { immerReducer } from 'ngrx-immer'; -export type MutableStateUpdater = (state: State) => void; +export type ImmerStateUpdater = (state: State) => void; function toFullStateUpdater(updater: PartialStateUpdater | MutableStateUpdater): (state: State) => State | void { return (state: State) => { From a0b7883137ce70cbca4a9b0e84a43b6f766290da Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 12 Mar 2024 17:32:42 +0100 Subject: [PATCH 05/10] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Marko Stanimirović --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 7d15e53..37a44e3 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,7 @@ export class MoviesStore extends ImmerComponentStore { ## `immerPatchState` -Provides an Immer-version of the Signal Store's `patchState`. It adds an additional updater function -which can mutate the state. +Provides an Immer-version of the `patchState` function from the `@ngrx/signals` package. In addition to partial state objects and updaters that update the state immutably, it accepts updater functions that update the state in a mutable manner. Similar to `patchState`, the `immerPatchState` function can be used to update the state of both SignalStore and SignalState. ```ts const UserState = signalStore( From 6d683663b4bdf35e584744224967d11fdf93be03 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 12 Mar 2024 17:44:37 +0100 Subject: [PATCH 06/10] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Marko Stanimirović --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 37a44e3..a36cc7e 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,9 @@ This one is going to throw a runtime error: ```ts // will throw because of both returning and mutable change -immerPatchState(userState, (state) => { - state.name.lastname = 'Sanders'; // mutable change - return state; // returning state +immerPatchState(userStore, (state) => { + state.name.lastname = 'Sanders'; // mutable change + return state; // returning state }); ``` From f7a2b51ff98e125598b30d27d9b22947bfda089c Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 12 Mar 2024 18:07:04 +0100 Subject: [PATCH 07/10] refactor: add improvements according to review. --- README.md | 39 +++++++++++++++++++-------------------- src/signals/index.ts | 4 ++-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a36cc7e..47d6c50 100644 --- a/README.md +++ b/README.md @@ -85,23 +85,24 @@ export class MoviesStore extends ImmerComponentStore { Provides an Immer-version of the `patchState` function from the `@ngrx/signals` package. In addition to partial state objects and updaters that update the state immutably, it accepts updater functions that update the state in a mutable manner. Similar to `patchState`, the `immerPatchState` function can be used to update the state of both SignalStore and SignalState. ```ts -const UserState = signalStore( - withState({ - id: 1, - name: { firstname: 'Konrad', lastname: 'Schultz' }, - address: { city: 'Vienna', zip: '1010' }, - }), - withComputed(({ name }) => ({ - prettyName: computed(() => `${name.firstname()} ${name.lastname()}`), - })) +const UserStore = signalStore( + withState({ + user: { firstName: 'Konrad', lastName: 'Schultz' }, + address: { city: 'Vienna', zip: '1010' }, + }), + withMethods((store) => ({ + setLastName(lastName: string): void { + immerPatchState(store, (state) => { + state.user.lastName = lastName; + }); + }, + setCity(city: string): void { + immerPatchState(store, (state) => { + state.address.city = city; + }); + }, + })) ); - -const userState = new UserState(); - -immerPatchState(userState, (state => { - state.name = { firstname: 'Lucy', lastname: 'Sanders' }; - state.address.zip = '1020' -})); ``` Please note, that the updater function can only mutate a change without returning it or return an immutable @@ -112,13 +113,11 @@ This one is going to throw a runtime error: ```ts // will throw because of both returning and mutable change immerPatchState(userStore, (state) => { - state.name.lastname = 'Sanders'; // mutable change - return state; // returning state + state.name.lastname = 'Sanders'; // mutable change + return state; // returning state }); ``` - - ## `immerReducer` Inspired by [Alex Okrushko](https://twitter.com/alexokrushko), `immerReducer` is a reducer method that uses the Immer `produce` method. diff --git a/src/signals/index.ts b/src/signals/index.ts index 680f7f1..d74fea2 100644 --- a/src/signals/index.ts +++ b/src/signals/index.ts @@ -3,7 +3,7 @@ import { immerReducer } from 'ngrx-immer'; export type ImmerStateUpdater = (state: State) => void; -function toFullStateUpdater(updater: PartialStateUpdater | MutableStateUpdater): (state: State) => State | void { +function toFullStateUpdater(updater: PartialStateUpdater | ImmerStateUpdater): (state: State) => State | void { return (state: State) => { const patchedState = updater(state); if (patchedState) { @@ -13,7 +13,7 @@ function toFullStateUpdater(updater: PartialStateUpdater(state: StateSignal, ...updaters: Array | PartialStateUpdater | MutableStateUpdater>) { +export function immerPatchState(state: StateSignal, ...updaters: Array | PartialStateUpdater | ImmerStateUpdater>) { const immerUpdaters = updaters.map(updater => { if (typeof updater === 'function') { return immerReducer(toFullStateUpdater(updater)) as unknown as PartialStateUpdater; From 172c901cc4c119e4be10180688980bee24bbcbac Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Wed, 13 Mar 2024 16:01:15 +0100 Subject: [PATCH 08/10] ci: run tests before build --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebf09aa..6350b5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,10 @@ jobs: node-version: ${{ matrix.node-version }} - name: install run: npm install - - name: build - run: npm run build - name: test run: npm run test + - name: build + run: npm run build - name: Release if: github.repository == 'timdeschryver/ngrx-immer' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') run: npx semantic-release From 9383dcad3b3d80f48c6eb4c95043a5af8425adc6 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Wed, 13 Mar 2024 16:20:52 +0100 Subject: [PATCH 09/10] test: should work with chained patch functions --- src/signals/tests/immer-patch-state.jest.ts | 95 ++++++++++++++++----- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/src/signals/tests/immer-patch-state.jest.ts b/src/signals/tests/immer-patch-state.jest.ts index 900d43a..3f9a1b0 100644 --- a/src/signals/tests/immer-patch-state.jest.ts +++ b/src/signals/tests/immer-patch-state.jest.ts @@ -1,18 +1,27 @@ -import { signalStore, withComputed, withState } from '@ngrx/signals'; +import { + PartialStateUpdater, + signalStore, + withComputed, + withState, +} from '@ngrx/signals'; import { immerPatchState } from 'ngrx-immer/signals'; import { computed, effect } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -const UserState = signalStore(withState({ - id: 1, - name: { firstname: 'Konrad', lastname: 'Schultz' }, - address: { city: 'Vienna', zip: '1010' }, -}), withComputed(({ name }) => ({ prettyName: computed(() => `${name.firstname()} ${name.lastname()}`) }))); - +const UserState = signalStore( + withState({ + id: 1, + name: { firstname: 'Konrad', lastname: 'Schultz' }, + address: { city: 'Vienna', zip: '1010' }, + }), + withComputed(({ name }) => ({ + prettyName: computed(() => `${name.firstname()} ${name.lastname()}`), + })), +); describe('immerPatchState', () => { const setup = () => { - return new UserState; + return new UserState(); }; it('should do a sanity check', () => { @@ -27,20 +36,56 @@ describe('immerPatchState', () => { immerPatchState(userState, { number: 1 }); //@ts-expect-error number is not a property - immerPatchState(userState, state => ({ number: 1 })); + immerPatchState(userState, (state) => ({ number: 1 })); }); it('should allow patching with object literal', () => { const userState = setup(); - immerPatchState(userState, { name: { firstname: 'Lucy', lastname: 'Sanders' } }); + immerPatchState(userState, { + name: { firstname: 'Lucy', lastname: 'Sanders' }, + }); expect(userState.prettyName()).toBe('Lucy Sanders'); }); describe('update with return value', () => { it('should work with the default patch function', () => { const userState = setup(); - immerPatchState(userState, ({ name }) => ({ name: { firstname: name.firstname, lastname: 'Sanders' } })); + immerPatchState(userState, ({ name }) => ({ + name: { firstname: name.firstname, lastname: 'Sanders' }, + })); + expect(userState.prettyName()).toBe('Konrad Sanders'); + }); + + it('should work with chained patch functions', () => { + const userState = setup(); + + function updateNames< + T extends { + name: { firstname: string; lastname: string }; + }, + >(newState: T): PartialStateUpdater { + return (state) => { + return { + ...state, + ...newState + }; + }; + } + + immerPatchState( + userState, + updateNames({ name: { firstname: 'Konrad', lastname: 'Sanders' } }), + (state) => { + state.id = 2; + }, + (state) => { + state.address = { city: 'Updated', zip: '1234' }; + }, + ); + expect(userState.prettyName()).toBe('Konrad Sanders'); + expect(userState.id()).toBe(2); + expect(userState.address()).toEqual({ city: 'Updated', zip: '1234' }); }); it('should not emit other signals', () => { @@ -54,7 +99,9 @@ describe('immerPatchState', () => { TestBed.flushEffects(); expect(effectCounter).toBe(1); - immerPatchState(userState, ({ name }) => ({ name: { firstname: name.firstname, lastname: 'Sanders' } })); + immerPatchState(userState, ({ name }) => ({ + name: { firstname: name.firstname, lastname: 'Sanders' }, + })); TestBed.flushEffects(); expect(effectCounter).toBe(1); @@ -68,16 +115,19 @@ describe('immerPatchState', () => { immerPatchState(userState, (state) => { state.name.lastname = 'Sanders'; return state; - })).toThrow('[Immer] An immer producer returned a new value *and* modified its draft.'); + }), + ).toThrow( + '[Immer] An immer producer returned a new value *and* modified its draft.', + ); }); }); describe('update without returning a value', () => { it('should allow a mutable update', () => { const userState = setup(); - immerPatchState(userState, (state => { + immerPatchState(userState, (state) => { state.name = { firstname: 'Lucy', lastname: 'Sanders' }; - })); + }); expect(userState.prettyName()).toBe('Lucy Sanders'); }); @@ -92,9 +142,9 @@ describe('immerPatchState', () => { TestBed.flushEffects(); expect(effectCounter).toBe(1); - immerPatchState(userState, (state => { + immerPatchState(userState, (state) => { state.name = { firstname: 'Lucy', lastname: 'Sanders' }; - })); + }); TestBed.flushEffects(); expect(effectCounter).toBe(1); @@ -115,14 +165,13 @@ describe('immerPatchState', () => { }); effect(() => { userState.address(); - addressEffectCounter++ + addressEffectCounter++; }); effect(() => { userState.name(); - nameEffectCounter++ + nameEffectCounter++; }); - // first run TestBed.flushEffects(); expect(idEffectCounter).toBe(1); @@ -130,10 +179,10 @@ describe('immerPatchState', () => { expect(nameEffectCounter).toBe(1); // change - immerPatchState(userState, (state => { + immerPatchState(userState, (state) => { state.name = { firstname: 'Lucy', lastname: 'Sanders' }; - state.address.zip = '1020' - })); + state.address.zip = '1020'; + }); // second run TestBed.flushEffects(); From d220193c05f29214a5d990f7eae06ff084bfd135 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Wed, 13 Mar 2024 16:22:33 +0100 Subject: [PATCH 10/10] test: reword tests descriptions (no should) --- .../provide-immer-component-store.test.ts | 2 +- src/signals/tests/immer-patch-state.jest.ts | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/component-store/tests/provide-immer-component-store.test.ts b/src/component-store/tests/provide-immer-component-store.test.ts index c6d7ed5..b7c090a 100644 --- a/src/component-store/tests/provide-immer-component-store.test.ts +++ b/src/component-store/tests/provide-immer-component-store.test.ts @@ -6,7 +6,7 @@ import { ImmerComponentStore, provideImmerComponentStore } from 'ngrx-immer/comp import { provideComponentStore } from '@ngrx/component-store'; -test('provideImmerComponentStore() should equal provideComponentStore()', () => { +test('provideImmerComponentStore() equals provideComponentStore()', () => { const ngrxProviders = provideComponentStore(DummyImmerComponentStore as any); const ngrxImmerProviders = provideImmerComponentStore(DummyImmerComponentStore); diff --git a/src/signals/tests/immer-patch-state.jest.ts b/src/signals/tests/immer-patch-state.jest.ts index 3f9a1b0..48e9792 100644 --- a/src/signals/tests/immer-patch-state.jest.ts +++ b/src/signals/tests/immer-patch-state.jest.ts @@ -24,12 +24,12 @@ describe('immerPatchState', () => { return new UserState(); }; - it('should do a sanity check', () => { + it('smoketest', () => { const userState = setup(); expect(userState.id()).toBe(1); }); - it('should be type-safe', () => { + it('is type-safe', () => { const userState = setup(); //@ts-expect-error number is not a property @@ -39,7 +39,7 @@ describe('immerPatchState', () => { immerPatchState(userState, (state) => ({ number: 1 })); }); - it('should allow patching with object literal', () => { + it('allows patching with object literal', () => { const userState = setup(); immerPatchState(userState, { name: { firstname: 'Lucy', lastname: 'Sanders' }, @@ -48,7 +48,7 @@ describe('immerPatchState', () => { }); describe('update with return value', () => { - it('should work with the default patch function', () => { + it('works with the default patch function', () => { const userState = setup(); immerPatchState(userState, ({ name }) => ({ name: { firstname: name.firstname, lastname: 'Sanders' }, @@ -56,7 +56,7 @@ describe('immerPatchState', () => { expect(userState.prettyName()).toBe('Konrad Sanders'); }); - it('should work with chained patch functions', () => { + it('works with chained patch functions', () => { const userState = setup(); function updateNames< @@ -88,7 +88,7 @@ describe('immerPatchState', () => { expect(userState.address()).toEqual({ city: 'Updated', zip: '1234' }); }); - it('should not emit other signals', () => { + it('does not emit other signals', () => { TestBed.runInInjectionContext(() => { let effectCounter = 0; const userState = setup(); @@ -108,7 +108,7 @@ describe('immerPatchState', () => { }); }); - it('should throw if a mutated patched state is returned', () => { + it('throws if a mutated patched state is returned', () => { const userState = setup(); expect(() => @@ -123,7 +123,7 @@ describe('immerPatchState', () => { }); describe('update without returning a value', () => { - it('should allow a mutable update', () => { + it('allows a mutable update', () => { const userState = setup(); immerPatchState(userState, (state) => { state.name = { firstname: 'Lucy', lastname: 'Sanders' }; @@ -131,7 +131,7 @@ describe('immerPatchState', () => { expect(userState.prettyName()).toBe('Lucy Sanders'); }); - it('should not emit other signals', () => { + it('does not emit other signals', () => { TestBed.runInInjectionContext(() => { let effectCounter = 0; const userState = setup(); @@ -152,7 +152,7 @@ describe('immerPatchState', () => { }); }); - it('should check the Signal notification on multiple updates', () => { + it('checks the Signal notification on multiple updates', () => { TestBed.runInInjectionContext(() => { // setup effects let addressEffectCounter = 0;