Skip to content

Commit

Permalink
feat(signals): add signalStore and signalStoreFeature (#4049)
Browse files Browse the repository at this point in the history
Closes #4000
  • Loading branch information
markostanimirovic committed Sep 22, 2023
1 parent 58e2c46 commit 0010281
Show file tree
Hide file tree
Showing 20 changed files with 1,605 additions and 62 deletions.
27 changes: 26 additions & 1 deletion modules/signals/spec/helpers.ts
@@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, inject, Type } from '@angular/core';
import { TestBed } from '@angular/core/testing';

export function testEffects(testFn: (tick: () => void) => void): () => void {
Expand All @@ -13,3 +13,28 @@ export function testEffects(testFn: (tick: () => void) => void): () => void {
TestBed.runInInjectionContext(() => testFn(() => fixture.detectChanges()));
};
}

export function createLocalStore<Store extends Type<unknown>>(
storeToken: Store
): {
store: InstanceType<Store>;
destroy: () => void;
} {
@Component({
standalone: true,
template: '',
providers: [storeToken],
})
class TestComponent {
store = inject(storeToken);
}

const fixture = TestBed.configureTestingModule({
imports: [TestComponent],
}).createComponent(TestComponent);

return {
store: fixture.componentInstance.store,
destroy: () => fixture.destroy(),
};
}
121 changes: 69 additions & 52 deletions modules/signals/spec/patch-state.spec.ts
@@ -1,4 +1,5 @@
import { patchState, signalState } from '../src';
import { patchState, signalState, signalStore, withState } from '../src';
import { STATE_SIGNAL } from '../src/signal-state';

