Skip to content

Commit c719d19

Browse files
feat(signals): provide support for scoped events (#4997)
Closes #4776
1 parent eea69d7 commit c719d19

File tree

11 files changed

+592
-39
lines changed

11 files changed

+592
-39
lines changed

modules/signals/events/spec/dispatcher.spec.ts

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import { Injector } from '@angular/core';
12
import { TestBed } from '@angular/core/testing';
23
import { take } from 'rxjs';
34
import { type } from '@ngrx/signals';
4-
import { Dispatcher, event, Events } from '../src';
5-
import { ReducerEvents } from '../src/events-service';
5+
import {
6+
Dispatcher,
7+
event,
8+
Events,
9+
provideDispatcher,
10+
ReducerEvents,
11+
} from '../src';
12+
import { EVENTS } from '../src/events-service';
613

714
describe('Dispatcher', () => {
815
it('is provided globally', () => {
@@ -33,4 +40,127 @@ describe('Dispatcher', () => {
3340
{ type: 'set', payload: 10, order: 2 },
3441
]);
3542
});
43+
44+
describe('hierarchical dispatchers', () => {
45+
function setup() {
46+
const parentInjector = Injector.create({
47+
providers: [provideDispatcher()],
48+
parent: TestBed.inject(Injector),
49+
});
50+
const childInjector = Injector.create({
51+
providers: [provideDispatcher()],
52+
parent: parentInjector,
53+
});
54+
55+
const globalDispatcher = TestBed.inject(Dispatcher);
56+
const parentDispatcher = parentInjector.get(Dispatcher);
57+
const childDispatcher = childInjector.get(Dispatcher);
58+
59+
const globalEvents = TestBed.inject(Events)[EVENTS];
60+
const parentEvents = parentInjector.get(Events)[EVENTS];
61+
const childEvents = childInjector.get(Events)[EVENTS];
62+
63+
vitest.spyOn(globalDispatcher, 'dispatch');
64+
vitest.spyOn(parentDispatcher, 'dispatch');
65+
vitest.spyOn(globalEvents, 'next');
66+
vitest.spyOn(parentEvents, 'next');
67+
vitest.spyOn(childEvents, 'next');
68+
69+
return {
70+
globalDispatcher,
71+
parentDispatcher,
72+
childDispatcher,
73+
globalEvents,
74+
parentEvents,
75+
childEvents,
76+
};
77+
}
78+
79+
it('dispatches an event to the local dispatcher by default', () => {
80+
const {
81+
globalDispatcher,
82+
parentDispatcher,
83+
childDispatcher,
84+
globalEvents,
85+
parentEvents,
86+
childEvents,
87+
} = setup();
88+
const increment = event('increment');
89+
90+
childDispatcher.dispatch(increment());
91+
92+
expect(childEvents.next).toHaveBeenCalledWith(increment());
93+
expect(parentEvents.next).not.toHaveBeenCalled();
94+
expect(globalEvents.next).not.toHaveBeenCalled();
95+
expect(parentDispatcher.dispatch).not.toHaveBeenCalled();
96+
expect(globalDispatcher.dispatch).not.toHaveBeenCalled();
97+
});
98+
99+
it('dispatches an event to the local dispatcher when scope is self', () => {
100+
const {
101+
globalDispatcher,
102+
parentDispatcher,
103+
childDispatcher,
104+
globalEvents,
105+
parentEvents,
106+
childEvents,
107+
} = setup();
108+
const increment = event('increment');
109+
110+
childDispatcher.dispatch(increment(), { scope: 'self' });
111+
112+
expect(childEvents.next).toHaveBeenCalledWith(increment());
113+
expect(parentEvents.next).not.toHaveBeenCalled();
114+
expect(globalEvents.next).not.toHaveBeenCalled();
115+
expect(parentDispatcher.dispatch).not.toHaveBeenCalled();
116+
expect(globalDispatcher.dispatch).not.toHaveBeenCalled();
117+
});
118+
119+
it('dispatches an event to the parent dispatcher when scope is parent', () => {
120+
const {
121+
globalDispatcher,
122+
parentDispatcher,
123+
childDispatcher,
124+
globalEvents,
125+
parentEvents,
126+
childEvents,
127+
} = setup();
128+
const increment = event('increment');
129+
130+
childDispatcher.dispatch(increment(), { scope: 'parent' });
131+
132+
expect(childEvents.next).not.toHaveBeenCalled();
133+
expect(parentEvents.next).toHaveBeenCalledWith(increment());
134+
expect(globalEvents.next).not.toHaveBeenCalled();
135+
expect(parentDispatcher.dispatch).toHaveBeenCalledWith(
136+
increment(),
137+
undefined
138+
);
139+
expect(globalDispatcher.dispatch).not.toHaveBeenCalled();
140+
});
141+
142+
it('dispatches an event to the parent dispatcher when scope is global', () => {
143+
const {
144+
globalDispatcher,
145+
parentDispatcher,
146+
childDispatcher,
147+
globalEvents,
148+
parentEvents,
149+
childEvents,
150+
} = setup();
151+
const increment = event('increment');
152+
153+
childDispatcher.dispatch(increment(), { scope: 'global' });
154+
155+
expect(childEvents.next).not.toHaveBeenCalled();
156+
expect(parentEvents.next).not.toHaveBeenCalled();
157+
expect(globalEvents.next).toHaveBeenCalledWith(increment());
158+
expect(parentDispatcher.dispatch).toHaveBeenCalledWith(increment(), {
159+
scope: 'global',
160+
});
161+
expect(globalDispatcher.dispatch).toHaveBeenCalledWith(increment(), {
162+
scope: 'global',
163+
});
164+
});
165+
});
36166
});

modules/signals/events/spec/events-service.spec.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import { Injector } from '@angular/core';
12
import { TestBed } from '@angular/core/testing';
23
import { type } from '@ngrx/signals';
3-
import { Dispatcher, event, EventInstance, Events } from '../src';
4+
import {
5+
Dispatcher,
6+
event,
7+
EventInstance,
8+
Events,
9+
provideDispatcher,
10+
} from '../src';
411
import { SOURCE_TYPE } from '../src/events-service';
512

613
describe('Events', () => {
@@ -68,4 +75,44 @@ describe('Events', () => {
6875
expect(sourceTypes).toEqual(['foo', 'bar']);
6976
});
7077
});
78+
79+
it('receives dispatched events from ancestor Events services', () => {
80+
const parentInjector = Injector.create({
81+
providers: [provideDispatcher()],
82+
parent: TestBed.inject(Injector),
83+
});
84+
const childInjector = Injector.create({
85+
providers: [provideDispatcher()],
86+
parent: parentInjector,
87+
});
88+
89+
const globalEvents = TestBed.inject(Events);
90+
const parentEvents = parentInjector.get(Events);
91+
const childEvents = childInjector.get(Events);
92+
const childDispatcher = childInjector.get(Dispatcher);
93+
94+
const foo = event('foo', type<string>());
95+
96+
const globalResult: string[] = [];
97+
const parentResult: string[] = [];
98+
const childResult: string[] = [];
99+
100+
globalEvents.on(foo).subscribe(({ payload }) => globalResult.push(payload));
101+
parentEvents.on(foo).subscribe(({ payload }) => parentResult.push(payload));
102+
childEvents.on(foo).subscribe(({ payload }) => childResult.push(payload));
103+
104+
childDispatcher.dispatch(foo('self by default'));
105+
childDispatcher.dispatch(foo('explicit self'), { scope: 'self' });
106+
childDispatcher.dispatch(foo('parent'), { scope: 'parent' });
107+
childDispatcher.dispatch(foo('global'), { scope: 'global' });
108+
109+
expect(globalResult).toEqual(['global']);
110+
expect(parentResult).toEqual(['parent', 'global']);
111+
expect(childResult).toEqual([
112+
'self by default',
113+
'explicit self',
114+
'parent',
115+
'global',
116+
]);
117+
});
71118
});

