Skip to content

Commit

Permalink
feat(signals): add withReset() feature to SignalStore
Browse files Browse the repository at this point in the history
`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
  • Loading branch information
rainerhahnekamp authored Jan 6, 2025
1 parent f3bad94 commit 90a64b3
Show file tree
Hide file tree
Showing 10 changed files with 417 additions and 2 deletions.
30 changes: 30 additions & 0 deletions apps/demo/e2e/reset.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test, expect } from '@playwright/test';

test('has title', async ({ page }) => {
await page.goto('');
await page.getByRole('link', { name: 'reset' }).click();
await page
.getByRole('row', { name: 'Go for a walk' })
.getByRole('checkbox')
.click();
await page
.getByRole('row', { name: 'Exercise' })
.getByRole('checkbox')
.click();

await expect(
page.getByRole('row', { name: 'Go for a walk' }).getByRole('checkbox')
).toBeChecked();
await expect(
page.getByRole('row', { name: 'Exercise' }).getByRole('checkbox')
).toBeChecked();

await page.getByRole('button', { name: 'Reset State' }).click();

await expect(
page.getByRole('row', { name: 'Go for a walk' }).getByRole('checkbox')
).not.toBeChecked();
await expect(
page.getByRole('row', { name: 'Exercise' }).getByRole('checkbox')
).not.toBeChecked();
});
1 change: 1 addition & 0 deletions apps/demo/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
>Redux Connector</a
>
<a mat-list-item routerLink="/todo-storage-sync">withStorageSync</a>
<a mat-list-item routerLink="/reset">withReset</a>
</mat-nav-list>
</mat-drawer>
<mat-drawer-content>
Expand Down
5 changes: 5 additions & 0 deletions apps/demo/src/app/lazy-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,9 @@ export const lazyRoutes: Route[] = [
providers: [provideFlightStore()],
component: FlightSearchReducConnectorComponent,
},
{
path: 'reset',
loadComponent: () =>
import('./reset/todo.component').then((m) => m.TodoComponent),
},
];
84 changes: 84 additions & 0 deletions apps/demo/src/app/reset/todo-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
getState,
patchState,
signalStore,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { addEntity, updateEntity, withEntities } from '@ngrx/signals/entities';
import { setResetState, withReset } from '@angular-architects/ngrx-toolkit';

export interface Todo {
id: number;
name: string;
finished: boolean;
description?: string;
deadline?: Date;
}

export type AddTodo = Omit<Todo, 'id'>;

export const TodoStore = signalStore(
{ providedIn: 'root' },
withReset(),
withEntities<Todo>(),
withState({
selectedIds: [] as number[],
}),
withMethods((store) => {
let currentId = 0;
return {
_add(todo: AddTodo) {
patchState(store, addEntity({ ...todo, id: ++currentId }));
},
toggleFinished(id: number) {
const todo = store.entityMap()[id];
patchState(
store,
updateEntity({ id, changes: { finished: !todo.finished } })
);
},
};
}),
withHooks({
onInit: (store) => {
store._add({
name: 'Go for a Walk',
finished: false,
description:
'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.',
});

store._add({
name: 'Read a Book',
finished: false,
description:
'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.',
});

store._add({
name: 'Write a Journal',
finished: false,
description:
'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.',
});

store._add({
name: 'Exercise',
finished: false,
description:
'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.',
});

store._add({
name: 'Cook a Meal',
finished: false,
description:
'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.',
});

setResetState(store, getState(store));
},
})
);
70 changes: 70 additions & 0 deletions apps/demo/src/app/reset/todo.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Component, effect, inject } from '@angular/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { SelectionModel } from '@angular/cdk/collections';
import { Todo, TodoStore } from './todo-store';
import { MatButton } from '@angular/material/button';

@Component({
template: `
<div class="button">
<button mat-raised-button (click)="resetState()">Reset State</button>
</div>
<div>
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<!-- Checkbox Column -->
<ng-container matColumnDef="finished">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row" class="actions">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="toggleFinished(row)"
[checked]="row.finished"
>
</mat-checkbox>
</mat-cell>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
<mat-cell *matCellDef="let element">{{ element.name }}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row
*matRowDef="let row; columns: displayedColumns"
(click)="selection.toggle(row)"
></mat-row>
</mat-table>
</div>
`,
styles: `.button {
margin-bottom: 1em;
}
`,
imports: [MatCheckboxModule, MatIconModule, MatTableModule, MatButton],
})
export class TodoComponent {
todoStore = inject(TodoStore);

displayedColumns: string[] = ['finished', 'name'];
dataSource = new MatTableDataSource<Todo>([]);
selection = new SelectionModel<Todo>(true, []);

constructor() {
effect(() => {
this.dataSource.data = this.todoStore.entities();
});
}

toggleFinished(todo: Todo) {
this.todoStore.toggleFinished(todo.id);
}

resetState() {
this.todoStore.resetState();
}
}
5 changes: 3 additions & 2 deletions docs/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ It offers extensions like:
- [⭐️ Devtools](./with-devtools): Integration into Redux Devtools
- [Redux](./with-redux): Possibility to use the Redux Pattern (Reducer, Actions, Effects)
- [DataService](./with-data-service): Builds on top of `withEntities` and adds the backend synchronization to it
- [Storage Sync](./with-storage-sync): Synchronize the Store with Web Storage
- [Undo Redo](./with-undo-redo): Add Undo/Redo functionality to your store
- [Storage Sync](./with-storage-sync): Synchronizes the Store with Web Storage
- [Undo Redo](./with-undo-redo): Adds Undo/Redo functionality to your store
- [Reset](./with-reset): Adds a `resetState` method to your store

To install it, run

Expand Down
49 changes: 49 additions & 0 deletions docs/docs/with-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: withReset()
---

`withReset()` adds a method the state of the Signal Store to its initial value. Nothing more to say about it 😅

Example:

```typescript
const Store = signalStore(
withState({
user: { id: 1, name: 'Konrad' },
address: { city: 'Vienna', zip: '1010' },
}),
withReset(), // <-- the reset extension
withMethods((store) => ({
changeUser(id: number, name: string) {
patchState(store, { user: { id, name } });
},
changeUserName(name: string) {
patchState(store, (value) => ({ user: { ...value.user, name } }));
},
}))
);

