Skip to content

Commit

Permalink
feat(store): add selectSignal method for interop with Angular Signals
Browse files Browse the repository at this point in the history
  • Loading branch information
brandonroberts committed Apr 23, 2023
1 parent 0fd8dee commit 513b8ea
Show file tree
Hide file tree
Showing 16 changed files with 269 additions and 84 deletions.
133 changes: 133 additions & 0 deletions modules/store/spec/integration_signals.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { TestBed } from '@angular/core/testing';
import { ActionReducerMap, Store, provideStore } from '@ngrx/store';

import { State } from '../src/private_export';
import {
ADD_TODO,
COMPLETE_ALL_TODOS,
COMPLETE_TODO,
SET_VISIBILITY_FILTER,
todos,
visibilityFilter,
VisibilityFilters,
resetId,
} from './fixtures/todos';

interface Todo {
id: number;
text: string;
completed: boolean;
}

interface TodoAppSchema {
visibilityFilter: string;
todos: Todo[];
}

describe('NgRx and Signals Integration spec', () => {
let store: Store<TodoAppSchema>;
let state: State<TodoAppSchema>;

const initialState = {
todos: [],
visibilityFilter: VisibilityFilters.SHOW_ALL,
};
const reducers: ActionReducerMap<TodoAppSchema, any> = {
todos: todos,
visibilityFilter: visibilityFilter,
};

beforeEach(() => {
resetId();
spyOn(reducers, 'todos').and.callThrough();

TestBed.configureTestingModule({
providers: [provideStore(reducers, { initialState })],
});

store = TestBed.inject(Store);
state = TestBed.inject(State);
});

describe('todo integration spec', function () {
describe('using the store.selectSignal', () => {
it('should use visibilityFilter to filter todos', () => {
store.dispatch({ type: ADD_TODO, payload: { text: 'first todo' } });
store.dispatch({ type: ADD_TODO, payload: { text: 'second todo' } });
store.dispatch({
type: COMPLETE_TODO,
payload: { id: state.value.todos[0].id },
});

const filterVisibleTodos = (
visibilityFilter: string,
todos: Todo[]
) => {
let predicate;
if (visibilityFilter === VisibilityFilters.SHOW_ALL) {
predicate = () => true;
} else if (visibilityFilter === VisibilityFilters.SHOW_ACTIVE) {
predicate = (todo: any) => !todo.completed;
} else {
predicate = (todo: any) => todo.completed;
}
return todos.filter(predicate);
};

const filter = TestBed.runInInjectionContext(() =>
store.selectSignal((state) => state.visibilityFilter)
);
const todos = TestBed.runInInjectionContext(() =>
store.selectSignal((state) => state.todos)
);
const currentlyVisibleTodos = () =>
filterVisibleTodos(filter(), todos());

expect(currentlyVisibleTodos().length).toBe(2);

store.dispatch({
type: SET_VISIBILITY_FILTER,
payload: VisibilityFilters.SHOW_ACTIVE,
});

expect(currentlyVisibleTodos().length).toBe(1);
expect(currentlyVisibleTodos()[0].completed).toBe(false);

store.dispatch({
type: SET_VISIBILITY_FILTER,
payload: VisibilityFilters.SHOW_COMPLETED,
});

expect(currentlyVisibleTodos().length).toBe(1);
expect(currentlyVisibleTodos()[0].completed).toBe(true);

store.dispatch({ type: COMPLETE_ALL_TODOS });

expect(currentlyVisibleTodos().length).toBe(2);
expect(currentlyVisibleTodos()[0].completed).toBe(true);
expect(currentlyVisibleTodos()[1].completed).toBe(true);

store.dispatch({
type: SET_VISIBILITY_FILTER,
payload: VisibilityFilters.SHOW_ACTIVE,
});

expect(currentlyVisibleTodos().length).toBe(0);
});
});
});

describe('context integration spec', () => {
it('Store.selectSignal should throw an error if used outside in the injection context', () => {
let error;

try {
store.selectSignal((state) => state.todos);
} catch (e) {
error = `${e}`;
}

expect(error).toContain('Error: NG0203');
});
});
});
58 changes: 58 additions & 0 deletions modules/store/spec/types/select_signal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expecter } from 'ts-snippet';
import { compilerOptions } from './utils';

