Skip to content

Commit 7e949aa

Browse files
feat: add withEventsTracking (#231)
1 parent c36db3c commit 7e949aa

File tree

11 files changed

+387
-2
lines changed

11 files changed

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

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
@@ -1,4 +1,5 @@
11
export { withDisabledNameIndices } from './lib/devtools/features/with-disabled-name-indicies';
2+
export { withEventsTracking } from './lib/devtools/features/with-events-tracking';
23
export { withGlitchTracking } from './lib/devtools/features/with-glitch-tracking';
34
export { withMapper } from './lib/devtools/features/with-mapper';
45
export {

0 commit comments

Comments
 (0)