Skip to content

Commit 05a5ad5

Browse files
rainerhahnekampmsari-ipe-ext-1wolfmanfxmichael-small
authored
feat(devtools): add withTrackedReducer for events [backport v20]
Implement `withTrackedReducer` to track state changes within the events plugin. This utility automatically derives the event name, streamlining the tracking process. Users must replace usages of `withReducer` with `withTrackedReducer` to enable this functionality. Note: Native `withReducer` support is planned for the future but requires upstream support from `@ngrx/signals`. Closes #231 Co-authored-by: Murat Sari <murat.sari@coderabbit.at> Co-authored-by: Murat Sari <wolfmanfx@gmail.com> Co-authored-by: Michael Small <33669563+michael-small@users.noreply.github.com>
1 parent 369f5a1 commit 05a5ad5

File tree

14 files changed

+723
-14
lines changed

14 files changed

+723
-14
lines changed

apps/demo/e2e/devtools.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ test.describe('DevTools', () => {
88
await page.goto('');
99
const errors = [];
1010
page.on('pageerror', (error) => errors.push(error));
11-
await page.getByRole('link', { name: 'DevTools' }).click();
11+
await page.getByRole('link', { name: 'DevTools', exact: true }).click();
1212
await expect(
1313
page.getByRole('row', { name: 'Go for a walk' }),
1414
).toBeVisible();
@@ -30,7 +30,7 @@ test.describe('DevTools', () => {
3030
},
3131
};
3232
});
33-
await page.getByRole('link', { name: 'DevTools' }).click();
33+
await page.getByRole('link', { name: 'DevTools', exact: true }).click();
3434
await page
3535
.getByRole('row', { name: 'Go for a walk' })
3636
.getByRole('checkbox')

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<mat-drawer mode="side" #drawer [opened]="opened()">
99
<mat-nav-list>
1010
<a mat-list-item routerLink="/todo">DevTools</a>
11+
<a mat-list-item routerLink="/events-sample">Events + DevTools Sample</a>
1112
<a mat-list-item routerLink="/flight-search">withRedux</a>
1213
<a mat-list-item routerLink="/flight-search-data-service-simple"
1314
>withDataService (Simple)</a
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { type } from '@ngrx/signals';
2+
import { eventGroup } from '@ngrx/signals/events';
3+
import { Book } from './book.model';
4+
5+
export const bookEvents = eventGroup({
6+
source: 'Book Store',
7+
events: {
8+
loadBooks: type<void>(),
9+
bookSelected: type<{ bookId: string }>(),
10+
selectionCleared: type<void>(),
11+
filterUpdated: type<{ filter: string }>(),
12+
stockToggled: type<{ bookId: string }>(),
13+
bookAdded: type<{ book: Book }>(),
14+
bookRemoved: type<{ bookId: string }>(),
15+
},
16+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export interface Book {
2+
id: string;
3+
title: string;
4+
author: string;
5+
year: number;
6+
isbn: string;
7+
inStock: boolean;
8+
}
9+
10+
export const mockBooks: Book[] = [
11+
{
12+
id: '1',
13+
title: 'The Great Gatsby',
14+
author: 'F. Scott Fitzgerald',
15+
year: 1925,
16+
isbn: '978-0-7432-7356-5',
17+
inStock: true,
18+
},
19+
{
20+
id: '2',
21+
title: '1984',
22+
author: 'George Orwell',
23+
year: 1949,
24+
isbn: '978-0-452-28423-4',
25+
inStock: true,
26+
},
27+
{
28+
id: '3',
29+
title: 'To Kill a Mockingbird',
30+
author: 'Harper Lee',
31+
year: 1960,
32+
isbn: '978-0-06-112008-4',
33+
inStock: false,
34+
},
35+
{
36+
id: '4',
37+
title: 'Pride and Prejudice',
38+
author: 'Jane Austen',
39+
year: 1813,
40+
isbn: '978-0-14-143951-8',
41+
inStock: true,
42+
},
43+
{
44+
id: '5',
45+
title: 'The Catcher in the Rye',
46+
author: 'J.D. Salinger',
47+
year: 1951,
48+
isbn: '978-0-316-76948-0',
49+
inStock: false,
50+
},
51+
];
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {
2+
withDevtools,
3+
withGlitchTracking,
4+
withTrackedReducer,
5+
} from '@angular-architects/ngrx-toolkit';
6+
import { signalStore, withComputed, withHooks, withState } from '@ngrx/signals';
7+
import { injectDispatch, on } from '@ngrx/signals/events';
8+
import { bookEvents } from './book-events';
9+
import { Book, mockBooks } from './book.model';
10+
11+
export const BookStore = signalStore(
12+
{ providedIn: 'root' },
13+
withDevtools('book-store-events', withGlitchTracking()),
14+
withState({
15+
books: [] as Book[],
16+
selectedBookId: null as string | null,
17+
filter: '',
18+
}),
19+
20+
withComputed((store) => ({
21+
selectedBook: () => {
22+
const id = store.selectedBookId();
23+
return id ? store.books().find((b) => b.id === id) || null : null;
24+
},
25+
26+
filteredBooks: () => {
27+
const filter = store.filter().toLowerCase();
28+
if (!filter) return store.books();
29+
30+
return store
31+
.books()
32+
.filter(
33+
(book) =>
34+
book.title.toLowerCase().includes(filter) ||
35+
book.author.toLowerCase().includes(filter),
36+
);
37+
},
38+
39+
totalBooks: () => store.books().length,
40+
41+
availableBooks: () => store.books().filter((book) => book.inStock).length,
42+
})),
43+
44+
withTrackedReducer(
45+
on(bookEvents.loadBooks, () => ({
46+
books: mockBooks,
47+
})),
48+
49+
on(bookEvents.bookSelected, ({ payload }) => ({
50+
selectedBookId: payload.bookId,
51+
})),
52+
53+
on(bookEvents.selectionCleared, () => ({
54+
selectedBookId: null,
55+
})),
56+
57+
on(bookEvents.filterUpdated, ({ payload }) => ({
58+
filter: payload.filter,
59+
})),
60+
61+
on(bookEvents.stockToggled, (event, state) => ({
62+
books: state.books.map((book) =>
63+
book.id === event.payload.bookId
64+
? { ...book, inStock: !book.inStock }
65+
: book,
66+
),
67+
})),
68+
69+
on(bookEvents.bookAdded, (event, state) => ({
70+
books: [...state.books, event.payload.book],
71+
})),
72+
73+
on(bookEvents.bookRemoved, (event, state) => ({
74+
books: state.books.filter((book) => book.id !== event.payload.bookId),
75+
})),
76+
),
77+
withHooks({
78+
onInit() {
79+
injectDispatch(bookEvents).loadBooks();
80+
},
81+
}),
82+
);
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { CommonModule } from '@angular/common';
2+
import { Component, inject } from '@angular/core';
3+
import { FormsModule } from '@angular/forms';
4+
import { MatButtonModule } from '@angular/material/button';
5+
import { MatCardModule } from '@angular/material/card';
6+
import { MatChipsModule } from '@angular/material/chips';
7+
import { MatFormFieldModule } from '@angular/material/form-field';
8+
import { MatGridListModule } from '@angular/material/grid-list';
9+
import { MatIconModule } from '@angular/material/icon';
10+
import { MatInputModule } from '@angular/material/input';
11+
import { MatToolbarModule } from '@angular/material/toolbar';
12+
import { injectDispatch } from '@ngrx/signals/events';
13+
import { bookEvents } from './book-events';
14+
import { BookStore } from './book.store';
15+
16+
@Component({
17+
selector: 'demo-events-sample',
18+
imports: [
19+
CommonModule,
20+
FormsModule,
21+
MatCardModule,
22+
MatButtonModule,
23+
MatInputModule,
24+
MatFormFieldModule,
25+
MatIconModule,
26+
MatChipsModule,
27+
MatGridListModule,
28+
MatToolbarModule,
29+
],
30+
template: `
31+
<mat-toolbar color="primary">
32+
<span>Book Store with Event Tracking</span>
33+
</mat-toolbar>
34+
35+
<mat-card>
36+
<mat-card-content>
37+
<mat-form-field appearance="outline">
38+
<mat-label>Search books</mat-label>
39+
<input
40+
matInput
41+
[(ngModel)]="filterText"
42+
(ngModelChange)="dispatch.filterUpdated({ filter: $event })"
43+
placeholder="Filter by title or author..."
44+
/>
45+
<mat-icon matSuffix>search</mat-icon>
46+
</mat-form-field>
47+
48+
<button mat-raised-button color="primary" (click)="addRandomBook()">
49+
<mat-icon>add</mat-icon> Add Book
50+
</button>
51+
<button mat-raised-button (click)="dispatch.selectionCleared()">
52+
Clear Selection
53+
</button>
54+
</mat-card-content>
55+
</mat-card>
56+
57+
<mat-card>
58+
<mat-card-content>
59+
<mat-chip-set>
60+
<mat-chip>Total: {{ store.totalBooks() }}</mat-chip>
61+
<mat-chip>In Stock: {{ store.availableBooks() }}</mat-chip>
62+
<mat-chip>Filtered: {{ store.filteredBooks().length }}</mat-chip>
63+
</mat-chip-set>
64+
</mat-card-content>
65+
</mat-card>
66+
67+
<mat-grid-list cols="3" rowHeight="350px" gutterSize="16">
68+
@for (book of store.filteredBooks(); track book.id) {
69+
<mat-grid-tile>
70+
<mat-card
71+
[style.border]="
72+
store.selectedBook()?.id === book.id
73+
? '2px solid #4caf50'
74+
: 'none'
75+
"
76+
(click)="dispatch.bookSelected({ bookId: book.id })"
77+
>
78+
<mat-card-header>
79+
<mat-card-title>{{ book.title }}</mat-card-title>
80+
<mat-card-subtitle
81+
>{{ book.author }} ({{ book.year }})</mat-card-subtitle
82+
>
83+
</mat-card-header>
84+
<mat-card-content>
85+
<p>ISBN: {{ book.isbn }}</p>
86+
<mat-chip [color]="book.inStock ? 'primary' : 'warn'">
87+
{{ book.inStock ? 'In Stock' : 'Out of Stock' }}
88+
</mat-chip>
89+
</mat-card-content>
90+
<mat-card-actions>
91+
<button mat-button (click)="toggleStock(book.id, $event)">
92+
Toggle Stock
93+
</button>
94+
<button
95+
mat-button
96+
color="warn"
97+
(click)="removeBook(book.id, $event)"
98+
>
99+
Remove
100+
</button>
101+
</mat-card-actions>
102+
</mat-card>
103+
</mat-grid-tile>
104+
} @empty {
105+
<mat-grid-tile [colspan]="3">
106+
<p>
107+
@if (store.filter()) {
108+
No books found matching "{{ store.filter() }}"
109+
} @else {
110+
No books available
111+
}
112+
</p>
113+
</mat-grid-tile>
114+
}
115+
</mat-grid-list>
116+
117+
@if (store.selectedBook(); as book) {
118+
<mat-card>
119+
<mat-card-header>
120+
<mat-card-title>Selected: {{ book.title }}</mat-card-title>
121+
</mat-card-header>
122+
<mat-card-content>
123+
<p>Author: {{ book.author }}</p>
124+
<p>Year: {{ book.year }}</p>
125+
<p>ISBN: {{ book.isbn }}</p>
126+
<p>Status: {{ book.inStock ? 'In Stock' : 'Out of Stock' }}</p>
127+
</mat-card-content>
128+
</mat-card>
129+
}
130+
`,
131+
styles: [
132+
`
133+
mat-card {
134+
margin: 16px;
135+
}
136+
137+
mat-form-field {
138+
margin-right: 16px;
139+
}
140+
141+
button {
142+
margin-right: 8px;
143+
}
144+
145+
mat-grid-tile mat-card {
146+
width: 100%;
147+
cursor: pointer;
148+
}
149+
`,
150+
],
151+
})
152+
export class EventsSampleComponent {
153+
readonly store = inject(BookStore);
154+
readonly dispatch = injectDispatch(bookEvents);
155+
filterText = '';
156+
157+
toggleStock(bookId: string, event: Event) {
158+
event.stopPropagation();
159+
this.dispatch.stockToggled({ bookId });
160+
}
161+
162+
removeBook(bookId: string, event: Event) {
163+
event.stopPropagation();
164+
this.dispatch.bookRemoved({ bookId });
165+
}
166+
167+
addRandomBook() {
168+
const titles = [
169+
'The Hobbit',
170+
'Brave New World',
171+
'Fahrenheit 451',
172+
'The Road',
173+
'Dune',
174+
];
175+
const authors = [
176+
'J.R.R. Tolkien',
177+
'Aldous Huxley',
178+
'Ray Bradbury',
179+
'Cormac McCarthy',
180+
'Frank Herbert',
181+
];
182+
const randomIndex = Math.floor(Math.random() * titles.length);
183+
184+
this.dispatch.bookAdded({
185+
book: {
186+
id: crypto.randomUUID(),
187+
title: titles[randomIndex],
188+
author: authors[randomIndex],
189+
year: 1950 + Math.floor(Math.random() * 70),
190+
isbn: `978-${Math.floor(Math.random() * 10)}-${Math.floor(Math.random() * 100000)}`,
191+
inStock: Math.random() > 0.5,
192+
},
193+
});
194+
}
195+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Route } from '@angular/router';
22
import { TodoComponent } from './devtools/todo.component';
3+
import { EventsSampleComponent } from './events-sample/events-sample.component';
34
import { FlightEditDynamicComponent } from './flight-search-data-service-dynamic/flight-edit.component';
45
import { FlightSearchDynamicComponent } from './flight-search-data-service-dynamic/flight-search.component';
56
import { FlightEditSimpleComponent } from './flight-search-data-service-simple/flight-edit-simple.component';
@@ -13,6 +14,7 @@ import { TodoStorageSyncComponent } from './todo-storage-sync/todo-storage-sync.
1314

1415
export const lazyRoutes: Route[] = [
1516
{ path: 'todo', component: TodoComponent },
17+
{ path: 'events-sample', component: EventsSampleComponent },
1618
{ path: 'flight-search', component: FlightSearchComponent },
1719
{
1820
path: 'flight-search-data-service-simple',

libs/ngrx-toolkit/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { renameDevtoolsName } from './lib/devtools/rename-devtools-name';
99
export { patchState, updateState } from './lib/devtools/update-state';
1010
export { withDevToolsStub } from './lib/devtools/with-dev-tools-stub';
1111
export { DevtoolsFeature, withDevtools } from './lib/devtools/with-devtools';
12+
export { withTrackedReducer } from './lib/devtools/with-tracked-reducer';
1213

1314
export {
1415
createEffects,

0 commit comments

Comments
 (0)