From b684df2b2c79244d514b5424a92032168a09e00a Mon Sep 17 00:00:00 2001 From: Clarchik Date: Mon, 29 Sep 2025 00:58:39 +0200 Subject: [PATCH] feat: support full and partial resets via reset() in withReset() Signed-off-by: Clarchik --- libs/ngrx-toolkit/src/index.ts | 2 +- libs/ngrx-toolkit/src/lib/with-reset.spec.ts | 575 ++++++++++++++++++- libs/ngrx-toolkit/src/lib/with-reset.ts | 68 ++- 3 files changed, 642 insertions(+), 3 deletions(-) diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 8b8bba85..1a29d77d 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -21,7 +21,7 @@ export { export * from './lib/with-call-state'; export * from './lib/with-data-service'; export * from './lib/with-pagination'; -export { setResetState, withReset } from './lib/with-reset'; +export { reset, setResetState, withReset } from './lib/with-reset'; export * from './lib/with-undo-redo'; export { withImmutableState } from './lib/immutable-state/with-immutable-state'; diff --git a/libs/ngrx-toolkit/src/lib/with-reset.spec.ts b/libs/ngrx-toolkit/src/lib/with-reset.spec.ts index 91cd8357..8cdb3b02 100644 --- a/libs/ngrx-toolkit/src/lib/with-reset.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-reset.spec.ts @@ -7,7 +7,7 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { setResetState, withReset } from './with-reset'; +import { reset, setResetState, withReset } from './with-reset'; describe('withReset', () => { const setup = () => { @@ -110,3 +110,576 @@ describe('withReset', () => { ); }); }); + +describe('reset function with SignalStore', () => { + describe('generic reset (no pick function)', () => { + class TestClass { + value = 'test'; + } + + const originalDate = new Date('2023-01-01T00:00:00Z'); + const newInstance = new TestClass(); + + const setup = () => { + const initialState = { + name: 'John', + description: 'Test description', + count: 42, + price: 99.99, + negative: -5, + isActive: true, + isVisible: false, + hasPermission: true, + items: [1, 2, 3], + tags: ['a', 'b'], + empty: [], + user: { id: 1, name: 'John' }, + config: { theme: 'dark' }, + emptyObj: {}, + nullValue: null, + undefinedValue: undefined, + mixed: null, + createdAt: originalDate, + updatedAt: new Date(), + address: { city: 'Vienna', zip: '1010' }, + stringValue: 'hello', + numberValue: 42, + booleanValue: true, + arrayValue: [1, 2, 3], + objectValue: { nested: 'value' }, + dateValue: new Date('2023-01-01'), + callback: () => 'test', + method: function () { + return 'method'; + }, + arrow: () => 'arrow', + instance: new TestClass(), + error: new Error('test'), + regex: /test/, + }; + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState(initialState), + withReset(), + withMethods((store) => ({ + updateName(name: string) { + patchState(store, { name }); + }, + updateDescription(description: string) { + patchState(store, { description }); + }, + updateCount(count: number) { + patchState(store, { count }); + }, + updatePrice(price: number) { + patchState(store, { price }); + }, + toggleActive() { + patchState(store, (state) => ({ isActive: !state.isActive })); + }, + toggleVisible() { + patchState(store, (state) => ({ isVisible: !state.isVisible })); + }, + addItem(item: number) { + patchState(store, (state) => ({ items: [...state.items, item] })); + }, + addTag(tag: string) { + patchState(store, (state) => ({ tags: [...state.tags, tag] })); + }, + updateUser(user: { id: number; name: string }) { + patchState(store, { user }); + }, + updateConfig(config: { theme: string }) { + patchState(store, { config }); + }, + setNullValue(value: null) { + patchState(store, { nullValue: value }); + }, + updateCreatedAt(date: Date) { + patchState(store, { createdAt: date }); + }, + updateAll() { + patchState(store, { + stringValue: 'world', + numberValue: 100, + booleanValue: false, + arrayValue: [4, 5, 6], + objectValue: { nested: 'updated' }, + dateValue: new Date('2024-01-01'), + }); + }, + updateInstance(instance: TestClass) { + patchState(store, { instance }); + }, + updateCallback(fn: () => string) { + patchState(store, { callback: fn }); + }, + })), + ); + + const store = TestBed.configureTestingModule({ + providers: [Store], + }).inject(Store); + + return { store, initialState }; + }; + it('should reset string values to empty string', () => { + const { store } = setup(); + + // Modify state + store.updateName('Jane'); + store.updateDescription('Updated description'); + expect(getState(store)).toMatchObject({ + name: 'Jane', + description: 'Updated description', + }); + + // Reset using generic reset + patchState(store, reset()); + expect(getState(store)).toMatchObject({ name: '', description: '' }); + }); + + it('should reset number values to 0', () => { + const { store } = setup(); + + // Modify state + store.updateCount(100); + store.updatePrice(199.99); + expect(getState(store)).toMatchObject({ + count: 100, + price: 199.99, + negative: -5, + }); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + expect(getState(store)).toMatchObject({ + count: 0, + price: 0, + negative: 0, + }); + }); + + it('should reset boolean values to false', () => { + const { store } = setup(); + + // Modify state + store.toggleActive(); + store.toggleVisible(); + expect(getState(store)).toMatchObject({ + isActive: false, + isVisible: true, + hasPermission: true, + }); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + expect(getState(store)).toMatchObject({ + isActive: false, + isVisible: false, + hasPermission: false, + }); + }); + + it('should reset array values to empty array', () => { + const { store } = setup(); + + // Modify state + store.addItem(4); + store.addTag('c'); + expect(getState(store)).toMatchObject({ + items: [1, 2, 3, 4], + tags: ['a', 'b', 'c'], + empty: [], + }); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + expect(getState(store)).toMatchObject({ items: [], tags: [], empty: [] }); + }); + + it('should reset object values to empty object', () => { + const { store } = setup(); + + // Modify state + store.updateUser({ id: 2, name: 'Jane' }); + store.updateConfig({ theme: 'light' }); + expect(getState(store)).toMatchObject({ + user: { id: 2, name: 'Jane' }, + config: { theme: 'light' }, + empty: {}, + }); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + expect(getState(store)).toMatchObject({ + user: {}, + config: {}, + empty: {}, + }); + }); + + it('should preserve null and undefined values', () => { + const { store } = setup(); + + // Modify state + store.setNullValue(null); + expect(getState(store)).toMatchObject({ + nullValue: null, + undefinedValue: undefined, + mixed: null, + }); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + expect(getState(store)).toMatchObject({ + nullValue: null, + undefinedValue: undefined, + mixed: null, + }); + }); + + it('should reset Date values to new Date', () => { + const { store } = setup(); + + // Modify state + const newDate = new Date('2024-01-01T00:00:00Z'); + store.updateCreatedAt(newDate); + expect(getState(store).createdAt).toEqual(newDate); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + const resetState = getState(store); + expect(resetState.createdAt).toBeInstanceOf(Date); + expect(resetState.updatedAt).toBeInstanceOf(Date); + expect(resetState.createdAt.getTime()).not.toBe(originalDate.getTime()); + }); + + it('should handle mixed data types', () => { + const { store } = setup(); + + // Modify state + store.updateAll(); + expect(getState(store).stringValue).toBe('world'); + expect(getState(store).numberValue).toBe(100); + expect(getState(store).booleanValue).toBe(false); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + const resetState = getState(store); + expect(resetState.stringValue).toBe(''); + expect(resetState.numberValue).toBe(0); + expect(resetState.booleanValue).toBe(false); + expect(resetState.arrayValue).toEqual([]); + expect(resetState.objectValue).toEqual({}); + expect(resetState.nullValue).toBeNull(); + expect(resetState.undefinedValue).toBeUndefined(); + expect(resetState.dateValue).toBeInstanceOf(Date); + }); + + it('should handle functions and return undefined', () => { + const { store } = setup(); + + // Modify state + store.updateCallback(() => 'updated'); + expect(getState(store).callback()).toBe('updated'); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + const resetState = getState(store); + expect(resetState.callback).toBeUndefined(); + expect(resetState.method).toBeUndefined(); + expect(resetState.arrow).toBeUndefined(); + }); + + it('should handle class instances and return undefined', () => { + const { store } = setup(); + + // Modify state + newInstance.value = 'updated'; + store.updateInstance(newInstance); + expect(getState(store).instance.value).toBe('updated'); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + const resetState = getState(store); + expect(resetState.instance).toBeUndefined(); + expect(resetState.error).toBeUndefined(); + expect(resetState.regex).toBeUndefined(); + }); + }); + + describe('selective reset (with pick function)', () => { + const initialState = { + user: { id: 1, name: 'John', profile: { age: 30 } }, + count: 42, + isActive: true, + items: [1, 2, 3], + tags: ['a', 'b'], + settings: { theme: 'dark', language: 'en' }, + }; + + const setup = () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState(initialState), + withReset(), + withMethods((store) => ({ + updateUser(user: { + id: number; + name: string; + profile: { age: number }; + }) { + patchState(store, { user }); + }, + updateSettings(settings: { theme: string; language: string }) { + patchState(store, { settings }); + }, + resetUser() { + patchState( + store, + reset((initial) => ({ user: initial.user })), + ); + }, + updateCount(count: number) { + patchState(store, { count }); + }, + toggleActive() { + patchState(store, (state) => ({ isActive: !state.isActive })); + }, + addItem(item: number) { + patchState(store, (state) => ({ items: [...state.items, item] })); + }, + resetSelected() { + patchState( + store, + reset((initial) => ({ + user: initial.user, + count: initial.count, + })), + ); + }, + addTag(tag: string) { + patchState(store, (state) => ({ tags: [...state.tags, tag] })); + }, + + resetItems() { + patchState( + store, + reset((initial) => ({ items: initial.items })), + ); + }, + })), + ); + + const store = TestBed.configureTestingModule({ + providers: [Store], + }).inject(Store); + + return { store, initialState }; + }; + + it('should reset only selected properties', () => { + const { store } = setup(); + + // Modify state + store.updateUser({ id: 2, name: 'Jane', profile: { age: 45 } }); + store.updateCount(100); + store.toggleActive(); + store.addItem(4); + expect(getState(store)).toMatchObject({ + user: { id: 2, name: 'Jane' }, + count: 100, + isActive: false, + items: [1, 2, 3, 4], + }); + + // Reset only selected properties + store.resetSelected(); + const resetState = getState(store); + expect(resetState.user).toMatchObject({ + id: 2, + name: 'Jane', + profile: { age: 45 }, + }); + expect(resetState.count).toBe(100); + expect(resetState.isActive).toBe(false); // Should remain unchanged + expect(resetState.items).toEqual([1, 2, 3, 4]); // Should remain unchanged + }); + + it('should handle nested object reset', () => { + const { store } = setup(); + + console.log(store, 'store'); + + // Modify state + store.updateUser({ id: 2, name: 'Jane', profile: { age: 25 } }); + store.updateSettings({ theme: 'light', language: 'fr' }); + expect(getState(store)).toMatchObject({ + user: { id: 2, name: 'Jane', profile: { age: 25 } }, + settings: { theme: 'light', language: 'fr' }, + }); + + // Reset only user + store.resetUser(); + const resetState = getState(store); + expect(resetState.user).toEqual({ + id: 2, + name: 'Jane', + profile: { age: 25 }, + }); + expect(resetState.settings).toEqual({ theme: 'light', language: 'fr' }); + }); + + it('should handle array reset', () => { + const { store } = setup(); + + // Modify state + store.addItem(4); + store.addTag('c'); + store.updateCount(100); + expect(getState(store)).toMatchObject({ + items: [1, 2, 3, 4], + tags: ['a', 'b', 'c'], + count: 100, + }); + + // Reset only items + store.resetItems(); + const resetState = getState(store); + expect(resetState.items).toEqual([1, 2, 3, 4]); + expect(resetState.tags).toEqual(['a', 'b', 'c']); + expect(resetState.count).toBe(100); + }); + }); + + describe('edge cases', () => { + it('should handle empty state object', () => { + const initialState = {}; + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState(initialState), + withReset(), + withMethods((store) => ({ + // Since we can't add properties to empty state, we'll test reset directly + testReset() { + patchState(store, reset()(getState(store))); + }, + })), + ); + + const store = TestBed.configureTestingModule({ + providers: [Store], + }).inject(Store); + + // Test reset on empty state + store.testReset(); + expect(getState(store)).toEqual({}); + }); + + it('should handle state with only null/undefined values', () => { + const initialState = { + nullValue: null, + undefinedValue: undefined, + }; + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState(initialState), + withReset(), + withMethods((store) => ({ + setNullValue(value: null) { + patchState(store, { nullValue: value }); + }, + })), + ); + + const store = TestBed.configureTestingModule({ + providers: [Store], + }).inject(Store); + + // Modify state + store.setNullValue(null); + expect(getState(store)).toEqual({ + nullValue: null, + undefinedValue: undefined, + }); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + expect(getState(store)).toEqual({ + nullValue: null, + undefinedValue: undefined, + }); + }); + + it('should handle state with Symbol keys', () => { + const sym = Symbol('test'); + const initialState = { + [sym]: 'symbol value', + regular: 'regular value', + }; + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState(initialState), + withReset(), + withMethods((store) => ({ + updateRegular(value: string) { + patchState(store, { regular: value }); + }, + })), + ); + + const store = TestBed.configureTestingModule({ + providers: [Store], + }).inject(Store); + + // Modify state + store.updateRegular('updated'); + expect(getState(store).regular).toBe('updated'); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + const resetState = getState(store); + expect(resetState.regular).toBe(''); + // Symbol properties are not enumerable, so they won't be processed + }); + + it('should handle state with non-enumerable properties', () => { + const initialState = { regular: 'value' }; + Object.defineProperty(initialState, 'nonEnumerable', { + value: 'test', + enumerable: false, + }); + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState(initialState), + withReset(), + withMethods((store) => ({ + updateRegular(value: string) { + patchState(store, { regular: value }); + }, + })), + ); + + const store = TestBed.configureTestingModule({ + providers: [Store], + }).inject(Store); + + // Modify state + store.updateRegular('updated'); + expect(getState(store).regular).toBe('updated'); + + // Reset using generic reset + patchState(store, reset()(getState(store))); + const resetState = getState(store); + expect(resetState.regular).toBe(''); + // Non-enumerable properties are processed by reset and reset to default values + expect((resetState as Record)['nonEnumerable']).toBe(''); + }); + }); +}); diff --git a/libs/ngrx-toolkit/src/lib/with-reset.ts b/libs/ngrx-toolkit/src/lib/with-reset.ts index 9f5b4c8d..515eed60 100644 --- a/libs/ngrx-toolkit/src/lib/with-reset.ts +++ b/libs/ngrx-toolkit/src/lib/with-reset.ts @@ -9,6 +9,9 @@ import { } from '@ngrx/signals'; export type PublicMethods = { + /** + * @deprecated Use {@link reset} instead. + */ resetState(): void; }; @@ -25,7 +28,10 @@ export function withReset() { // workaround to TS excessive property check const methods = { resetState() { - patchState(store, store._resetState.value); + patchState( + store, + reset(() => store._resetState.value), + ); }, __setResetState__(state: object) { store._resetState.value = state; @@ -60,3 +66,63 @@ export function setResetState( } (store.__setResetState__ as (state: State) => void)(state); } + +/** + * Creates a default value for a given type + */ +function getDefaultValue(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + return []; + } + + if (typeof value === 'object' && value.constructor === Object) { + return {}; + } + + if (typeof value === 'string') { + return ''; + } + + if (typeof value === 'number') { + return 0; + } + + if (typeof value === 'boolean') { + return false; + } + + if (value instanceof Date) { + return new Date(); + } + + // For other types (functions, classes, etc.), return undefined + return undefined; +} + +/** + * Creates a generic reset state by resetting each property to its default value + */ +function resetState(state: TState): TState { + const resetState = {} as TState; + + for (const key in state) { + resetState[key] = getDefaultValue(state[key]) as TState[Extract< + keyof TState, + string + >]; + } + + return resetState; +} + +export function reset( + pick?: (initial: TState) => Pick, +): (state: TState) => TState { + return (state: TState) => { + return pick ? { ...state, ...pick(state) } : resetState(state); + }; +}