describe('Store.selectSignal()', () => {
const expectSnippet = expecter(
(code) => `
import { Store, createSelector, createFeatureSelector } from '@ngrx/store';
interface State { foo: { bar: { baz: [] } } };
const store = {} as Store<State>;
const fooSelector = createFeatureSelector<State, State['foo']>('foo')
const barSelector = createSelector(fooSelector, s => s.bar)
${code}
`,
compilerOptions()
);

describe('as property', () => {
describe('with functions', () => {
it('should enforce that properties exists on state (root)', () => {
expectSnippet(
`const selector = store.selectSignal(s => s.mia);`
).toFail(/Property 'mia' does not exist on type 'State'/);
});

it('should enforce that properties exists on state (nested)', () => {
expectSnippet(
`const selector = store.selectSignal(s => s.foo.bar.mia);`
).toFail(/Property 'mia' does not exist on type '\{ baz: \[\]; \}'/);
});

it('should infer correctly (root)', () => {
expectSnippet(
`const selector = store.selectSignal(s => s.foo);`
).toInfer('selector', 'Signal<{ bar: { baz: []; }; }>');
});

it('should infer correctly (nested)', () => {
expectSnippet(
`const selector = store.selectSignal(s => s.foo.bar);`
).toInfer('selector', 'Signal<{ baz: []; }>');
});
});

describe('with selectors', () => {
it('should infer correctly', () => {
expectSnippet(
`const selector = store.selectSignal(fooSelector);`
).toInfer('selector', 'Signal<{ bar: { baz: []; }; }>');

expectSnippet(
`const selector = store.selectSignal(barSelector);`
).toInfer('selector', 'Signal<{ baz: []; }>');
});
});
});
});
14 changes: 13 additions & 1 deletion modules/store/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// disabled because we have lowercase generics for `select`
import { Injectable, Provider } from '@angular/core';
import { Injectable, Provider, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Observable, Observer, Operator } from 'rxjs';
import { distinctUntilChanged, map, pluck } from 'rxjs/operators';

Expand Down Expand Up @@ -93,6 +94,17 @@ export class Store<T = object>
return (select as any).call(null, pathOrMapFn, ...paths)(this);
}

/**
* Returns a signal of the provided selector.
* This method must be run within in injection context.
*
* @param selectorFn selector function
*/
selectSignal<K>(selectorFn: (state: T) => K): Signal<K>;
selectSignal<K = unknown>(selectorFn: (state: T) => K): Signal<unknown> {
return toSignal(this.select(selectorFn), { requireSync: true });
}