modules/signals/events/spec/inject-dispatch.spec.ts

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,108 @@ describe('injectDispatch', () => {
1919
vitest.spyOn(dispatcher, 'dispatch');
2020

2121
dispatch.increment();
22-
expect(dispatcher.dispatch).toHaveBeenCalledWith({
23-
type: '[Counter Page] increment',
24-
});
22+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
23+
{
24+
type: '[Counter Page] increment',
25+
payload: undefined,
26+
},
27+
{ scope: 'self' }
28+
);
2529

2630
dispatch.set({ count: 10 });
27-
expect(dispatcher.dispatch).toHaveBeenCalledWith({
28-
type: '[Counter Page] set',
29-
payload: { count: 10 },
31+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
32+
{
33+
type: '[Counter Page] set',
34+
payload: { count: 10 },
35+
},
36+
{ scope: 'self' }
37+
);
38+
});
39+
40+
it('creates self-dispatching events with a custom scope', () => {
41+
const usersPageEvents = eventGroup({
42+
source: 'Users Page',
43+
events: {
44+
opened: type<void>(),
45+
queryChanged: type<string>(),
46+
paginationChanged: type<{ currentPage: number; pageSize: number }>(),
47+
},
48+
});
49+
const dispatcher = TestBed.inject(Dispatcher);
50+
const dispatch = TestBed.runInInjectionContext(() =>
51+
injectDispatch(usersPageEvents)
52+
);
53+
vitest.spyOn(dispatcher, 'dispatch');
54+
55+
dispatch({ scope: 'self' }).opened();
56+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
57+
{
58+
type: '[Users Page] opened',
59+
payload: undefined,
60+
},
61+
{ scope: 'self' }
62+
);
63+
64+
dispatch({ scope: 'parent' }).queryChanged('ngrx');
65+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
66+
{
67+
type: '[Users Page] queryChanged',
68+
payload: 'ngrx',
69+
},
70+
{ scope: 'parent' }
71+
);
72+
73+
dispatch({ scope: 'global' }).paginationChanged({
74+
currentPage: 10,
75+
pageSize: 100,
3076
});
77+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
78+
{
79+
type: '[Users Page] paginationChanged',
80+
payload: { currentPage: 10, pageSize: 100 },
81+
},
82+
{ scope: 'global' }
83+
);
84+
});
85+
86+
it('allows defining event names equal to predefined function properties', () => {
87+
const fooEvents = eventGroup({
88+
source: 'foo',
89+
events: {
90+
name: type<boolean>(),
91+
toString: type<{ bar: number }>(),
92+
},
93+
});
94+
95+
const dispatcher = TestBed.inject(Dispatcher);
96+
const dispatch = TestBed.runInInjectionContext(() =>
97+
injectDispatch(fooEvents)
98+
);
99+
vitest.spyOn(dispatcher, 'dispatch');
100+
101+
dispatch.name(true);
102+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
103+
{ type: '[foo] name', payload: true },
104+
{ scope: 'self' }
105+
);
106+
107+
dispatch({ scope: 'parent' }).name(false);
108+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
109+
{ type: '[foo] name', payload: false },
110+
{ scope: 'parent' }
111+
);
112+
113+
dispatch.toString({ bar: 10 });
114+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
115+
{ type: '[foo] toString', payload: { bar: 10 } },
116+
{ scope: 'self' }
117+
);
118+
119+
dispatch({ scope: 'global' }).toString({ bar: 100 });
120+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
121+
{ type: '[foo] toString', payload: { bar: 100 } },
122+
{ scope: 'global' }
123+
);
31124
});
32125

