diff --git a/README.md b/README.md index a73645b..fade436 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ npm i @angular-architects/ngrx-toolkit - [DataService `withDataService()`](#dataservice-withdataservice) - [DataService with Dynamic Properties](#dataservice-with-dynamic-properties) - [Storage Sync `withStorageSync`](#storage-sync-withstoragesync) + - [Undo-Redo `withUndoRedo`](#undo-redo-withUndoRedo) - [Redux Connector for the NgRx Signal Store `createReduxState()`](#redux-connector-for-the-ngrx-signal-store-createreduxstate) - [Use a present Signal Store](#use-a-present-signal-store) - [Use well-known NgRx Store Actions](#use-well-known-ngrx-store-actions) @@ -139,6 +140,7 @@ export const SimpleFlightBookingStore = signalStore( ``` The features `withCallState` and `withUndoRedo` are optional, but when present, they enrich each other. +Refer to the [Undo-Redo](#undo-redo-withundoredo) section for more information. The Data Service needs to implement the `DataService` interface: @@ -305,6 +307,43 @@ public class SyncedStoreComponent { } ``` +## Undo-Redo `withUndoRedo()` + +`withUndoRedo` adds undo and redo functionality to the store. + +Example: + +```ts +const SyncStore = signalStore( + withUndoRedo({ + maxStackSize: 100, // limit of undo/redo steps - `100` by default + collections: ['flight'], // entity collections to keep track of - unnamed collection is tracked by default + keys: ['test'], // non-entity based keys to track - `[]` by default + skip: 0, // number of initial state changes to skip - `0` by default + }) +); +``` + +```ts +@Component(...) +public class UndoRedoComponent { + private syncStore = inject(SyncStore); + + canUndo = this.store.canUndo; // use in template or in ts + canRedo = this.store.canRedo; // use in template or in ts + + undo(): void { + if (!this.canUndo()) return; + this.store.undo(); + } + + redo(): void { + if (!this.canRedo()) return; + this.store.redo(); + } +} +``` + ## Redux Connector for the NgRx Signal Store `createReduxState()` The Redux Connector turns any `signalStore()` into a Gobal State Management Slice following the Redux pattern. diff --git a/libs/ngrx-toolkit/src/lib/with-undo-redo.spec.ts b/libs/ngrx-toolkit/src/lib/with-undo-redo.spec.ts new file mode 100644 index 0000000..a0c665f --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-undo-redo.spec.ts @@ -0,0 +1,194 @@ +import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { withUndoRedo } from './with-undo-redo'; +import { addEntity, withEntities } from '@ngrx/signals/entities'; +import { computed, inject } from '@angular/core'; +import { withCallState } from './with-call-state'; + +const testState = { test: '' }; +const testKeys = ['test' as const]; +const newValue = 'new value'; +const newerValue = 'newer value'; + +describe('withUndoRedo', () => { + it('adds methods for undo, redo, canUndo, canRedo', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore(withState(testState), withUndoRedo({ keys: testKeys })); + const store = new Store(); + + expect(Object.keys(store)).toEqual([ + 'test', + 'canUndo', + 'canRedo', + 'undo', + 'redo' + ]); + }); + }); + + it('should check keys and collection types', () => { + signalStore(withState(testState), + // @ts-expect-error - should not allow invalid keys + withUndoRedo({ keys: ['tes'] })); + signalStore(withState(testState), + withEntities({ entity: type(), collection: 'flight' }), + // @ts-expect-error - should not allow invalid keys when entities are present + withUndoRedo({ keys: ['flightIdsTest'] })); + signalStore(withState(testState), + // @ts-expect-error - should not allow collections without named entities + withUndoRedo({ collections: ['tee'] })); + signalStore(withState(testState), withComputed(store => ({ testComputed: computed(() => store.test()) })), + // @ts-expect-error - should not allow collections without named entities with other computed + withUndoRedo({ collections: ['tested'] })); + signalStore(withEntities({ entity: type() }), + // @ts-expect-error - should not allow collections without named entities + withUndoRedo({ collections: ['test'] })); + signalStore(withEntities({ entity: type(), collection: 'flight' }), + // @ts-expect-error - should not allow invalid collections + withUndoRedo({ collections: ['test'] })); + }); + + describe('undo and redo', () => { + it('restores previous state for regular store key', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withState(testState), + withMethods(store => ({ updateTest: (newTest: string) => patchState(store, { test: newTest }) })), + withUndoRedo({ keys: testKeys }) + ); + + const store = new Store(); + tick(1); + + store.updateTest(newValue); + tick(1); + expect(store.test()).toEqual(newValue); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(false); + + store.undo(); + tick(1); + + expect(store.test()).toEqual(''); + expect(store.canUndo()).toBe(false); + expect(store.canRedo()).toBe(true); + }); + })); + + it('restores previous state for regular store key and respects skip', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withState(testState), + withMethods(store => ({ updateTest: (newTest: string) => patchState(store, { test: newTest }) })), + withUndoRedo({ keys: testKeys, skip: 1 }) + ); + + const store = new Store(); + tick(1); + + store.updateTest(newValue); + tick(1); + expect(store.test()).toEqual(newValue); + + store.updateTest(newerValue); + tick(1); + + store.undo(); + tick(1); + + expect(store.test()).toEqual(newValue); + expect(store.canUndo()).toBe(false); + + store.undo(); + tick(1); + + // should not change + expect(store.test()).toEqual(newValue); + }); + })); + + it('undoes and redoes previous state for entity', fakeAsync(() => { + const Store = signalStore( + withEntities({ entity: type<{ id: string }>() }), + withMethods(store => ({ + addEntity: (newTest: string) => patchState(store, addEntity({ id: newTest })) + })), + withUndoRedo() + ); + TestBed.configureTestingModule({ providers: [Store] }); + TestBed.runInInjectionContext(() => { + const store = inject(Store); + tick(1); + expect(store.entities()).toEqual([]); + expect(store.canUndo()).toBe(false); + expect(store.canRedo()).toBe(false); + + store.addEntity(newValue); + tick(1); + expect(store.entities()).toEqual([{ id: newValue }]); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(false); + + store.addEntity(newerValue); + tick(1); + expect(store.entities()).toEqual([{ id: newValue }, { id: newerValue }]); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(false); + + store.undo(); + + expect(store.entities()).toEqual([{ id: newValue }]); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(true); + + store.undo(); + + expect(store.entities()).toEqual([]); + expect(store.canUndo()).toBe(false); + expect(store.canRedo()).toBe(true); + + store.redo(); + tick(1); + + expect(store.entities()).toEqual([{ id: newValue }]); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(true); + + // should return canRedo=false after a change + store.addEntity('newest'); + tick(1); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(false); + }); + })); + + it('restores previous state for named entity', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ entity: type<{ id: string }>(), collection: 'flight' }), + withMethods(store => ({ + addEntity: (newTest: string) => patchState(store, addEntity({ id: newTest }, { collection: 'flight' })) + })), + withCallState({ collection: 'flight' }), + withUndoRedo({ collections: ['flight'] }) + ); + + const store = new Store(); + tick(1); + + store.addEntity(newValue); + tick(1); + expect(store.flightEntities()).toEqual([{ id: newValue }]); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(false); + + store.undo(); + tick(1); + + expect(store.flightEntities()).toEqual([]); + expect(store.canUndo()).toBe(false); + expect(store.canRedo()).toBe(true); + }); + })); + }); +}); diff --git a/libs/ngrx-toolkit/src/lib/with-undo-redo.ts b/libs/ngrx-toolkit/src/lib/with-undo-redo.ts index 742f49b..0f9e134 100644 --- a/libs/ngrx-toolkit/src/lib/with-undo-redo.ts +++ b/libs/ngrx-toolkit/src/lib/with-undo-redo.ts @@ -5,25 +5,24 @@ import { withComputed, withHooks, withMethods, - EmptyFeatureResult, + EmptyFeatureResult, SignalStoreFeatureResult } from '@ngrx/signals'; -import { EntityId, EntityMap, EntityState } from '@ngrx/signals/entities'; import { Signal, effect, signal, untracked, isSignal } from '@angular/core'; -import { Entity, capitalize } from './with-data-service'; -import { - EntityComputed, - NamedEntityComputed, -} from './shared/signal-store-models'; +import { capitalize } from './with-data-service'; export type StackItem = Record; export type NormalizedUndoRedoOptions = { maxStackSize: number; collections?: string[]; + keys: string[]; + skip: number, }; const defaultOptions: NormalizedUndoRedoOptions = { maxStackSize: 100, + keys: [], + skip: 0, }; export function getUndoRedoKeys(collections?: string[]): string[] { @@ -38,51 +37,33 @@ export function getUndoRedoKeys(collections?: string[]): string[] { return ['entityMap', 'ids', 'selectedIds', 'filter']; } -export function withUndoRedo(options?: { - maxStackSize?: number; - collections: Collection[]; -}): SignalStoreFeature< - EmptyFeatureResult & { - computed: NamedEntityComputed; - }, - EmptyFeatureResult & { - computed: { - canUndo: Signal; - canRedo: Signal; - }; - methods: { - undo: () => void; - redo: () => void; - }; - } ->; +type NonNever = T extends never ? never : T; -export function withUndoRedo(options?: { - maxStackSize?: number; -}): SignalStoreFeature< - EmptyFeatureResult & { - state: EntityState; - computed: EntityComputed; - }, +type ExtractEntityCollection = T extends `${infer U}Entities` ? U : never; + +type ExtractEntityCollections = NonNever<{ + [K in keyof Store['computed']]: ExtractEntityCollection; +}[keyof Store['computed']]>; + +type OptionsForState = Partial> & { + collections?: ExtractEntityCollections[]; + keys?: (keyof Store['state'])[]; +}; + +export function withUndoRedo< + Input extends EmptyFeatureResult>(options?: OptionsForState): SignalStoreFeature< + Input, EmptyFeatureResult & { - computed: { - canUndo: Signal; - canRedo: Signal; - }; - methods: { - undo: () => void; - redo: () => void; - }; - } ->; - -export function withUndoRedo( - options: { - maxStackSize?: number; - collections?: Collection[]; - } = {} -): // eslint-disable-next-line @typescript-eslint/no-explicit-any -SignalStoreFeature { + computed: { + canUndo: Signal; + canRedo: Signal; + }; + methods: { + undo: () => void; + redo: () => void; + }; +} +> { let previous: StackItem | null = null; let skipOnce = false; @@ -107,7 +88,7 @@ SignalStoreFeature { canRedo.set(redoStack.length !== 0); }; - const keys = getUndoRedoKeys(normalized?.collections); + const keys = [...getUndoRedoKeys(normalized.collections), ...normalized.keys]; return signalStoreFeature( withComputed(() => ({ @@ -147,10 +128,10 @@ SignalStoreFeature { }, })), withHooks({ - onInit(store: Record) { + onInit(store) { effect(() => { const cand = keys.reduce((acc, key) => { - const s = store[key]; + const s = (store as Record)[key]; if (s && isSignal(s)) { return { ...acc, @@ -160,6 +141,11 @@ SignalStoreFeature { return acc; }, {}); + if (normalized.skip > 0) { + normalized.skip--; + return; + } + if (skipOnce) { skipOnce = false; return;