override lift<R>(operator: Operator<T, R>): Store<R> {
const store = new Store<R>(this, this.actionsObserver, this.reducerManager);
store.operator = operator;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ exports[`Logout Confirmation Dialog should compile 1`] = `
<button
class="mdc-button mat-mdc-button mat-unthemed mat-mdc-button-base"
mat-button=""
mat-button-is-fab="false"
mat-button-ripple-uninitialized=""
type="button"
>
<span
Expand All @@ -30,17 +32,15 @@ exports[`Logout Confirmation Dialog should compile 1`] = `
<span
class="mat-mdc-focus-indicator"
/>
<span
class="mat-ripple mat-mdc-button-ripple"
matripple=""
/>
<span
class="mat-mdc-button-touch-target"
/>
</button>
<button
class="mdc-button mat-mdc-button mat-unthemed mat-mdc-button-base"
mat-button=""
mat-button-is-fab="false"
mat-button-ripple-uninitialized=""
type="button"
>
<span
Expand All @@ -54,10 +54,6 @@ exports[`Logout Confirmation Dialog should compile 1`] = `
<span
class="mat-mdc-focus-indicator"
/>
<span
class="mat-ripple mat-mdc-button-ripple"
matripple=""
/>
<span
class="mat-mdc-button-touch-target"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

exports[`Login Page should compile 1`] = `
<bc-login-page
error$={[Function Store]}
pending$={[Function Store]}
error={[Function Function]}
pending={[Function Function]}
store={[Function MockStore]}
>
<bc-login-form>
Expand Down Expand Up @@ -59,7 +59,7 @@ exports[`Login Page should compile 1`] = `
>
<div
class="mat-mdc-form-field-hint-wrapper ng-trigger ng-trigger-transitionMessages"
style="opacity:1;transform:translateY(0%);0:opacity;1:transform;"
style="opacity: 1; transform: translateY(0%);"
>
<div
class="mat-mdc-form-field-hint-spacer"
Expand Down Expand Up @@ -105,7 +105,7 @@ exports[`Login Page should compile 1`] = `
>
<div
class="mat-mdc-form-field-hint-wrapper ng-trigger ng-trigger-transitionMessages"
style="opacity:1;transform:translateY(0%);0:opacity;1:transform;"
style="opacity: 1; transform: translateY(0%);"
>
<div
class="mat-mdc-form-field-hint-spacer"
Expand All @@ -120,6 +120,8 @@ exports[`Login Page should compile 1`] = `
<button
class="mdc-button mat-mdc-button _mat-animation-noopable mat-unthemed mat-mdc-button-base"
mat-button=""
mat-button-is-fab="false"
mat-button-ripple-uninitialized=""
type="submit"
>
<span
Expand All @@ -133,10 +135,6 @@ exports[`Login Page should compile 1`] = `
<span
class="mat-mdc-focus-indicator"
/>
<span
class="mat-ripple mat-mdc-button-ripple"
matripple=""
/>
<span
class="mat-mdc-button-touch-target"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('Login Page', () => {
provideMockStore({
selectors: [
{ selector: fromAuth.selectLoginPagePending, value: false },
{ selector: fromAuth.selectLoginPageError, value: false },
],
}),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ import { LoginPageActions } from '@example-app/auth/actions';
template: `
<bc-login-form
(submitted)="onSubmit($event)"
[pending]="(pending$ | async)!"
[errorMessage]="error$ | async"
[pending]="pending()!"
[errorMessage]="error()"
>
</bc-login-form>
`,
styles: [],
})
export class LoginPageComponent {
pending$ = this.store.select(fromAuth.selectLoginPagePending);
error$ = this.store.select(fromAuth.selectLoginPageError);
pending = this.store.selectSignal(fromAuth.selectLoginPagePending);
error = this.store.selectSignal(fromAuth.selectLoginPageError);

constructor(private store: Store) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

exports[`Collection Page should compile 1`] = `
<bc-collection-page
books$={[Function Store]}
books={[Function Function]}
store={[Function MockStore]}
>
<mat-card
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

exports[`Find Book Page should compile 1`] = `
<bc-find-book-page
books$={[Function Store]}
error$={[Function Store]}
loading$={[Function Store]}
books={[Function Function]}
error={[Function Function]}
loading={[Function Function]}
searchQuery$={[Function Store]}
store={[Function MockStore]}
>
Expand Down Expand Up @@ -54,7 +54,7 @@ exports[`Find Book Page should compile 1`] = `
>
<div
class="mat-mdc-form-field-hint-wrapper ng-trigger ng-trigger-transitionMessages"
style="opacity:1;transform:translateY(0%);0:opacity;1:transform;"
style="opacity: 1; transform: translateY(0%);"
>
<div
class="mat-mdc-form-field-hint-spacer"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

exports[`Selected Book Page should compile 1`] = `
<bc-selected-book-page
book$={[Function Store]}
isSelectedBookInCollection$={[Function Store]}
book={[Function Function]}
isSelectedBookInCollection={[Function Function]}
store={[Function MockStore]}
>
<bc-book-detail>
Expand Down
Loading

0 comments on commit 513b8ea

Please sign in to comment.