Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 132 additions & 2 deletions modules/signals/events/spec/dispatcher.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { Injector } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { take } from 'rxjs';
import { type } from '@ngrx/signals';
import { Dispatcher, event, Events } from '../src';
import { ReducerEvents } from '../src/events-service';
import {
Dispatcher,
event,
Events,
provideDispatcher,
ReducerEvents,
} from '../src';
import { EVENTS } from '../src/events-service';

describe('Dispatcher', () => {
it('is provided globally', () => {
Expand Down Expand Up @@ -33,4 +40,127 @@ describe('Dispatcher', () => {
{ type: 'set', payload: 10, order: 2 },
]);
});

describe('hierarchical dispatchers', () => {
function setup() {
const parentInjector = Injector.create({
providers: [provideDispatcher()],
parent: TestBed.inject(Injector),
});
const childInjector = Injector.create({
providers: [provideDispatcher()],
parent: parentInjector,
});

const globalDispatcher = TestBed.inject(Dispatcher);
const parentDispatcher = parentInjector.get(Dispatcher);
const childDispatcher = childInjector.get(Dispatcher);

const globalEvents = TestBed.inject(Events)[EVENTS];
const parentEvents = parentInjector.get(Events)[EVENTS];
const childEvents = childInjector.get(Events)[EVENTS];

vitest.spyOn(globalDispatcher, 'dispatch');
vitest.spyOn(parentDispatcher, 'dispatch');
vitest.spyOn(globalEvents, 'next');
vitest.spyOn(parentEvents, 'next');
vitest.spyOn(childEvents, 'next');

return {
globalDispatcher,
parentDispatcher,
childDispatcher,
globalEvents,
parentEvents,
childEvents,
};
}

it('dispatches an event to the local dispatcher by default', () => {
const {
globalDispatcher,
parentDispatcher,
childDispatcher,
globalEvents,
parentEvents,
childEvents,
} = setup();
const increment = event('increment');

childDispatcher.dispatch(increment());

expect(childEvents.next).toHaveBeenCalledWith(increment());
expect(parentEvents.next).not.toHaveBeenCalled();
expect(globalEvents.next).not.toHaveBeenCalled();
expect(parentDispatcher.dispatch).not.toHaveBeenCalled();
expect(globalDispatcher.dispatch).not.toHaveBeenCalled();
});

it('dispatches an event to the local dispatcher when scope is self', () => {
const {
globalDispatcher,
parentDispatcher,
childDispatcher,
globalEvents,
parentEvents,
childEvents,
} = setup();
const increment = event('increment');

childDispatcher.dispatch(increment(), { scope: 'self' });

expect(childEvents.next).toHaveBeenCalledWith(increment());
expect(parentEvents.next).not.toHaveBeenCalled();
expect(globalEvents.next).not.toHaveBeenCalled();
expect(parentDispatcher.dispatch).not.toHaveBeenCalled();
expect(globalDispatcher.dispatch).not.toHaveBeenCalled();
});

it('dispatches an event to the parent dispatcher when scope is parent', () => {
const {
globalDispatcher,
parentDispatcher,
childDispatcher,
globalEvents,
parentEvents,
childEvents,
} = setup();
const increment = event('increment');

childDispatcher.dispatch(increment(), { scope: 'parent' });

expect(childEvents.next).not.toHaveBeenCalled();
expect(parentEvents.next).toHaveBeenCalledWith(increment());
expect(globalEvents.next).not.toHaveBeenCalled();
expect(parentDispatcher.dispatch).toHaveBeenCalledWith(
increment(),
undefined
);
expect(globalDispatcher.dispatch).not.toHaveBeenCalled();
});

it('dispatches an event to the parent dispatcher when scope is global', () => {
const {
globalDispatcher,
parentDispatcher,
childDispatcher,
globalEvents,
parentEvents,
childEvents,
} = setup();
const increment = event('increment');

childDispatcher.dispatch(increment(), { scope: 'global' });

expect(childEvents.next).not.toHaveBeenCalled();
expect(parentEvents.next).not.toHaveBeenCalled();
expect(globalEvents.next).toHaveBeenCalledWith(increment());
expect(parentDispatcher.dispatch).toHaveBeenCalledWith(increment(), {
scope: 'global',
});
expect(globalDispatcher.dispatch).toHaveBeenCalledWith(increment(), {
scope: 'global',
});
});
});
});
49 changes: 48 additions & 1 deletion modules/signals/events/spec/events-service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Injector } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { type } from '@ngrx/signals';
import { Dispatcher, event, EventInstance, Events } from '../src';
import {
Dispatcher,
event,
EventInstance,
Events,
provideDispatcher,
} from '../src';
import { SOURCE_TYPE } from '../src/events-service';

describe('Events', () => {
Expand Down Expand Up @@ -68,4 +75,44 @@ describe('Events', () => {
expect(sourceTypes).toEqual(['foo', 'bar']);
});
});