describe('patchState', () => {
const initialState = {
Expand All @@ -11,67 +12,83 @@ describe('patchState', () => {
ngrx: 'signals',
};

it('patches state via partial state object', () => {
const state = signalState(initialState);
[
{
name: 'with signalState',
stateFactory: () => signalState(initialState),
},
{
name: 'with signalStore',
stateFactory: () => {
const SignalStore = signalStore(withState(initialState));
return new SignalStore();
},
},
].forEach(({ name, stateFactory }) => {
describe(name, () => {
it('patches state via partial state object', () => {
const state = stateFactory();

patchState(state, {
user: { firstName: 'Johannes', lastName: 'Schmidt' },
foo: 'baz',
});
patchState(state, {
user: { firstName: 'Johannes', lastName: 'Schmidt' },
foo: 'baz',
});

expect(state()).toEqual({
...initialState,
user: { firstName: 'Johannes', lastName: 'Schmidt' },
foo: 'baz',
});
});
expect(state[STATE_SIGNAL]()).toEqual({
...initialState,
user: { firstName: 'Johannes', lastName: 'Schmidt' },
foo: 'baz',
});
});

it('patches state via updater function', () => {
const state = signalState(initialState);
it('patches state via updater function', () => {
const state = stateFactory();

patchState(state, (state) => ({
numbers: [...state.numbers, 4],
ngrx: 'rocks',
}));
patchState(state, (state) => ({
numbers: [...state.numbers, 4],
ngrx: 'rocks',
}));

expect(state()).toEqual({
...initialState,
numbers: [1, 2, 3, 4],
ngrx: 'rocks',
});
});
expect(state[STATE_SIGNAL]()).toEqual({
...initialState,
numbers: [1, 2, 3, 4],
ngrx: 'rocks',
});
});

it('patches state via sequence of partial state objects and updater functions', () => {
const state = signalState(initialState);
it('patches state via sequence of partial state objects and updater functions', () => {
const state = stateFactory();

patchState(
state,
{ user: { firstName: 'Johannes', lastName: 'Schmidt' } },
(state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }),
(state) => ({ user: { ...state.user, firstName: 'Jovan' } }),
{ foo: 'foo' }
);
patchState(
state,
{ user: { firstName: 'Johannes', lastName: 'Schmidt' } },
(state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }),
(state) => ({ user: { ...state.user, firstName: 'Jovan' } }),
{ foo: 'foo' }
);

expect(state()).toEqual({
...initialState,
user: { firstName: 'Jovan', lastName: 'Schmidt' },
foo: 'foo',
numbers: [1, 2, 3, 4],
});
});
expect(state[STATE_SIGNAL]()).toEqual({
...initialState,
user: { firstName: 'Jovan', lastName: 'Schmidt' },
foo: 'foo',
numbers: [1, 2, 3, 4],
});
});

it('patches state immutably', () => {
const state = signalState(initialState);
it('patches state immutably', () => {
const state = stateFactory();

patchState(state, {
foo: 'bar',
numbers: [3, 2, 1],
ngrx: 'rocks',
});
patchState(state, {
foo: 'bar',
numbers: [3, 2, 1],
ngrx: 'rocks',
});

expect(state.user()).toBe(initialState.user);
expect(state.foo()).toBe(initialState.foo);
expect(state.numbers()).not.toBe(initialState.numbers);
expect(state.ngrx()).not.toBe(initialState.ngrx);
expect(state.user()).toBe(initialState.user);
expect(state.foo()).toBe(initialState.foo);
expect(state.numbers()).not.toBe(initialState.numbers);
expect(state.ngrx()).not.toBe(initialState.ngrx);
});
});
});
});
9 changes: 9 additions & 0 deletions modules/signals/spec/signal-state.spec.ts
@@ -1,5 +1,6 @@
import { effect, isSignal } from '@angular/core';
import { patchState, signalState } from '../src';
import { STATE_SIGNAL } from '../src/signal-state';
import { testEffects } from './helpers';

describe('signalState', () => {
Expand All @@ -13,6 +14,14 @@ describe('signalState', () => {
ngrx: 'signals',
};

it('has state signal', () => {
const state = signalState({});
const stateSignal = state[STATE_SIGNAL];

expect(isSignal(stateSignal)).toBe(true);
expect(typeof stateSignal.update === 'function').toBe(true);
});

it('creates signals for nested state slices', () => {
const state = signalState(initialState);

Expand Down
94 changes: 94 additions & 0 deletions modules/signals/spec/signal-store-feature.spec.ts
@@ -0,0 +1,94 @@
import { Signal, signal } from '@angular/core';
import {
selectSignal,
signalStore,
signalStoreFeature,
type,
withMethods,
withSignals,
withState,
} from '../src';
import { STATE_SIGNAL } from '../src/signal-state';

describe('signalStoreFeature', () => {
function withCustomFeature1() {
return signalStoreFeature(
withState({ foo: 'foo' }),
withSignals(({ foo }) => ({ bar: selectSignal(() => foo() + 1) })),
withMethods(({ foo, bar }) => ({
baz: () => foo() + bar() + 2,
}))
);
}

function withCustomFeature2() {
return signalStoreFeature(
withCustomFeature1(),
withMethods(({ foo, baz }) => ({
bar: (value: number) => value,
m: () => foo() + baz() + 3,
}))
);
}

function withCustomFeatureWithInput<_>() {
return signalStoreFeature(
{
state: type<{ foo: string }>(),
signals: type<{ s: Signal<number> }>(),
},
withState({ foo1: 1 }),
withState({ foo2: 2 })
);
}

it('creates a custom feature by combining base features', () => {
const Store = signalStore(
withCustomFeature1(),
withSignals(({ bar }) => ({
s: selectSignal(() => bar() + 's'),
}))
);

const store = new Store();

expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' });
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('foo1');
expect(store.baz()).toBe('foofoo12');
expect(store.s()).toBe('foo1s');
});

it('creates a custom feature by combining base and custom features', () => {
const Store = signalStore(
withCustomFeature2(),
withMethods(({ foo }) => ({ m1: () => foo() + 10 }))
);

const store = new Store();

expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' });
expect(store.foo()).toBe('foo');
expect(store.bar(10)).toBe(10);
expect(store.m()).toBe('foofoofoo123');
expect(store.m1()).toBe('foo10');
});

it('creates a custom feature with input', () => {
const Store = signalStore(
withCustomFeature1(),
withSignals(() => ({ s: signal(1).asReadonly() })),
withCustomFeatureWithInput()
);

const store = new Store();

expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo', foo1: 1, foo2: 2 });
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('foo1');
expect(store.baz()).toBe('foofoo12');
expect(store.s()).toBe(1);
expect(store.foo1()).toBe(1);
expect(store.foo2()).toBe(2);
});
});

0 comments on commit 0010281

Please sign in to comment.