Skip to content

Commit e9d9634

Browse files
marcindz88rainerhahnekamp
authored andcommitted
feat: add undo-redo skip and keys options, docs and unit tests
1 parent cb77fc9 commit e9d9634

File tree

3 files changed

+272
-53
lines changed

3 files changed

+272
-53
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ npm i @angular-architects/ngrx-toolkit
4242
- [DataService `withDataService()`](#dataservice-withdataservice)
4343
- [DataService with Dynamic Properties](#dataservice-with-dynamic-properties)
4444
- [Storage Sync `withStorageSync`](#storage-sync-withstoragesync)
45+
- [Undo-Redo `withUndoRedo`](#undo-redo-withUndoRedo)
4546
- [Redux Connector for the NgRx Signal Store `createReduxState()`](#redux-connector-for-the-ngrx-signal-store-createreduxstate)
4647
- [Use a present Signal Store](#use-a-present-signal-store)
4748
- [Use well-known NgRx Store Actions](#use-well-known-ngrx-store-actions)
@@ -139,6 +140,7 @@ export const SimpleFlightBookingStore = signalStore(
139140
```
140141

141142
The features `withCallState` and `withUndoRedo` are optional, but when present, they enrich each other.
143+
Refer to the [Undo-Redo](#undo-redo-withundoredo) section for more information.
142144

143145
The Data Service needs to implement the `DataService` interface:
144146

@@ -305,6 +307,43 @@ public class SyncedStoreComponent {
305307
}
306308
```
307309

310+
## Undo-Redo `withUndoRedo()`
311+
312+
`withUndoRedo` adds undo and redo functionality to the store.
313+
314+
Example:
315+
316+
```ts
317+
const SyncStore = signalStore(
318+
withUndoRedo({
319+
maxStackSize: 100, // limit of undo/redo steps - `100` by default
320+
collections: ['flight'], // entity collections to keep track of - unnamed collection is tracked by default
321+
keys: ['test'], // non-entity based keys to track - `[]` by default
322+
skip: 0, // number of initial state changes to skip - `0` by default
323+
})
324+
);
325+
```
326+
327+
```ts
328+
@Component(...)
329+
public class UndoRedoComponent {
330+
private syncStore = inject(SyncStore);
331+
332+
canUndo = this.store.canUndo; // use in template or in ts
333+
canRedo = this.store.canRedo; // use in template or in ts
334+
335+
undo(): void {
336+
if (!this.canUndo()) return;
337+
this.store.undo();
338+
}
339+
340+
redo(): void {
341+
if (!this.canRedo()) return;
342+
this.store.redo();
343+
}
344+
}
345+
```
346+
308347
## Redux Connector for the NgRx Signal Store `createReduxState()`
309348

310349
The Redux Connector turns any `signalStore()` into a Gobal State Management Slice following the Redux pattern.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals';
2+
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
3+
import { withUndoRedo } from './with-undo-redo';
4+
import { addEntity, withEntities } from '@ngrx/signals/entities';
5+
import { computed, inject } from '@angular/core';
6+
import { withCallState } from './with-call-state';
7+
8+
const testState = { test: '' };
9+
const testKeys = ['test' as const];
10+
const newValue = 'new value';
11+
const newerValue = 'newer value';
12+
13+
describe('withUndoRedo', () => {
14+
it('adds methods for undo, redo, canUndo, canRedo', () => {
15+
TestBed.runInInjectionContext(() => {
16+
const Store = signalStore(withState(testState), withUndoRedo({ keys: testKeys }));
17+
const store = new Store();
18+
19+
expect(Object.keys(store)).toEqual([
20+
'test',
21+
'canUndo',
22+
'canRedo',
23+
'undo',
24+
'redo'
25+
]);
26+
});
27+
});
28+
29+
it('should check keys and collection types', () => {
30+
signalStore(withState(testState),
31+
// @ts-expect-error - should not allow invalid keys
32+
withUndoRedo({ keys: ['tes'] }));
33+
signalStore(withState(testState),
34+
withEntities({ entity: type(), collection: 'flight' }),
35+
// @ts-expect-error - should not allow invalid keys when entities are present
36+
withUndoRedo({ keys: ['flightIdsTest'] }));
37+
signalStore(withState(testState),
38+
// @ts-expect-error - should not allow collections without named entities
39+
withUndoRedo({ collections: ['tee'] }));
40+
signalStore(withState(testState), withComputed(store => ({ testComputed: computed(() => store.test()) })),
41+
// @ts-expect-error - should not allow collections without named entities with other computed
42+
withUndoRedo({ collections: ['tested'] }));
43+
signalStore(withEntities({ entity: type() }),
44+
// @ts-expect-error - should not allow collections without named entities
45+
withUndoRedo({ collections: ['test'] }));
46+
signalStore(withEntities({ entity: type(), collection: 'flight' }),
47+
// @ts-expect-error - should not allow invalid collections
48+
withUndoRedo({ collections: ['test'] }));
49+
});
50+
51+
describe('undo and redo', () => {
52+
it('restores previous state for regular store key', fakeAsync(() => {
53+
TestBed.runInInjectionContext(() => {
54+
const Store = signalStore(
55+
withState(testState),
56+
withMethods(store => ({ updateTest: (newTest: string) => patchState(store, { test: newTest }) })),
57+
withUndoRedo({ keys: testKeys })
58+
);
59+
60+
const store = new Store();
61+
tick(1);
62+
63+
store.updateTest(newValue);
64+
tick(1);
65+
expect(store.test()).toEqual(newValue);
66+
expect(store.canUndo()).toBe(true);
67+
expect(store.canRedo()).toBe(false);
68+
69+
store.undo();
70+
tick(1);
71+
72+
expect(store.test()).toEqual('');
73+
expect(store.canUndo()).toBe(false);
74+
expect(store.canRedo()).toBe(true);
75+
});
76+
}));
77+
78+
it('restores previous state for regular store key and respects skip', fakeAsync(() => {
79+
TestBed.runInInjectionContext(() => {
80+
const Store = signalStore(
81+
withState(testState),
82+
withMethods(store => ({ updateTest: (newTest: string) => patchState(store, { test: newTest }) })),
83+
withUndoRedo({ keys: testKeys, skip: 1 })
84+
);
85+
86+
const store = new Store();
87+
tick(1);
88+
89+
store.updateTest(newValue);
90+
tick(1);
91+
expect(store.test()).toEqual(newValue);
92+
93+
store.updateTest(newerValue);
94+
tick(1);
95+
96+
store.undo();
97+
tick(1);
98+
99+
expect(store.test()).toEqual(newValue);
100+
expect(store.canUndo()).toBe(false);
101+
102+
store.undo();
103+
tick(1);
104+
105+
// should not change
106+
expect(store.test()).toEqual(newValue);
107+
});
108+
}));
109+
110+
it('undoes and redoes previous state for entity', fakeAsync(() => {
111+
const Store = signalStore(
112+
withEntities({ entity: type<{ id: string }>() }),
113+
withMethods(store => ({
114+
addEntity: (newTest: string) => patchState(store, addEntity({ id: newTest }))
115+
})),
116+
withUndoRedo()
117+
);
118+
TestBed.configureTestingModule({ providers: [Store] });
119+
TestBed.runInInjectionContext(() => {
120+
const store = inject(Store);
121+
tick(1);
122+
expect(store.entities()).toEqual([]);
123+
expect(store.canUndo()).toBe(false);
124+
expect(store.canRedo()).toBe(false);
125+
126+
store.addEntity(newValue);
127+
tick(1);
128+
expect(store.entities()).toEqual([{ id: newValue }]);
129+
expect(store.canUndo()).toBe(true);
130+
expect(store.canRedo()).toBe(false);
131+
132+
store.addEntity(newerValue);
133+
tick(1);
134+
expect(store.entities()).toEqual([{ id: newValue }, { id: newerValue }]);
135+
expect(store.canUndo()).toBe(true);
136+
expect(store.canRedo()).toBe(false);
137+
138+
store.undo();
139+
140+
expect(store.entities()).toEqual([{ id: newValue }]);
141+
expect(store.canUndo()).toBe(true);
142+
expect(store.canRedo()).toBe(true);
143+
144+
store.undo();
145+
146+
expect(store.entities()).toEqual([]);
147+
expect(store.canUndo()).toBe(false);
148+
expect(store.canRedo()).toBe(true);
149+
150+
store.redo();
151+
tick(1);
152+
153+
expect(store.entities()).toEqual([{ id: newValue }]);
154+
expect(store.canUndo()).toBe(true);
155+
expect(store.canRedo()).toBe(true);
156+
157+
// should return canRedo=false after a change
158+
store.addEntity('newest');
159+
tick(1);
160+
expect(store.canUndo()).toBe(true);
161+
expect(store.canRedo()).toBe(false);
162+
});
163+
}));
164+
165+
it('restores previous state for named entity', fakeAsync(() => {
166+
TestBed.runInInjectionContext(() => {
167+
const Store = signalStore(
168+
withEntities({ entity: type<{ id: string }>(), collection: 'flight' }),
169+
withMethods(store => ({
170+
addEntity: (newTest: string) => patchState(store, addEntity({ id: newTest }, { collection: 'flight' }))
171+
})),
172+
withCallState({ collection: 'flight' }),
173+
withUndoRedo({ collections: ['flight'] })
174+
);
175+
176+
const store = new Store();
177+
tick(1);
178+
179+
store.addEntity(newValue);
180+
tick(1);
181+
expect(store.flightEntities()).toEqual([{ id: newValue }]);
182+
expect(store.canUndo()).toBe(true);
183+
expect(store.canRedo()).toBe(false);
184+
185+
store.undo();
186+
tick(1);
187+
188+
expect(store.flightEntities()).toEqual([]);
189+
expect(store.canUndo()).toBe(false);
190+
expect(store.canRedo()).toBe(true);
191+
});
192+
}));
193+
});
194+
});

libs/ngrx-toolkit/src/lib/with-undo-redo.ts

Lines changed: 39 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,24 @@ import {
55
withComputed,
66
withHooks,
77
withMethods,
8-
EmptyFeatureResult,
8+
EmptyFeatureResult, SignalStoreFeatureResult
99
} from '@ngrx/signals';
10-
import { EntityId, EntityMap, EntityState } from '@ngrx/signals/entities';
1110
import { Signal, effect, signal, untracked, isSignal } from '@angular/core';
12-
import { Entity, capitalize } from './with-data-service';
13-
import {
14-
EntityComputed,
15-
NamedEntityComputed,
16-
} from './shared/signal-store-models';
11+
import { capitalize } from './with-data-service';
1712

1813
export type StackItem = Record<string, unknown>;
1914

2015
export type NormalizedUndoRedoOptions = {
2116
maxStackSize: number;
2217
collections?: string[];
18+
keys: string[];
19+
skip: number,
2320
};
2421

2522
const defaultOptions: NormalizedUndoRedoOptions = {
2623
maxStackSize: 100,
24+
keys: [],
25+
skip: 0,
2726
};
2827

2928
export function getUndoRedoKeys(collections?: string[]): string[] {
@@ -38,51 +37,33 @@ export function getUndoRedoKeys(collections?: string[]): string[] {
3837
return ['entityMap', 'ids', 'selectedIds', 'filter'];
3938
}
4039

41-
export function withUndoRedo<Collection extends string>(options?: {
42-
maxStackSize?: number;
43-
collections: Collection[];
44-
}): SignalStoreFeature<
45-
EmptyFeatureResult & {
46-
computed: NamedEntityComputed<Entity, Collection>;
47-
},
48-
EmptyFeatureResult & {
49-
computed: {
50-
canUndo: Signal<boolean>;
51-
canRedo: Signal<boolean>;
52-
};
53-
methods: {
54-
undo: () => void;
55-
redo: () => void;
56-
};
57-
}
58-
>;
40+
type NonNever<T> = T extends never ? never : T;
5941

60-
export function withUndoRedo(options?: {
61-
maxStackSize?: number;
62-
}): SignalStoreFeature<
63-
EmptyFeatureResult & {
64-
state: EntityState<Entity>;
65-
computed: EntityComputed<Entity>;
66-
},
42+
type ExtractEntityCollection<T> = T extends `${infer U}Entities` ? U : never;
43+
44+
type ExtractEntityCollections<Store extends SignalStoreFeatureResult> = NonNever<{
45+
[K in keyof Store['computed']]: ExtractEntityCollection<K>;
46+
}[keyof Store['computed']]>;
47+
48+
type OptionsForState<Store extends SignalStoreFeatureResult> = Partial<Omit<NormalizedUndoRedoOptions, 'collections' | 'keys'>> & {
49+
collections?: ExtractEntityCollections<Store>[];
50+
keys?: (keyof Store['state'])[];
51+
};
52+
53+
export function withUndoRedo<
54+
Input extends EmptyFeatureResult>(options?: OptionsForState<Input>): SignalStoreFeature<
55+
Input,
6756
EmptyFeatureResult & {
68-
computed: {
69-
canUndo: Signal<boolean>;
70-
canRedo: Signal<boolean>;
71-
};
72-
methods: {
73-
undo: () => void;
74-
redo: () => void;
75-
};
76-
}
77-
>;
78-
79-
export function withUndoRedo<Collection extends string>(
80-
options: {
81-
maxStackSize?: number;
82-
collections?: Collection[];
83-
} = {}
84-
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
85-
SignalStoreFeature<any, any> {
57+
computed: {
58+
canUndo: Signal<boolean>;
59+
canRedo: Signal<boolean>;
60+
};
61+
methods: {
62+
undo: () => void;
63+
redo: () => void;
64+
};
65+
}
66+
> {
8667
let previous: StackItem | null = null;
8768
let skipOnce = false;
8869

@@ -107,7 +88,7 @@ SignalStoreFeature<any, any> {
10788
canRedo.set(redoStack.length !== 0);
10889
};
10990

110-
const keys = getUndoRedoKeys(normalized?.collections);
91+
const keys = [...getUndoRedoKeys(normalized.collections), ...normalized.keys];
11192

11293
return signalStoreFeature(
11394
withComputed(() => ({
@@ -147,10 +128,10 @@ SignalStoreFeature<any, any> {
147128
},
148129
})),
149130
withHooks({
150-
onInit(store: Record<string, unknown>) {
131+
onInit(store) {
151132
effect(() => {
152133
const cand = keys.reduce((acc, key) => {
153-
const s = store[key];
134+
const s = (store as Record<string | keyof Input['state'], unknown>)[key];
154135
if (s && isSignal(s)) {
155136
return {
156137
...acc,
@@ -160,6 +141,11 @@ SignalStoreFeature<any, any> {
160141
return acc;
161142
}, {});
162143

144+
if (normalized.skip > 0) {
145+
normalized.skip--;
146+
return;
147+
}
148+
163149
if (skipOnce) {
164150
skipOnce = false;
165151
return;

0 commit comments

Comments
 (0)