Skip to content

Commit

Permalink
feat: add undo-redo skip and keys options, docs and unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
marcindz88 authored and rainerhahnekamp committed Aug 8, 2024
1 parent cb77fc9 commit e9d9634
Show file tree
Hide file tree
Showing 3 changed files with 272 additions and 53 deletions.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
194 changes: 194 additions & 0 deletions libs/ngrx-toolkit/src/lib/with-undo-redo.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}));
});
});
92 changes: 39 additions & 53 deletions libs/ngrx-toolkit/src/lib/with-undo-redo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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[] {
Expand All @@ -38,51 +37,33 @@ export function getUndoRedoKeys(collections?: string[]): string[] {
return ['entityMap', 'ids', 'selectedIds', 'filter'];
}

export function withUndoRedo<Collection extends string>(options?: {
maxStackSize?: number;
collections: Collection[];
}): SignalStoreFeature<
EmptyFeatureResult & {
computed: NamedEntityComputed<Entity, Collection>;
},
EmptyFeatureResult & {
computed: {
canUndo: Signal<boolean>;
canRedo: Signal<boolean>;
};
methods: {
undo: () => void;
redo: () => void;
};
}
>;
type NonNever<T> = T extends never ? never : T;

export function withUndoRedo(options?: {
maxStackSize?: number;
}): SignalStoreFeature<
EmptyFeatureResult & {
state: EntityState<Entity>;
computed: EntityComputed<Entity>;
},
type ExtractEntityCollection<T> = T extends `${infer U}Entities` ? U : never;

type ExtractEntityCollections<Store extends SignalStoreFeatureResult> = NonNever<{
[K in keyof Store['computed']]: ExtractEntityCollection<K>;
}[keyof Store['computed']]>;

type OptionsForState<Store extends SignalStoreFeatureResult> = Partial<Omit<NormalizedUndoRedoOptions, 'collections' | 'keys'>> & {
collections?: ExtractEntityCollections<Store>[];
keys?: (keyof Store['state'])[];
};

export function withUndoRedo<
Input extends EmptyFeatureResult>(options?: OptionsForState<Input>): SignalStoreFeature<
Input,
EmptyFeatureResult & {
computed: {
canUndo: Signal<boolean>;
canRedo: Signal<boolean>;
};
methods: {
undo: () => void;
redo: () => void;
};
}
>;

export function withUndoRedo<Collection extends string>(
options: {
maxStackSize?: number;
collections?: Collection[];
} = {}
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
SignalStoreFeature<any, any> {
computed: {
canUndo: Signal<boolean>;
canRedo: Signal<boolean>;
};
methods: {
undo: () => void;
redo: () => void;
};
}
> {
let previous: StackItem | null = null;
let skipOnce = false;

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

const keys = getUndoRedoKeys(normalized?.collections);
const keys = [...getUndoRedoKeys(normalized.collections), ...normalized.keys];

return signalStoreFeature(
withComputed(() => ({
Expand Down Expand Up @@ -147,10 +128,10 @@ SignalStoreFeature<any, any> {
},
})),
withHooks({
onInit(store: Record<string, unknown>) {
onInit(store) {
effect(() => {
const cand = keys.reduce((acc, key) => {
const s = store[key];
const s = (store as Record<string | keyof Input['state'], unknown>)[key];
if (s && isSignal(s)) {
return {
...acc,
Expand All @@ -160,6 +141,11 @@ SignalStoreFeature<any, any> {
return acc;
}, {});

if (normalized.skip > 0) {
normalized.skip--;
return;
}

if (skipOnce) {
skipOnce = false;
return;
Expand Down

0 comments on commit e9d9634

Please sign in to comment.