const store = new Store();

store.changeUser(2, 'John');
console.log(store.user()); // { id: 2, name: 'John' }

store.resetState();
console.log(store.user()); // { id: 1, name: 'Konrad' }
```

## `setResetState()`

If you want to set a custom reset state, you can use the `setResetState()` method.

Example:

```typescript
// continue from the previous example

setResetState(store, { user: { id: 3, name: 'Jane' }, address: { city: 'Berlin', zip: '10115' } });
store.changeUser(4, 'Alice');

store.resetState();
console.log(store.user()); // { id: 3, name: 'Jane' }
```
1 change: 1 addition & 0 deletions libs/ngrx-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './lib/with-undo-redo';
export * from './lib/with-data-service';
export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
export * from './lib/with-pagination';
export { withReset, setResetState } from './lib/with-reset';
112 changes: 112 additions & 0 deletions libs/ngrx-toolkit/src/lib/with-reset.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
getState,
patchState,
signalStore,
withMethods,
withState,
} from '@ngrx/signals';
import { setResetState, withReset } from './with-reset';
import { TestBed } from '@angular/core/testing';
import { effect } from '@angular/core';

describe('withReset', () => {
const setup = () => {
const initialState = {
user: { id: 1, name: 'Konrad' },
address: { city: 'Vienna', zip: '1010' },
};

const Store = signalStore(
withState(initialState),
withReset(),
withMethods((store) => ({
changeUser(id: number, name: string) {
patchState(store, { user: { id, name } });
},
changeUserName(name: string) {
patchState(store, (value) => ({ user: { ...value.user, name } }));
},
changeAddress(city: string, zip: string) {
patchState(store, { address: { city, zip } });
},
}))
);

const store = TestBed.configureTestingModule({
providers: [Store],
}).inject(Store);

return { store, initialState };
};

it('should reset state to initial state', () => {
const { store, initialState } = setup();

store.changeUser(2, 'Max');
expect(getState(store)).toMatchObject({
user: { id: 2, name: 'Max' },
});
store.resetState();
expect(getState(store)).toStrictEqual(initialState);
});

it('should not fire if reset is called on unchanged state', () => {
const { store } = setup();
let effectCounter = 0;
TestBed.runInInjectionContext(() => {
effect(() => {
store.user();
effectCounter++;
});
});
TestBed.flushEffects();
store.resetState();
TestBed.flushEffects();
expect(effectCounter).toBe(1);
});

it('should not fire on props which are unchanged', () => {
const { store } = setup();
let effectCounter = 0;
TestBed.runInInjectionContext(() => {
effect(() => {
store.address();
effectCounter++;
});
});

TestBed.flushEffects();
expect(effectCounter).toBe(1);
store.changeUserName('Max');
TestBed.flushEffects();
store.changeUser(2, 'Ludwig');
TestBed.flushEffects();
expect(effectCounter).toBe(1);
});

it('should be possible to change the reset state', () => {
const { store } = setup();

setResetState(store, {
user: { id: 2, name: 'Max' },
address: { city: 'London', zip: 'SW1' },
});

store.changeUser(3, 'Ludwig');
store.changeAddress('Paris', '75001');

store.resetState();
expect(getState(store)).toEqual({
user: { id: 2, name: 'Max' },
address: { city: 'London', zip: 'SW1' },
});
});

it('should throw on setResetState if store is not configured with withReset()', () => {
const Store = signalStore({ providedIn: 'root' }, withState({}));
const store = TestBed.inject(Store);
expect(() => setResetState(store, {})).toThrowError(
'Cannot set reset state, since store is not configured with withReset()'
);
});
});
Loading

0 comments on commit 90a64b3

Please sign in to comment.