Skip to content

Commit 90a64b3

Browse files
feat(signals): add withReset() feature to SignalStore
`withReset()` adds a `resetState` method to reset the state of a SignalStore instance. By default, the initial state will be the reset state. The `setResetState` method allows customizing the reset state. - Unit and E2E tests added - Demo app updated with an example - Documentation updated
1 parent f3bad94 commit 90a64b3

File tree

10 files changed

+417
-2
lines changed

10 files changed

+417
-2
lines changed

apps/demo/e2e/reset.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('has title', async ({ page }) => {
4+
await page.goto('');
5+
await page.getByRole('link', { name: 'reset' }).click();
6+
await page
7+
.getByRole('row', { name: 'Go for a walk' })
8+
.getByRole('checkbox')
9+
.click();
10+
await page
11+
.getByRole('row', { name: 'Exercise' })
12+
.getByRole('checkbox')
13+
.click();
14+
15+
await expect(
16+
page.getByRole('row', { name: 'Go for a walk' }).getByRole('checkbox')
17+
).toBeChecked();
18+
await expect(
19+
page.getByRole('row', { name: 'Exercise' }).getByRole('checkbox')
20+
).toBeChecked();
21+
22+
await page.getByRole('button', { name: 'Reset State' }).click();
23+
24+
await expect(
25+
page.getByRole('row', { name: 'Go for a walk' }).getByRole('checkbox')
26+
).not.toBeChecked();
27+
await expect(
28+
page.getByRole('row', { name: 'Exercise' }).getByRole('checkbox')
29+
).not.toBeChecked();
30+
});

apps/demo/src/app/app.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
>Redux Connector</a
2020
>
2121
<a mat-list-item routerLink="/todo-storage-sync">withStorageSync</a>
22+
<a mat-list-item routerLink="/reset">withReset</a>
2223
</mat-nav-list>
2324
</mat-drawer>
2425
<mat-drawer-content>

apps/demo/src/app/lazy-routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,9 @@ export const lazyRoutes: Route[] = [
3333
providers: [provideFlightStore()],
3434
component: FlightSearchReducConnectorComponent,
3535
},
36+
{
37+
path: 'reset',
38+
loadComponent: () =>
39+
import('./reset/todo.component').then((m) => m.TodoComponent),
40+
},
3641
];

