Skip to content

Commit 980cf6f

Browse files
feat(signals): add Events plugin (#4769)
Closes #4580
1 parent b9beff4 commit 980cf6f

24 files changed

+1495
-2
lines changed

modules/signals/events/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './src/index';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"lib": {
3+
"entryFile": "index.ts"
4+
}
5+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { take } from 'rxjs';
3+
import { type } from '@ngrx/signals';
4+
import { Dispatcher, event, Events } from '../src';
5+
import { ReducerEvents } from '../src/events-service';
6+
7+
describe('Dispatcher', () => {
8+
it('is provided at the root level', () => {
9+
const dispatcher = TestBed.inject(Dispatcher);
10+
expect(dispatcher).toBeDefined();
11+
});
12+
13+
it('emits dispatched events to the ReducerEvents service before the Events service', () => {
14+
const dispatcher = TestBed.inject(Dispatcher);
15+
const events = TestBed.inject(Events);
16+
const reducerEvents = TestBed.inject(ReducerEvents);
17+
const set = event('set', type<number>());
18+
const result: Array<ReturnType<typeof set> & { order: number }> = [];
19+
20+
events
21+
.on(set)
22+
.pipe(take(1))
23+
.subscribe((event) => result.push({ ...event, order: 2 }));
24+
reducerEvents
25+
.on(set)
26+
.pipe(take(1))
27+
.subscribe((event) => result.push({ ...event, order: 1 }));
28+
29+
dispatcher.dispatch(set(10));
30+
31+
expect(result).toEqual([
32+
{ type: 'set', payload: 10, order: 1 },
33+
{ type: 'set', payload: 10, order: 2 },
34+
]);
35+
});
36+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { type } from '@ngrx/signals';
2+
import { EventCreator, eventGroup } from '../src';
3+
4+
describe('eventGroup', () => {
5+
it('creates a group of event creators', () => {
6+
const counterPageEvents = eventGroup({
7+
source: 'Counter Page',
8+
events: {
9+
increment: type<void>(),
10+
decrement: type<void>(),
11+
set: type<number>(),
12+
},
13+
});
14+
15+
const incrementEvent = counterPageEvents.increment();
16+
const decrementEvent = counterPageEvents.decrement();
17+
const setEvent = counterPageEvents.set(10);
18+
19+
expect(incrementEvent).toEqual({ type: '[Counter Page] increment' });
20+
expect(decrementEvent).toEqual({ type: '[Counter Page] decrement' });
21+
expect(setEvent).toEqual({ type: '[Counter Page] set', payload: 10 });
22+
});
23+
24+
it('allows creating custom event group factories', () => {
25+
function apiEventGroup<Source extends string, Entity>(
26+
source: Source,
27+
_entity: Entity
28+
): {
29+
loadedSuccess: EventCreator<`[${Source} API] loadedSuccess`, Entity[]>;
30+
loadedFailure: EventCreator<`[${Source} API] loadedFailure`, void>;
31+
} {
32+
return eventGroup({
33+
source: `${source} API`,
34+
events: {
35+
loadedSuccess: type<Entity[]>(),
36+
loadedFailure: type<void>(),
37+
},
38+
});
39+
}
40+
41+
type User = { id: number; name: string };
42+
const usersApiEvents = apiEventGroup('Users', type<User>());
43+
44+
const loadedSuccessEvent = usersApiEvents.loadedSuccess([
45+
{ id: 1, name: 'John Doe' },
46+
]);
47+
const loadedFailureEvent = usersApiEvents.loadedFailure();
48+
49+
expect(loadedSuccessEvent).toEqual({
50+
type: '[Users API] loadedSuccess',
51+
payload: [{ id: 1, name: 'John Doe' }],
52+
});
53+
expect(loadedFailureEvent).toEqual({ type: '[Users API] loadedFailure' });
54+
});
55+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { type } from '@ngrx/signals';
2+
import { event, EventCreator } from '../src';
3+
4+
describe('event', () => {
5+
it('creates an event creator without payload', () => {
6+
const increment = event('increment');
7+
expect(increment()).toEqual({ type: 'increment' });
8+
});
9+
10+
it('creates an event creator with payload', () => {
11+
const set = event('set', type<{ count: number }>());
12+
expect(set({ count: 10 })).toEqual({ type: 'set', payload: { count: 10 } });
13+
});
14+
15+
it('allows creating custom event creator factories', () => {
16+
function formattedEventCreator<Source extends string, Event extends string>(
17+
source: Source,
18+
eventName: Event
19+
): EventCreator<`[${Source}] ${Event}`, void> {
20+
return event(`[${source}] ${eventName}`);
21+
}
22+
23+
const increment = formattedEventCreator('Counter Page', 'Increment');
24+
expect(increment()).toEqual({ type: '[Counter Page] Increment' });
25+
});
26+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { type } from '@ngrx/signals';
3+
import { Dispatcher, event, EventInstance, Events } from '../src';
4+
import { SOURCE_TYPE } from '../src/events-service';
5+
6+
describe('Events', () => {
7+
it('is provided at the root level', () => {
8+
const events = TestBed.inject(Events);
9+
expect(events).toBeDefined();
10+
});
11+
12+
describe('on', () => {
13+
const foo = event('foo');
14+
const bar = event('bar', type<{ value: number }>());
15+
const baz = event('baz');
16+
17+
it('emits events matching the provided event creators', () => {
18+
const events = TestBed.inject(Events);
19+
const dispatcher = TestBed.inject(Dispatcher);
20+
const emittedEvents: EventInstance<string, unknown>[] = [];
21+
22+
events.on(foo, bar).subscribe((event) => emittedEvents.push(event));
23+
24+
dispatcher.dispatch(bar({ value: 10 }));
25+
dispatcher.dispatch(foo());
26+
dispatcher.dispatch(baz());
27+
dispatcher.dispatch(bar({ value: 100 }));
28+
29+
expect(emittedEvents).toEqual([
30+
{ type: 'bar', payload: { value: 10 } },
31+
{ type: 'foo' },
32+
{ type: 'bar', payload: { value: 100 } },
33+
]);
34+
});
35+
36+
it('emits all events when called without arguments', () => {
37+
const events = TestBed.inject(Events);
38+
const dispatcher = TestBed.inject(Dispatcher);
39+
const emittedEvents: EventInstance<string, unknown>[] = [];
40+
41+
events.on().subscribe((event) => emittedEvents.push(event));
42+
43+
dispatcher.dispatch(foo());
44+
dispatcher.dispatch(bar({ value: 10 }));
45+
dispatcher.dispatch(baz());
46+
dispatcher.dispatch(foo());
47+
48+
expect(emittedEvents).toEqual([
49+
{ type: 'foo' },
50+
{ type: 'bar', payload: { value: 10 } },
51+
{ type: 'baz' },
52+
{ type: 'foo' },
53+
]);
54+
});
55+
56+
it('adds SOURCE_TYPE to emitted events', () => {
57+
const events = TestBed.inject(Events);
58+
const dispatcher = TestBed.inject(Dispatcher);
59+
const sourceTypes: string[] = [];
60+
61+
events
62+
.on()
63+
.subscribe((event) => sourceTypes.push((event as any)[SOURCE_TYPE]));
64+
65+
dispatcher.dispatch(foo());
66+
dispatcher.dispatch(bar({ value: 10 }));
67+
68+
expect(sourceTypes).toEqual(['foo', 'bar']);
69+
});
70+
});
71+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { EnvironmentInjector } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { type } from '@ngrx/signals';
4+
import { Dispatcher, event, eventGroup, injectDispatch } from '../src';
5+
6+
describe('injectDispatch', () => {
7+
it('creates self-dispatching events', () => {
8+
const counterPageEvents = eventGroup({
9+
source: 'Counter Page',
10+
events: {
11+
increment: type<void>(),
12+
set: type<{ count: number }>(),
13+
},
14+
});
15+
const dispatcher = TestBed.inject(Dispatcher);
16+
const dispatch = TestBed.runInInjectionContext(() =>
17+
injectDispatch(counterPageEvents)
18+
);
19+
vitest.spyOn(dispatcher, 'dispatch');
20+
21+
dispatch.increment();
22+
expect(dispatcher.dispatch).toHaveBeenCalledWith({
23+
type: '[Counter Page] increment',
24+
});
25+
26+
dispatch.set({ count: 10 });
27+
expect(dispatcher.dispatch).toHaveBeenCalledWith({
28+
type: '[Counter Page] set',
29+
payload: { count: 10 },
30+
});
31+
});
32+
33+
it('creates self-dispatching events with a custom injector', () => {
34+
const increment = event('increment');
35+
const injector = TestBed.inject(EnvironmentInjector);
36+
const dispatcher = TestBed.inject(Dispatcher);
37+
const dispatch = injectDispatch({ increment }, { injector });
38+
vitest.spyOn(dispatcher, 'dispatch');
39+
40+
dispatch.increment();
41+
expect(dispatcher.dispatch).toHaveBeenCalledWith({ type: 'increment' });
42+
});
43+
44+
it('throws an error when called outside of an injection context', () => {
45+
const increment = event('increment');
46+
47+
expect(() => injectDispatch({ increment })).toThrowError(
48+
'injectDispatch() can only be used within an injection context'
49+
);
50+
});
51+
});

0 commit comments

Comments
 (0)