Skip to content

Commit

Permalink
feat(signals): add withProps base feature (#4607)
Browse files Browse the repository at this point in the history
BREAKING CHANGES:

- The `computed` property in `SignalStoreFeatureResult` type is renamed to `props`.
- The `EntityComputed` and `NamedEntityComputed` types in the `entities` plugin are renamed to `EntityProps` and `NamedEntityProps`.

BEFORE:

```ts
import { computed, Signal } from '@angular/core';
import {
  signalStoreFeature,
  SignalStoreFeature,
  type,
  withComputed,
} from '@ngrx/signals';
import { EntityComputed } from '@ngrx/signals/entities';

export function withTotalEntities<Entity>(): SignalStoreFeature<
  { state: {}, computed: EntityComputed<Entity>, methods: {} },
  { state: {}, computed: { total: Signal<number> }, methods: {} },
> {
  return signalStoreFeature(
    { computed: type<EntityComputed<Entity>>() },
    withComputed(({ entities }) => ({
      total: computed(() => entities().length),
    })),
  );
}
```

AFTER:

```ts
import { computed, Signal } from '@angular/core';
import {
  signalStoreFeature,
  SignalStoreFeature,
  type,
  withComputed,
} from '@ngrx/signals';
import { EntityProps } from '@ngrx/signals/entities';

export function withTotalEntities<Entity>(): SignalStoreFeature<
  { state: {}, props: EntityProps<Entity>, methods: {} },
  { state: {}, props: { total: Signal<number> }, methods: {} },
> {
  return signalStoreFeature(
    { props: type<EntityProps<Entity>>() },
    withComputed(({ entities }) => ({
      total: computed(() => entities().length),
    })),
  );
}
```
  • Loading branch information
markostanimirovic authored Nov 28, 2024
1 parent 2528d39 commit e626082
Show file tree
Hide file tree
Showing 23 changed files with 291 additions and 72 deletions.
4 changes: 2 additions & 2 deletions modules/signals/entities/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ export { updateAllEntities } from './updaters/update-all-entities';

export { entityConfig } from './entity-config';
export {
EntityComputed,
EntityId,
EntityMap,
EntityProps,
EntityState,
NamedEntityComputed,
NamedEntityProps,
NamedEntityState,
SelectEntityId,
} from './models';
Expand Down
6 changes: 3 additions & 3 deletions modules/signals/entities/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ export type NamedEntityState<Entity, Collection extends string> = {
[K in keyof EntityState<Entity> as `${Collection}${Capitalize<K>}`]: EntityState<Entity>[K];
};

export type EntityComputed<Entity> = {
export type EntityProps<Entity> = {
entities: Signal<Entity[]>;
};

export type NamedEntityComputed<Entity, Collection extends string> = {
[K in keyof EntityComputed<Entity> as `${Collection}${Capitalize<K>}`]: EntityComputed<Entity>[K];
export type NamedEntityProps<Entity, Collection extends string> = {
[K in keyof EntityProps<Entity> as `${Collection}${Capitalize<K>}`]: EntityProps<Entity>[K];
};

export type SelectEntityId<Entity> = (entity: Entity) => EntityId;
Expand Down
10 changes: 5 additions & 5 deletions modules/signals/entities/src/with-entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import {
withState,
} from '@ngrx/signals';
import {
EntityComputed,
EntityProps,
EntityId,
EntityMap,
EntityState,
NamedEntityComputed,
NamedEntityProps,
NamedEntityState,
} from './models';
import { getEntityStateKeys } from './helpers';
Expand All @@ -20,7 +20,7 @@ export function withEntities<Entity>(): SignalStoreFeature<
EmptyFeatureResult,
{
state: EntityState<Entity>;
computed: EntityComputed<Entity>;
props: EntityProps<Entity>;
methods: {};
}
>;
Expand All @@ -31,7 +31,7 @@ export function withEntities<Entity, Collection extends string>(config: {
EmptyFeatureResult,
{
state: NamedEntityState<Entity, Collection>;
computed: NamedEntityComputed<Entity, Collection>;
props: NamedEntityProps<Entity, Collection>;
methods: {};
}
>;
Expand All @@ -41,7 +41,7 @@ export function withEntities<Entity>(config: {
EmptyFeatureResult,
{
state: EntityState<Entity>;
computed: EntityComputed<Entity>;
props: EntityProps<Entity>;
methods: {};
}
>;
Expand Down
2 changes: 1 addition & 1 deletion modules/signals/spec/signal-store-feature.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('signalStoreFeature', () => {
return signalStoreFeature(
{
state: type<{ foo: string }>(),
computed: type<{ s: Signal<number> }>(),
props: type<{ s: Signal<number> }>(),
},
withState({ foo1: 1 }),
withState({ foo2: 2 })
Expand Down
53 changes: 51 additions & 2 deletions modules/signals/spec/signal-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
withComputed,
withHooks,
withMethods,
withProps,
withState,
} from '../src';
import { STATE_SOURCE } from '../src/state-source';
Expand Down Expand Up @@ -146,14 +147,54 @@ describe('signalStore', () => {
});
});

describe('withProps', () => {
it('provides previously defined state slices and properties as input argument', () => {
const Store = signalStore(
withState(() => ({ foo: 'foo' })),
withComputed(() => ({ bar: signal('bar').asReadonly() })),
withProps(() => ({ num: 10 })),
withProps(({ foo, bar, num }) => {
expect(foo()).toBe('foo');
expect(bar()).toBe('bar');
expect(num).toBe(10);

return { baz: num + 1 };
})
);

const store = new Store();

expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.num).toBe(10);
expect(store.baz).toBe(11);
});

it('executes withProps factory in injection context', () => {
const TOKEN = new InjectionToken('TOKEN', {
providedIn: 'root',
factory: () => ({ foo: 'bar' }),
});
const Store = signalStore(withProps(() => inject(TOKEN)));

TestBed.configureTestingModule({ providers: [Store] });
const store = TestBed.inject(Store);

expect(store.foo).toBe('bar');
});
});

describe('withComputed', () => {
it('provides previously defined state slices and computed signals as input argument', () => {
it('provides previously defined state slices and properties as input argument', () => {
const Store = signalStore(
withState(() => ({ foo: 'foo' })),
withComputed(() => ({ bar: signal('bar').asReadonly() })),
withComputed(({ foo, bar }) => {
withProps(() => ({ num: 10 })),
withComputed(({ foo, bar, num }) => {
expect(foo()).toBe('foo');
expect(bar()).toBe('bar');
expect(num).toBe(10);

return { baz: signal('baz').asReadonly() };
})
Expand All @@ -164,6 +205,7 @@ describe('signalStore', () => {
expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.num).toBe(10);
expect(store.baz()).toBe('baz');
});

Expand All @@ -187,11 +229,13 @@ describe('signalStore', () => {
withState(() => ({ foo: 'foo' })),
withComputed(() => ({ bar: signal('bar').asReadonly() })),
withMethods(() => ({ baz: () => 'baz' })),
withProps(() => ({ num: 100 })),
withMethods((store) => {
expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.baz()).toBe('baz');
expect(store.num).toBe(100);

return { m: () => 'm' };
})
Expand All @@ -203,6 +247,7 @@ describe('signalStore', () => {
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.baz()).toBe('baz');
expect(store.num).toBe(100);
expect(store.m()).toBe('m');
});

Expand Down Expand Up @@ -263,12 +308,14 @@ describe('signalStore', () => {
withState(() => ({ foo: 'foo' })),
withComputed(() => ({ bar: signal('bar').asReadonly() })),
withMethods(() => ({ baz: () => 'baz' })),
withProps(() => ({ num: 10 })),
withHooks({
onInit(store) {
expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' });
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.baz()).toBe('baz');
expect(store.num).toBe(10);
message = 'onInit';
},
})
Expand All @@ -285,11 +332,13 @@ describe('signalStore', () => {
withState(() => ({ foo: 'foo' })),
withComputed(() => ({ bar: signal('bar').asReadonly() })),
withMethods(() => ({ baz: () => 'baz' })),
withProps(() => ({ num: 100 })),
withHooks({
onDestroy(store) {
expect(store.foo()).toBe('foo');
expect(store.bar()).toBe('bar');
expect(store.baz()).toBe('baz');
expect(store.num).toBe(100);
message = 'onDestroy';
},
})
Expand Down
10 changes: 5 additions & 5 deletions modules/signals/spec/types/signal-store.types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,7 +846,7 @@ describe('signalStore', () => {
return signalStoreFeature(
{
state: type<{ q1: string }>(),
computed: type<{ sig: Signal<boolean> }>(),
props: type<{ sig: Signal<boolean> }>(),
},
withState({ y: initialY }),
withComputed(() => ({ sigY: computed(() => 'sigY') })),
Expand Down Expand Up @@ -932,7 +932,7 @@ describe('signalStore', () => {
${baseSnippet}
const feature = signalStoreFeature(
{ computed: type<{ sig: Signal<boolean> }>() },
{ props: type<{ sig: Signal<boolean> }>() },
withX(),
withState({ q1: 'q1' }),
withY(),
Expand Down Expand Up @@ -976,7 +976,7 @@ describe('signalStore', () => {
${baseSnippet}
const feature = signalStoreFeature(
{ computed: type<{ sig: Signal<string> }>() },
{ props: type<{ sig: Signal<string> }>() },
withX(),
withState({ q1: 'q1' }),
withY(),
Expand Down Expand Up @@ -1007,7 +1007,7 @@ describe('signalStore', () => {
const feature = signalStoreFeature(
{
computed: type<{ sig: Signal<boolean> }>(),
props: type<{ sig: Signal<boolean> }>(),
methods: type<{ f(): void; g(arg: string): string; }>(),
},
withX(),
Expand Down Expand Up @@ -1046,7 +1046,7 @@ describe('signalStore', () => {
entities: Entity[];
selectedEntity: Entity | null;
};
computed: {
props: {
selectedEntity2: Signal<Entity | undefined>;
};
methods: {
Expand Down
8 changes: 4 additions & 4 deletions modules/signals/spec/with-computed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ describe('withComputed', () => {

const store = withComputed(() => ({ s1, s2 }))(initialStore);

expect(Object.keys(store.computedSignals)).toEqual(['s1', 's2']);
expect(Object.keys(initialStore.computedSignals)).toEqual([]);
expect(Object.keys(store.props)).toEqual(['s1', 's2']);
expect(Object.keys(initialStore.props)).toEqual([]);

expect(store.computedSignals.s1).toBe(s1);
expect(store.computedSignals.s2).toBe(s2);
expect(store.props.s1).toBe(s1);
expect(store.props.s2).toBe(s2);
});

it('logs warning if previously defined signal store members have the same name', () => {
Expand Down
50 changes: 50 additions & 0 deletions modules/signals/spec/with-props.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { signal } from '@angular/core';
import { of } from 'rxjs';
import { withMethods, withProps, withState } from '../src';
import { getInitialInnerStore } from '../src/signal-store';

describe('withProps', () => {
it('adds properties to the store immutably', () => {
const initialStore = getInitialInnerStore();

const store = withProps(() => ({ p1: 1, p2: 2 }))(initialStore);

expect(Object.keys(store.props)).toEqual(['p1', 'p2']);
expect(Object.keys(initialStore.props)).toEqual([]);

expect(store.props.p1).toBe(1);
expect(store.props.p2).toBe(2);
});

it('logs warning if previously defined signal store members have the same name', () => {
const initialStore = [
withState({
s1: 10,
s2: 's2',
}),
withProps(() => ({
p1: of(100),
p2: 10,
})),
withMethods(() => ({
m1() {},
m2() {},
})),
].reduce((acc, feature) => feature(acc), getInitialInnerStore());
jest.spyOn(console, 'warn').mockImplementation();

withProps(() => ({
s1: { foo: 'bar' },
p: 10,
p2: signal(100),
m1: { ngrx: 'rocks' },
m3: of('m3'),
}))(initialStore);

expect(console.warn).toHaveBeenCalledWith(
'@ngrx/signals: SignalStore members cannot be overridden.',
'Trying to override:',
's1, p2, m1'
);
});
});
1 change: 1 addition & 0 deletions modules/signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export { Prettify } from './ts-helpers';
export { withComputed } from './with-computed';
export { withHooks } from './with-hooks';
export { withMethods } from './with-methods';
export { withProps } from './with-props';
export { withState } from './with-state';
2 changes: 1 addition & 1 deletion modules/signals/src/signal-store-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function assertUniqueStoreMembers(

const storeMembers = {
...store.stateSignals,
...store.computedSignals,
...store.props,
...store.methods,
};
const overriddenKeys = Object.keys(storeMembers).filter((memberKey) =>
Expand Down
2 changes: 1 addition & 1 deletion modules/signals/src/signal-store-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Prettify } from './ts-helpers';

type PrettifyFeatureResult<Result extends SignalStoreFeatureResult> = Prettify<{
state: Prettify<Result['state']>;
computed: Prettify<Result['computed']>;
props: Prettify<Result['props']>;
methods: Prettify<Result['methods']>;
}>;

Expand Down
12 changes: 6 additions & 6 deletions modules/signals/src/signal-store-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,26 @@ export type SignalStoreHooks = {

export type InnerSignalStore<
State extends object = object,
ComputedSignals extends SignalsDictionary = SignalsDictionary,
Props extends object = object,
Methods extends MethodsDictionary = MethodsDictionary
> = {
stateSignals: StateSignals<State>;
computedSignals: ComputedSignals;
props: Props;
methods: Methods;
hooks: SignalStoreHooks;
} & WritableStateSource<State>;

export type SignalStoreFeatureResult = {
state: object;
computed: SignalsDictionary;
props: object;
methods: MethodsDictionary;
};

export type EmptyFeatureResult = { state: {}; computed: {}; methods: {} };
export type EmptyFeatureResult = { state: {}; props: {}; methods: {} };

export type SignalStoreFeature<
Input extends SignalStoreFeatureResult = SignalStoreFeatureResult,
Output extends SignalStoreFeatureResult = SignalStoreFeatureResult
> = (
store: InnerSignalStore<Input['state'], Input['computed'], Input['methods']>
) => InnerSignalStore<Output['state'], Output['computed'], Output['methods']>;
store: InnerSignalStore<Input['state'], Input['props'], Input['methods']>
) => InnerSignalStore<Output['state'], Output['props'], Output['methods']>;
Loading

0 comments on commit e626082

Please sign in to comment.