apps/demo/src/app/reset/todo-store.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
getState,
3+
patchState,
4+
signalStore,
5+
withHooks,
6+
withMethods,
7+
withState,
8+
} from '@ngrx/signals';
9+
import { addEntity, updateEntity, withEntities } from '@ngrx/signals/entities';
10+
import { setResetState, withReset } from '@angular-architects/ngrx-toolkit';
11+
12+
export interface Todo {
13+
id: number;
14+
name: string;
15+
finished: boolean;
16+
description?: string;
17+
deadline?: Date;
18+
}
19+
20+
export type AddTodo = Omit<Todo, 'id'>;
21+
22+
export const TodoStore = signalStore(
23+
{ providedIn: 'root' },
24+
withReset(),
25+
withEntities<Todo>(),
26+
withState({
27+
selectedIds: [] as number[],
28+
}),
29+
withMethods((store) => {
30+
let currentId = 0;
31+
return {
32+
_add(todo: AddTodo) {
33+
patchState(store, addEntity({ ...todo, id: ++currentId }));
34+
},
35+
toggleFinished(id: number) {
36+
const todo = store.entityMap()[id];
37+
patchState(
38+
store,
39+
updateEntity({ id, changes: { finished: !todo.finished } })
40+
);
41+
},
42+
};
43+
}),
44+
withHooks({
45+
onInit: (store) => {
46+
store._add({
47+
name: 'Go for a Walk',
48+
finished: false,
49+
description:
50+
'Go for a walk in the park to relax and enjoy nature. Walking is a great way to clear your mind and get some exercise. It can help reduce stress and improve your mood. Make sure to wear comfortable shoes and bring a bottle of water. Enjoy the fresh air and take in the scenery around you.',
51+
});
52+
53+
store._add({
54+
name: 'Read a Book',
55+
finished: false,
56+
description:
57+
'Spend some time reading a book. It can be a novel, a non-fiction book, or any other genre you enjoy. Reading can help you relax and learn new things.',
58+
});
59+
60+
store._add({
61+
name: 'Write a Journal',
62+
finished: false,
63+
description:
64+
'Take some time to write in your journal. Reflect on your day, your thoughts, and your feelings. Journaling can be a great way to process emotions and document your life.',
65+
});
66+
67+
store._add({
68+
name: 'Exercise',
69+
finished: false,
70+
description:
71+
'Do some physical exercise. It can be a workout, a run, or any other form of exercise you enjoy. Exercise is important for maintaining physical and mental health.',
72+
});
73+
74+
store._add({
75+
name: 'Cook a Meal',
76+
finished: false,
77+
description:
78+
'Prepare a meal for yourself or your family. Cooking can be a fun and rewarding activity. Try out a new recipe or make one of your favorite dishes.',
79+
});
80+
81+
setResetState(store, getState(store));
82+
},
83+
})
84+
);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Component, effect, inject } from '@angular/core';
2+
import { MatCheckboxModule } from '@angular/material/checkbox';
3+
import { MatIconModule } from '@angular/material/icon';
4+
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
5+
import { SelectionModel } from '@angular/cdk/collections';
6+
import { Todo, TodoStore } from './todo-store';
7+
import { MatButton } from '@angular/material/button';
8+
9+
@Component({
10+
template: `
11+
<div class="button">
12+
<button mat-raised-button (click)="resetState()">Reset State</button>
13+
</div>
14+
15+
<div>
16+
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
17+
<!-- Checkbox Column -->
18+
<ng-container matColumnDef="finished">
19+
<mat-header-cell *matHeaderCellDef></mat-header-cell>
20+
<mat-cell *matCellDef="let row" class="actions">
21+
<mat-checkbox
22+
(click)="$event.stopPropagation()"
23+
(change)="toggleFinished(row)"
24+
[checked]="row.finished"
25+
>
26+
</mat-checkbox>
27+
</mat-cell>
28+
</ng-container>
29+
30+
<!-- Name Column -->
31+
<ng-container matColumnDef="name">
32+
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
33+
<mat-cell *matCellDef="let element">{{ element.name }}</mat-cell>
34+
</ng-container>
35+
36+
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
37+
<mat-row
38+
*matRowDef="let row; columns: displayedColumns"
39+
(click)="selection.toggle(row)"
40+
></mat-row>
41+
</mat-table>
42+
</div>
43+
`,
44+
styles: `.button {
45+
margin-bottom: 1em;
46+
}
47+
`,
48+
imports: [MatCheckboxModule, MatIconModule, MatTableModule, MatButton],
49+
})
50+
export class TodoComponent {
51+
todoStore = inject(TodoStore);
52+
53+
displayedColumns: string[] = ['finished', 'name'];
54+
dataSource = new MatTableDataSource<Todo>([]);
55+
selection = new SelectionModel<Todo>(true, []);
56+
57+
constructor() {
58+
effect(() => {
59+
this.dataSource.data = this.todoStore.entities();
60+
});
61+
}
62+
63+
toggleFinished(todo: Todo) {
64+
this.todoStore.toggleFinished(todo.id);
65+
}
66+
67+
resetState() {
68+
this.todoStore.resetState();
69+
}
70+
}

docs/docs/extensions.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ It offers extensions like:
99
- [⭐️ Devtools](./with-devtools): Integration into Redux Devtools
1010
- [Redux](./with-redux): Possibility to use the Redux Pattern (Reducer, Actions, Effects)
1111
- [DataService](./with-data-service): Builds on top of `withEntities` and adds the backend synchronization to it
12-
- [Storage Sync](./with-storage-sync): Synchronize the Store with Web Storage
13-
- [Undo Redo](./with-undo-redo): Add Undo/Redo functionality to your store
12+
- [Storage Sync](./with-storage-sync): Synchronizes the Store with Web Storage
13+
- [Undo Redo](./with-undo-redo): Adds Undo/Redo functionality to your store
14+
- [Reset](./with-reset): Adds a `resetState` method to your store
1415

1516
To install it, run
1617

docs/docs/with-reset.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
title: withReset()
3+
---
4+
5+
`withReset()` adds a method the state of the Signal Store to its initial value. Nothing more to say about it 😅
6+
7+
Example:
8+
9+
```typescript
10+
const Store = signalStore(
11+
withState({
12+
user: { id: 1, name: 'Konrad' },
13+
address: { city: 'Vienna', zip: '1010' },
14+
}),
15+
withReset(), // <-- the reset extension
16+
withMethods((store) => ({
17+
changeUser(id: number, name: string) {
18+
patchState(store, { user: { id, name } });
19+
},
20+
changeUserName(name: string) {
21+
patchState(store, (value) => ({ user: { ...value.user, name } }));
22+
},
23+
}))
24+
);
25+
26+
const store = new Store();
27+
28+
store.changeUser(2, 'John');
29+
console.log(store.user()); // { id: 2, name: 'John' }
30+
31+
store.resetState();
32+
console.log(store.user()); // { id: 1, name: 'Konrad' }
33+
```
34+
35+
## `setResetState()`
36+
37+
If you want to set a custom reset state, you can use the `setResetState()` method.
38+
39+
Example:
40+
41+
```typescript
42+
// continue from the previous example
43+
44+
setResetState(store, { user: { id: 3, name: 'Jane' }, address: { city: 'Berlin', zip: '10115' } });
45+
store.changeUser(4, 'Alice');
46+
47+
store.resetState();
48+
console.log(store.user()); // { id: 3, name: 'Jane' }
49+
```