it('receives dispatched events from ancestor Events services', () => {
const parentInjector = Injector.create({
providers: [provideDispatcher()],
parent: TestBed.inject(Injector),
});
const childInjector = Injector.create({
providers: [provideDispatcher()],
parent: parentInjector,
});

const globalEvents = TestBed.inject(Events);
const parentEvents = parentInjector.get(Events);
const childEvents = childInjector.get(Events);
const childDispatcher = childInjector.get(Dispatcher);

const foo = event('foo', type<string>());

const globalResult: string[] = [];
const parentResult: string[] = [];
const childResult: string[] = [];

globalEvents.on(foo).subscribe(({ payload }) => globalResult.push(payload));
parentEvents.on(foo).subscribe(({ payload }) => parentResult.push(payload));
childEvents.on(foo).subscribe(({ payload }) => childResult.push(payload));

childDispatcher.dispatch(foo('self by default'));
childDispatcher.dispatch(foo('explicit self'), { scope: 'self' });
childDispatcher.dispatch(foo('parent'), { scope: 'parent' });
childDispatcher.dispatch(foo('global'), { scope: 'global' });

expect(globalResult).toEqual(['global']);
expect(parentResult).toEqual(['parent', 'global']);
expect(childResult).toEqual([
'self by default',
'explicit self',
'parent',
'global',
]);
});
});
110 changes: 103 additions & 7 deletions modules/signals/events/spec/inject-dispatch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,108 @@ describe('injectDispatch', () => {
vitest.spyOn(dispatcher, 'dispatch');

dispatch.increment();
expect(dispatcher.dispatch).toHaveBeenCalledWith({
type: '[Counter Page] increment',
});
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{
type: '[Counter Page] increment',
payload: undefined,
},
{ scope: 'self' }
);

dispatch.set({ count: 10 });
expect(dispatcher.dispatch).toHaveBeenCalledWith({
type: '[Counter Page] set',
payload: { count: 10 },
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{
type: '[Counter Page] set',
payload: { count: 10 },
},
{ scope: 'self' }
);
});

it('creates self-dispatching events with a custom scope', () => {
const usersPageEvents = eventGroup({
source: 'Users Page',
events: {
opened: type<void>(),
queryChanged: type<string>(),
paginationChanged: type<{ currentPage: number; pageSize: number }>(),
},
});
const dispatcher = TestBed.inject(Dispatcher);
const dispatch = TestBed.runInInjectionContext(() =>
injectDispatch(usersPageEvents)
);
vitest.spyOn(dispatcher, 'dispatch');

dispatch({ scope: 'self' }).opened();
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{
type: '[Users Page] opened',
payload: undefined,
},
{ scope: 'self' }
);

dispatch({ scope: 'parent' }).queryChanged('ngrx');
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{
type: '[Users Page] queryChanged',
payload: 'ngrx',
},
{ scope: 'parent' }
);

dispatch({ scope: 'global' }).paginationChanged({
currentPage: 10,
pageSize: 100,
});
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{
type: '[Users Page] paginationChanged',
payload: { currentPage: 10, pageSize: 100 },
},
{ scope: 'global' }
);
});

it('allows defining event names equal to predefined function properties', () => {
const fooEvents = eventGroup({
source: 'foo',
events: {
name: type<boolean>(),
toString: type<{ bar: number }>(),
},
});

const dispatcher = TestBed.inject(Dispatcher);
const dispatch = TestBed.runInInjectionContext(() =>
injectDispatch(fooEvents)
);
vitest.spyOn(dispatcher, 'dispatch');

dispatch.name(true);
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{ type: '[foo] name', payload: true },
{ scope: 'self' }
);

dispatch({ scope: 'parent' }).name(false);
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{ type: '[foo] name', payload: false },
{ scope: 'parent' }
);

dispatch.toString({ bar: 10 });
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{ type: '[foo] toString', payload: { bar: 10 } },
{ scope: 'self' }
);

dispatch({ scope: 'global' }).toString({ bar: 100 });
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{ type: '[foo] toString', payload: { bar: 100 } },
{ scope: 'global' }
);
});

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

dispatch.increment();
expect(dispatcher.dispatch).toHaveBeenCalledWith({ type: 'increment' });
expect(dispatcher.dispatch).toHaveBeenCalledWith(
{ type: 'increment', payload: undefined },
{ scope: 'self' }
);
});

it('throws an error when called outside of an injection context', () => {
Expand Down
51 changes: 50 additions & 1 deletion modules/signals/events/spec/with-effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ import {
withProps,
withState,
} from '@ngrx/signals';
import { Dispatcher, event, EventInstance, Events, withEffects } from '../src';
import {
Dispatcher,
event,
EventInstance,
Events,
mapToScope,
toScope,
withEffects,
} from '../src';
import { createLocalService } from '../../spec/helpers';

describe('withEffects', () => {
Expand Down Expand Up @@ -127,6 +135,47 @@ describe('withEffects', () => {
]);
});

it('dispatches an event with provided scope via toScope', () => {
const Store = signalStore(
{ providedIn: 'root' },
withEffects((_, events = inject(Events)) => ({
$: events.on(event1).pipe(map(() => [event2(), toScope('parent')])),
}))
);

const dispatcher = TestBed.inject(Dispatcher);
vitest.spyOn(dispatcher, 'dispatch');

TestBed.inject(Store);

dispatcher.dispatch(event1());
expect(dispatcher.dispatch).toHaveBeenCalledWith(event2(), {
scope: 'parent',
});
});

it('dispatches an event with provided scope via mapToScope', () => {
const Store = signalStore(
{ providedIn: 'root' },
withEffects((_, events = inject(Events)) => ({
$: events.on(event1).pipe(
map(() => event3('ngrx')),
mapToScope('global')
),
}))
);

const dispatcher = TestBed.inject(Dispatcher);
vitest.spyOn(dispatcher, 'dispatch');

TestBed.inject(Store);

dispatcher.dispatch(event1());
expect(dispatcher.dispatch).toHaveBeenCalledWith(event3('ngrx'), {
scope: 'global',
});
});

it('unsubscribes from effects when the store is destroyed', () => {
let executionCount = 0;

Expand Down
Loading