33126
it('creates self-dispatching events with a custom injector', () => {
@@ -38,7 +131,10 @@ describe('injectDispatch', () => {
38131
vitest.spyOn(dispatcher, 'dispatch');
39132

40133
dispatch.increment();
41-
expect(dispatcher.dispatch).toHaveBeenCalledWith({ type: 'increment' });
134+
expect(dispatcher.dispatch).toHaveBeenCalledWith(
135+
{ type: 'increment', payload: undefined },
136+
{ scope: 'self' }
137+
);
42138
});
43139

44140
it('throws an error when called outside of an injection context', () => {

modules/signals/events/spec/with-effects.spec.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import {
1010
withProps,
1111
withState,
1212
} from '@ngrx/signals';
13-
import { Dispatcher, event, EventInstance, Events, withEffects } from '../src';
13+
import {
14+
Dispatcher,
15+
event,
16+
EventInstance,
17+
Events,
18+
mapToScope,
19+
toScope,
20+
withEffects,
21+
} from '../src';
1422
import { createLocalService } from '../../spec/helpers';
1523

1624
describe('withEffects', () => {
@@ -127,6 +135,47 @@ describe('withEffects', () => {
127135
]);
128136
});
129137

138+
it('dispatches an event with provided scope via toScope', () => {
139+
const Store = signalStore(
140+
{ providedIn: 'root' },
141+
withEffects((_, events = inject(Events)) => ({
142+
$: events.on(event1).pipe(map(() => [event2(), toScope('parent')])),
143+
}))
144+
);
145+
146+
const dispatcher = TestBed.inject(Dispatcher);
147+
vitest.spyOn(dispatcher, 'dispatch');
148+
149+
TestBed.inject(Store);
150+
151+
dispatcher.dispatch(event1());
152+
expect(dispatcher.dispatch).toHaveBeenCalledWith(event2(), {
153+
scope: 'parent',
154+
});
155+
});
156+
157+
it('dispatches an event with provided scope via mapToScope', () => {
158+
const Store = signalStore(
159+
{ providedIn: 'root' },
160+
withEffects((_, events = inject(Events)) => ({
161+
$: events.on(event1).pipe(
162+
map(() => event3('ngrx')),
163+
mapToScope('global')
164+
),
165+
}))
166+
);
167+
168+
const dispatcher = TestBed.inject(Dispatcher);
169+
vitest.spyOn(dispatcher, 'dispatch');
170+
171+
TestBed.inject(Store);
172+
173+
dispatcher.dispatch(event1());
174+
expect(dispatcher.dispatch).toHaveBeenCalledWith(event3('ngrx'), {
175+
scope: 'global',
176+
});
177+
});
178+
130179
it('unsubscribes from effects when the store is destroyed', () => {
131180
let executionCount = 0;
132181

0 commit comments

Comments
 (0)