diff --git a/apps/demo/e2e/devtools.spec.ts b/apps/demo/e2e/devtools.spec.ts index dc8265ca..26f29766 100644 --- a/apps/demo/e2e/devtools.spec.ts +++ b/apps/demo/e2e/devtools.spec.ts @@ -8,7 +8,7 @@ test.describe('DevTools', () => { await page.goto(''); const errors = []; page.on('pageerror', (error) => errors.push(error)); - await page.getByRole('link', { name: 'DevTools' }).click(); + await page.getByRole('link', { name: 'DevTools', exact: true }).click(); await expect( page.getByRole('row', { name: 'Go for a walk' }), ).toBeVisible(); @@ -30,7 +30,7 @@ test.describe('DevTools', () => { }, }; }); - await page.getByRole('link', { name: 'DevTools' }).click(); + await page.getByRole('link', { name: 'DevTools', exact: true }).click(); await page .getByRole('row', { name: 'Go for a walk' }) .getByRole('checkbox') diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index a18faa3d..1f8fd63b 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -5,6 +5,7 @@ DevTools + Events + DevTools Sample withRedux withDataService (Simple)(), + bookSelected: type<{ bookId: string }>(), + selectionCleared: type(), + filterUpdated: type<{ filter: string }>(), + stockToggled: type<{ bookId: string }>(), + bookAdded: type<{ book: Book }>(), + bookRemoved: type<{ bookId: string }>(), + }, +}); diff --git a/apps/demo/src/app/events-sample/book.model.ts b/apps/demo/src/app/events-sample/book.model.ts new file mode 100644 index 00000000..3a950cf5 --- /dev/null +++ b/apps/demo/src/app/events-sample/book.model.ts @@ -0,0 +1,51 @@ +export interface Book { + id: string; + title: string; + author: string; + year: number; + isbn: string; + inStock: boolean; +} + +export const mockBooks: Book[] = [ + { + id: '1', + title: 'The Great Gatsby', + author: 'F. Scott Fitzgerald', + year: 1925, + isbn: '978-0-7432-7356-5', + inStock: true, + }, + { + id: '2', + title: '1984', + author: 'George Orwell', + year: 1949, + isbn: '978-0-452-28423-4', + inStock: true, + }, + { + id: '3', + title: 'To Kill a Mockingbird', + author: 'Harper Lee', + year: 1960, + isbn: '978-0-06-112008-4', + inStock: false, + }, + { + id: '4', + title: 'Pride and Prejudice', + author: 'Jane Austen', + year: 1813, + isbn: '978-0-14-143951-8', + inStock: true, + }, + { + id: '5', + title: 'The Catcher in the Rye', + author: 'J.D. Salinger', + year: 1951, + isbn: '978-0-316-76948-0', + inStock: false, + }, +]; diff --git a/apps/demo/src/app/events-sample/book.store.ts b/apps/demo/src/app/events-sample/book.store.ts new file mode 100644 index 00000000..12b478bb --- /dev/null +++ b/apps/demo/src/app/events-sample/book.store.ts @@ -0,0 +1,81 @@ +import { + withDevtools, + withEventsTracking, +} from '@angular-architects/ngrx-toolkit'; +import { signalStore, withComputed, withHooks, withState } from '@ngrx/signals'; +import { injectDispatch, on, withReducer } from '@ngrx/signals/events'; +import { bookEvents } from './book-events'; +import { Book, mockBooks } from './book.model'; + +export const BookStore = signalStore( + { providedIn: 'root' }, + withDevtools('book-store-events', withEventsTracking()), + withState({ + books: [] as Book[], + selectedBookId: null as string | null, + filter: '', + }), + + withComputed((store) => ({ + selectedBook: () => { + const id = store.selectedBookId(); + return id ? store.books().find((b) => b.id === id) || null : null; + }, + + filteredBooks: () => { + const filter = store.filter().toLowerCase(); + if (!filter) return store.books(); + + return store + .books() + .filter( + (book) => + book.title.toLowerCase().includes(filter) || + book.author.toLowerCase().includes(filter), + ); + }, + + totalBooks: () => store.books().length, + + availableBooks: () => store.books().filter((book) => book.inStock).length, + })), + + withReducer( + on(bookEvents.loadBooks, () => ({ + books: mockBooks, + })), + + on(bookEvents.bookSelected, ({ payload }) => ({ + selectedBookId: payload.bookId, + })), + + on(bookEvents.selectionCleared, () => ({ + selectedBookId: null, + })), + + on(bookEvents.filterUpdated, ({ payload }) => ({ + filter: payload.filter, + })), + + on(bookEvents.stockToggled, (event, state) => ({ + books: state.books.map((book) => + book.id === event.payload.bookId + ? { ...book, inStock: !book.inStock } + : book, + ), + })), + + on(bookEvents.bookAdded, (event, state) => ({ + books: [...state.books, event.payload.book], + })), + + on(bookEvents.bookRemoved, (event, state) => ({ + books: state.books.filter((book) => book.id !== event.payload.bookId), + })), + ), + withHooks({ + onInit() { + injectDispatch(bookEvents).loadBooks(); + }, + }), +); diff --git a/apps/demo/src/app/events-sample/events-sample.component.ts b/apps/demo/src/app/events-sample/events-sample.component.ts new file mode 100644 index 00000000..5c15e6a2 --- /dev/null +++ b/apps/demo/src/app/events-sample/events-sample.component.ts @@ -0,0 +1,196 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatGridListModule } from '@angular/material/grid-list'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { injectDispatch } from '@ngrx/signals/events'; +import { bookEvents } from './book-events'; +import { BookStore } from './book.store'; + +@Component({ + selector: 'demo-events-sample', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatCardModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatIconModule, + MatChipsModule, + MatGridListModule, + MatToolbarModule, + ], + template: ` + + Book Store with Event Tracking + + + + + + Search books + + search + + + + + + + + + + + Total: {{ store.totalBooks() }} + In Stock: {{ store.availableBooks() }} + Filtered: {{ store.filteredBooks().length }} + + + + + + @for (book of store.filteredBooks(); track book.id) { + + + + {{ book.title }} + {{ book.author }} ({{ book.year }}) + + +

ISBN: {{ book.isbn }}

+ + {{ book.inStock ? 'In Stock' : 'Out of Stock' }} + +
+ + + + +
+
+ } @empty { + +

+ @if (store.filter()) { + No books found matching "{{ store.filter() }}" + } @else { + No books available + } +

+
+ } +
+ + @if (store.selectedBook(); as book) { + + + Selected: {{ book.title }} + + +

Author: {{ book.author }}

+

Year: {{ book.year }}

+

ISBN: {{ book.isbn }}

+

Status: {{ book.inStock ? 'In Stock' : 'Out of Stock' }}

+
+
+ } + `, + styles: [ + ` + mat-card { + margin: 16px; + } + + mat-form-field { + margin-right: 16px; + } + + button { + margin-right: 8px; + } + + mat-grid-tile mat-card { + width: 100%; + cursor: pointer; + } + `, + ], +}) +export class EventsSampleComponent { + readonly store = inject(BookStore); + readonly dispatch = injectDispatch(bookEvents); + filterText = ''; + + toggleStock(bookId: string, event: Event) { + event.stopPropagation(); + this.dispatch.stockToggled({ bookId }); + } + + removeBook(bookId: string, event: Event) { + event.stopPropagation(); + this.dispatch.bookRemoved({ bookId }); + } + + addRandomBook() { + const titles = [ + 'The Hobbit', + 'Brave New World', + 'Fahrenheit 451', + 'The Road', + 'Dune', + ]; + const authors = [ + 'J.R.R. Tolkien', + 'Aldous Huxley', + 'Ray Bradbury', + 'Cormac McCarthy', + 'Frank Herbert', + ]; + const randomIndex = Math.floor(Math.random() * titles.length); + + this.dispatch.bookAdded({ + book: { + id: crypto.randomUUID(), + title: titles[randomIndex], + author: authors[randomIndex], + year: 1950 + Math.floor(Math.random() * 70), + isbn: `978-${Math.floor(Math.random() * 10)}-${Math.floor(Math.random() * 100000)}`, + inStock: Math.random() > 0.5, + }, + }); + } +} diff --git a/apps/demo/src/app/lazy-routes.ts b/apps/demo/src/app/lazy-routes.ts index ba4c66e5..f4f93da0 100644 --- a/apps/demo/src/app/lazy-routes.ts +++ b/apps/demo/src/app/lazy-routes.ts @@ -1,5 +1,6 @@ import { Route } from '@angular/router'; import { TodoComponent } from './devtools/todo.component'; +import { EventsSampleComponent } from './events-sample/events-sample.component'; import { FlightEditDynamicComponent } from './flight-search-data-service-dynamic/flight-edit.component'; import { FlightSearchDynamicComponent } from './flight-search-data-service-dynamic/flight-search.component'; 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. export const lazyRoutes: Route[] = [ { path: 'todo', component: TodoComponent }, + { path: 'events-sample', component: EventsSampleComponent }, { path: 'flight-search', component: FlightSearchComponent }, { path: 'flight-search-data-service-simple', diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 8b8bba85..287d95ae 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -1,4 +1,5 @@ export { withDisabledNameIndices } from './lib/devtools/features/with-disabled-name-indicies'; +export { withEventsTracking } from './lib/devtools/features/with-events-tracking'; export { withGlitchTracking } from './lib/devtools/features/with-glitch-tracking'; export { withMapper } from './lib/devtools/features/with-mapper'; export { diff --git a/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts b/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts new file mode 100644 index 00000000..a16f9f2b --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts @@ -0,0 +1,13 @@ +import { createDevtoolsFeature } from '../internal/devtools-feature'; + +/** + * Automatically infers DevTools action names from NgRx SignalStore events. + * + * It listens to all dispatched events via the Events stream and enqueues + * the event's type as the upcoming DevTools action name. When the corresponding + * reducer mutates state, the DevTools sync will use that name instead of + * the default "Store Update". + */ +export function withEventsTracking() { + return createDevtoolsFeature({ eventsTracking: true }); +} diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts index 6477f376..600c1163 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts @@ -8,12 +8,14 @@ export type DevtoolsOptions = { indexNames?: boolean; // defines if names should be indexed. map?: Mapper; // defines a mapper for the state. tracker?: new () => Tracker; // defines a tracker for the state + eventsTracking?: boolean; // enables @ngrx/signals/events → DevTools action name tracking }; export type DevtoolsInnerOptions = { indexNames: boolean; map: Mapper; tracker: Tracker; + eventsTracking: boolean; }; /** diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts index 5827e7ae..49345721 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts @@ -6,6 +6,10 @@ import { withHooks, withMethods, } from '@ngrx/signals'; +import { EventInstance, Events } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { tap } from 'rxjs'; +import { currentActionNames } from './internal/current-action-names'; import { DefaultTracker } from './internal/default-tracker'; import { DevtoolsFeature, @@ -22,6 +26,7 @@ declare global { export const renameDevtoolsMethodName = '___renameDevtoolsName'; export const uniqueDevtoolsId = '___uniqueDevtoolsId'; +export const devtoolsEventsTracker = '___devtoolsEventsTracker'; const EXISTING_NAMES = new InjectionToken( 'Array contain existing names for the signal stores', @@ -54,6 +59,16 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { syncer.renameStore(name, newName); }, [uniqueDevtoolsId]: () => id, + [devtoolsEventsTracker]: rxMethod>( + (c$) => + c$.pipe( + tap((ev) => { + if (ev && typeof ev.type === 'string' && ev.type.length > 0) { + currentActionNames.add(ev.type); + } + }), + ), + ), } as Record unknown>; }), withHooks((store) => { @@ -68,9 +83,15 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { tracker: inject( features.find((f) => f.tracker)?.tracker || DefaultTracker, ), + eventsTracking: features.some((f) => f.eventsTracking === true), }; syncer.addStore(id, name, store, finalOptions); + + if (finalOptions.eventsTracking) { + const events = inject(Events); + store[devtoolsEventsTracker](events.on()); + } }, onDestroy() { syncer.removeStore(id);