libs/ngrx-toolkit/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from './lib/with-undo-redo';
1111
export * from './lib/with-data-service';
1212
export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
1313
export * from './lib/with-pagination';
14+
export { withReset, setResetState } from './lib/with-reset';
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
getState,
3+
patchState,
4+
signalStore,
5+
withMethods,
6+
withState,
7+
} from '@ngrx/signals';
8+
import { setResetState, withReset } from './with-reset';
9+
import { TestBed } from '@angular/core/testing';
10+
import { effect } from '@angular/core';
11+
12+
describe('withReset', () => {
13+
const setup = () => {
14+
const initialState = {
15+
user: { id: 1, name: 'Konrad' },
16+
address: { city: 'Vienna', zip: '1010' },
17+
};
18+
19+
const Store = signalStore(
20+
withState(initialState),
21+
withReset(),
22+
withMethods((store) => ({
23+
changeUser(id: number, name: string) {
24+
patchState(store, { user: { id, name } });
25+
},
26+
changeUserName(name: string) {
27+
patchState(store, (value) => ({ user: { ...value.user, name } }));
28+
},
29+
changeAddress(city: string, zip: string) {
30+
patchState(store, { address: { city, zip } });
31+
},
32+
}))
33+
);
34+
35+
const store = TestBed.configureTestingModule({
36+
providers: [Store],
37+
}).inject(Store);
38+
39+
return { store, initialState };
40+
};
41+
42+
it('should reset state to initial state', () => {
43+
const { store, initialState } = setup();
44+
45+
store.changeUser(2, 'Max');
46+
expect(getState(store)).toMatchObject({
47+
user: { id: 2, name: 'Max' },
48+
});
49+
store.resetState();
50+
expect(getState(store)).toStrictEqual(initialState);
51+
});
52+
53+
it('should not fire if reset is called on unchanged state', () => {
54+
const { store } = setup();
55+
let effectCounter = 0;
56+
TestBed.runInInjectionContext(() => {
57+
effect(() => {
58+
store.user();
59+
effectCounter++;
60+
});
61+
});
62+
TestBed.flushEffects();
63+
store.resetState();
64+
TestBed.flushEffects();
65+
expect(effectCounter).toBe(1);
66+
});
67+
68+
it('should not fire on props which are unchanged', () => {
69+
const { store } = setup();
70+
let effectCounter = 0;
71+
TestBed.runInInjectionContext(() => {
72+
effect(() => {
73+
store.address();
74+
effectCounter++;
75+
});
76+
});
77+
78+
TestBed.flushEffects();
79+
expect(effectCounter).toBe(1);
80+
store.changeUserName('Max');
81+
TestBed.flushEffects();
82+
store.changeUser(2, 'Ludwig');
83+
TestBed.flushEffects();
84+
expect(effectCounter).toBe(1);
85+
});
86+
87+
it('should be possible to change the reset state', () => {
88+
const { store } = setup();
89+
90+
setResetState(store, {
91+
user: { id: 2, name: 'Max' },
92+
address: { city: 'London', zip: 'SW1' },
93+
});
94+
95+
store.changeUser(3, 'Ludwig');
96+
store.changeAddress('Paris', '75001');
97+
98+
store.resetState();
99+
expect(getState(store)).toEqual({
100+
user: { id: 2, name: 'Max' },
101+
address: { city: 'London', zip: 'SW1' },
102+
});
103+
});
104+
105+
it('should throw on setResetState if store is not configured with withReset()', () => {
106+
const Store = signalStore({ providedIn: 'root' }, withState({}));
107+
const store = TestBed.inject(Store);
108+
expect(() => setResetState(store, {})).toThrowError(
109+
'Cannot set reset state, since store is not configured with withReset()'
110+
);
111+
});
112+
});

0 commit comments

Comments
 (0)