diff --git a/modules/store/spec/integration_signals.spec.ts b/modules/store/spec/integration_signals.spec.ts new file mode 100644 index 0000000000..8eed6a92ca --- /dev/null +++ b/modules/store/spec/integration_signals.spec.ts @@ -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; + let state: State; + + const initialState = { + todos: [], + visibilityFilter: VisibilityFilters.SHOW_ALL, + }; + const reducers: ActionReducerMap = { + 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'); + }); + }); +}); diff --git a/modules/store/spec/types/select_signal.spec.ts b/modules/store/spec/types/select_signal.spec.ts new file mode 100644 index 0000000000..1d7409ad5b --- /dev/null +++ b/modules/store/spec/types/select_signal.spec.ts @@ -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; + const fooSelector = createFeatureSelector('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: []; }>'); + }); + }); + }); +}); diff --git a/modules/store/src/store.ts b/modules/store/src/store.ts index de11ccb53c..ef5f1ddbb0 100644 --- a/modules/store/src/store.ts +++ b/modules/store/src/store.ts @@ -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'; @@ -93,6 +94,17 @@ export class Store 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(selectorFn: (state: T) => K): Signal; + selectSignal(selectorFn: (state: T) => K): Signal { + return toSignal(this.select(selectorFn), { requireSync: true }); + } + override lift(operator: Operator): Store { const store = new Store(this, this.actionsObserver, this.reducerManager); store.operator = operator; diff --git a/projects/example-app/src/app/auth/components/__snapshots__/logout-confirmation-dialog.component.spec.ts.snap b/projects/example-app/src/app/auth/components/__snapshots__/logout-confirmation-dialog.component.spec.ts.snap index 026a8232cf..d403a13046 100644 --- a/projects/example-app/src/app/auth/components/__snapshots__/logout-confirmation-dialog.component.spec.ts.snap +++ b/projects/example-app/src/app/auth/components/__snapshots__/logout-confirmation-dialog.component.spec.ts.snap @@ -17,6 +17,8 @@ exports[`Logout Confirmation Dialog should compile 1`] = `