Skip to content

Commit 57f8e4e

Browse files
refactor: final improvements
1 parent 8fa94f0 commit 57f8e4e

File tree

3 files changed

+168
-99
lines changed

3 files changed

+168
-99
lines changed
Lines changed: 143 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
1-
import { lastValueFrom, of } from 'rxjs';
1+
import {
2+
computed,
3+
inject,
4+
Injectable,
5+
ResourceStatus,
6+
Signal,
7+
} from '@angular/core';
28
import { TestBed } from '@angular/core/testing';
3-
import { computed, Signal } from '@angular/core';
9+
import { tapResponse } from '@ngrx/operators';
10+
import { lastValueFrom, Observable, of, pipe, switchMap, tap } from 'rxjs';
11+
import { describe, expect, it } from 'vitest';
12+
import { EntityState, setAllEntities, withEntities } from '../entities';
13+
import { rxMethod } from '../rxjs-interop';
414
import {
515
getState,
616
patchState,
717
signalStore,
818
signalStoreFeature,
19+
type,
920
withComputed,
1021
withFeature,
22+
withHooks,
1123
withMethods,
24+
withProps,
1225
withState,
1326
} from '../src';
1427

@@ -17,35 +30,23 @@ type User = {
1730
name: string;
1831
};
1932

20-
function withMyEntity<Entity>(loadMethod: (id: number) => Promise<Entity>) {
21-
return signalStoreFeature(
22-
withState({
23-
currentId: 1 as number | undefined,
24-
entity: undefined as undefined | Entity,
25-
}),
26-
withMethods((store) => ({
27-
async load(id: number) {
28-
const entity = await loadMethod(1);
29-
patchState(store, { entity, currentId: id });
30-
},
31-
}))
32-
);
33-
}
34-
35-
describe('withFeatureFactory', () => {
36-
it('should allow a sum feature', () => {
37-
function withSum(a: Signal<number>, b: Signal<number>) {
33+
describe('withFeature', () => {
34+
it('provides methods', async () => {
35+
function withMyEntity<Entity>(loadMethod: (id: number) => Promise<Entity>) {
3836
return signalStoreFeature(
39-
withComputed(() => ({ sum: computed(() => a() + b()) }))
37+
withState({
38+
currentId: 1 as number | undefined,
39+
entity: undefined as undefined | Entity,
40+
}),
41+
withMethods((store) => ({
42+
async load(id: number) {
43+
const entity = await loadMethod(1);
44+
patchState(store, { entity, currentId: id });
45+
},
46+
}))
4047
);
4148
}
42-
signalStore(
43-
withState({ a: 1, b: 2 }),
44-
withFeature((store) => withSum(store.a, store.b))
45-
);
46-
});
4749

48-
it('should allow to pass elements from a SignalStore to a feature', async () => {
4950
const UserStore = signalStore(
5051
{ providedIn: 'root' },
5152
withMethods(() => ({
@@ -66,4 +67,119 @@ describe('withFeatureFactory', () => {
6667
entity: { id: 1, name: 'Konrad' },
6768
});
6869
});
70+
71+
it('provides state signals', async () => {
72+
const withDouble = (n: Signal<number>) =>
73+
signalStoreFeature(
74+
withComputed((state) => ({ double: computed(() => n() * 2) }))
75+
);
76+
77+
const Store = signalStore(
78+
{ providedIn: 'root' },
79+
withState({ counter: 1 }),
80+
withMethods((store) => ({
81+
increaseCounter() {
82+
patchState(store, ({ counter }) => ({ counter: counter + 1 }));
83+
},
84+
})),
85+
withFeature(({ counter }) => withDouble(counter))
86+
);
87+
88+
const store = TestBed.inject(Store);
89+
90+
expect(store.double()).toBe(2);
91+
store.increaseCounter();
92+
expect(store.double()).toBe(4);
93+
});
94+
95+
it('provides properties', () => {
96+
@Injectable({ providedIn: 'root' })
97+
class Config {
98+
baseUrl = 'https://www.ngrx.io';
99+
}
100+
const withUrlizer = (baseUrl: string) =>
101+
signalStoreFeature(
102+
withMethods(() => ({
103+
createUrl: (path: string) =>
104+
`${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`,
105+
}))
106+
);
107+
108+
const Store = signalStore(
109+
{ providedIn: 'root' },
110+
withProps(() => ({
111+
_config: inject(Config),
112+
})),
113+
withFeature((store) => withUrlizer(store._config.baseUrl))
114+
);
115+
116+
const store = TestBed.inject(Store);
117+
expect(store.createUrl('docs')).toBe('https://www.ngrx.io/docs');
118+
});
119+
120+
it('can be cominbed with inputs', () => {
121+
function withLoadEntities<Entity extends { id: number }, Filter>(config: {
122+
filter: Signal<Filter>;
123+
loader: (filter: Filter) => Observable<Entity[]>;
124+
}) {
125+
return signalStoreFeature(
126+
type<{ state: EntityState<Entity> & { status: ResourceStatus } }>(),
127+
withMethods((store) => ({
128+
_loadEntities: rxMethod<Filter>(
129+
pipe(
130+
tap(() => patchState(store, { status: ResourceStatus.Loading })),
131+
switchMap((filter) =>
132+
config.loader(filter).pipe(
133+
tapResponse({
134+
next: (entities) =>
135+
patchState(
136+
store,
137+
{ status: ResourceStatus.Resolved },
138+
setAllEntities(entities)
139+
),
140+
error: () =>
141+
patchState(store, { status: ResourceStatus.Error }),
142+
})
143+
)
144+
)
145+
)
146+
),
147+
})),
148+
withHooks({
149+
onInit: ({ _loadEntities }) => _loadEntities(config.filter),
150+
})
151+
);
152+
}
153+
154+
const Store = signalStore(
155+
{ providedIn: 'root' },
156+
withEntities<User>(),
157+
withState({ filter: { name: '' }, status: ResourceStatus.Idle }),
158+
withMethods((store) => ({
159+
setFilter(name: string) {
160+
patchState(store, { filter: { name } });
161+
},
162+
_load(filters: { name: string }) {
163+
return of(
164+
[{ id: 1, name: 'Konrad' }].filter((person) =>
165+
person.name.startsWith(filters.name)
166+
)
167+
);
168+
},
169+
})),
170+
withFeature((store) =>
171+
withLoadEntities({ filter: store.filter, loader: store._load })
172+
)
173+
);
174+
175+
const store = TestBed.inject(Store);
176+
177+
expect(store.entities()).toEqual([]);
178+
store.setFilter('K');
179+
TestBed.flushEffects();
180+
expect(store.entities()).toEqual([{ id: 1, name: 'Konrad' }]);
181+
store.setFilter('Sabine');
182+
TestBed.flushEffects();
183+
expect(store.entities()).toEqual([]);
184+
});
69185
});

modules/signals/src/with-feature.ts

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,6 @@ import {
44
StateSignals,
55
} from './signal-store-models';
66

7-
type StoreForFactory<Input extends SignalStoreFeatureResult> = StateSignals<
8-
Input['state']
9-
> &
10-
Input['props'] &
11-
Input['methods'];
12-
137
/**
148
* Allows passing properties, methods, or signals from a SignalStore
159
* to a feature.
@@ -30,68 +24,22 @@ type StoreForFactory<Input extends SignalStoreFeatureResult> = StateSignals<
3024
* );
3125
* ```
3226
*
33-
* **Explanation:**
34-
*
35-
* Typically, a `signalStoreFeature` can have input constraints like:
36-
*
37-
* ```typescript
38-
* function withLoader() {
39-
* return signalStoreFeature(
40-
* type<{
41-
* methods: { load: (id: number) => Promise<Entity> };
42-
* }>()
43-
* // ...
44-
* );
45-
* }
46-
* ```
47-
*
48-
* It is not possible for every store to fulfill these constraints.
49-
* For example, a required method already might already with a different
50-
* signature and can't be replaced.
51-
*
52-
* `withFeature` allows replacing the hard input constraint with
53-
* a parameter that can be provided at the call site.
54-
*
55-
*
56-
* ```typescript
57-
* function withLoader(load: (id: number) => Promise<Entity>) {
58-
* return signalStoreFeature(
59-
* // ...
60-
* );
61-
* }
62-
*
63-
* signalStore(
64-
* withMethods((store) => ({
65-
* // returns Observable instead Promise,
66-
* // can't be changed. 👇
67-
* load(id: number): Obsevable<Entity> {
68-
* // some dummy implementation
69-
* },
70-
* })),
71-
* withFeature((store) =>
72-
* // provides the Promise-version without exposing
73-
* // it to the Store 👇
74-
* withEntityLoader((id) => firstValueFrom(store.load(id)))
75-
* )
76-
* );
77-
* ```
78-
*
7927
* @param featureFactory function returning the actual feature
8028
*/
8129
export function withFeature<
8230
Input extends SignalStoreFeatureResult,
8331
Output extends SignalStoreFeatureResult
8432
>(
8533
featureFactory: (
86-
store: StoreForFactory<Input>
34+
store: StateSignals<Input['state']> & Input['props'] & Input['methods']
8735
) => SignalStoreFeature<Input, Output>
8836
): SignalStoreFeature<Input, Output> {
8937
return (store) => {
9038
const storeForFactory = {
9139
...store['stateSignals'],
9240
...store['props'],
9341
...store['methods'],
94-
} as StoreForFactory<Input>;
42+
};
9543

9644
return featureFactory(storeForFactory)(store);
9745
};

projects/ngrx.io/content/guide/signals/signal-store/custom-store-features.md

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -319,35 +319,40 @@ const Store = signalStore(
319319
); // ✅ works as expected
320320
```
321321

322-
As an alternative, you can also consider using `withFeature`.
322+
For more complicated use cases, `withFeature` offers an alternative approach.
323323

324324
## Connecting a Custom Feature with the Store
325325

326-
The `withFeature` function allows you to pass properties, methods, or signals from a SignalStore to a custom feature.
326+
The `withFeature` function allows passing properties, methods, or signals from a SignalStore to a custom feature.
327327

328328
This is an alternative to the input approach above and allows more flexibility:
329329

330330
<code-example header="loader.store.ts">
331331

332-
import { Signal } from '@angular/core';
333-
import { signalStore, signalStoreFeature, withFeature, withMethods } from '@ngrx/signals';
334-
import { firstValueFrom, Observable } from 'rxjs';
335-
336-
type Entity = { id: number };
332+
import { computed, Signal } from '@angular/core';
333+
import { patchState, signalStore, signalStoreFeature, withComputed, withFeature, withMethods, withState } from '@ngrx/signals';
334+
import { withEntities } from '@ngrx/signals/entities';
337335

338-
function withLoader(load: (id: number) => Promise<Entity>) {
336+
export function withBooksFilter(books: Signal<Book[]>) {
339337
return signalStoreFeature(
340-
// some code...
341-
);
342-
}
338+
withState({ query: '' }),
339+
withComputed(({ query }) => ({
340+
filteredBooks: computed(() =>
341+
books().filter((b) => b.name.includes(query()))
342+
),
343+
})),
344+
withMethods((store) => ({
345+
setQuery(query: string): void {
346+
patchState(store, { query });
347+
},
348+
})),
349+
)};
343350

344-
const LoaderStore = signalStore(
345-
withMethods((store) => ({
346-
load(id: number): Observable<Entity> {
347-
// some code...
348-
},
349-
})),
350-
withFeature((store) => withLoader((id) => firstValueFrom(store.load(id))))
351+
export const BooksStore = signalStore(
352+
withEntities<Book>(),
353+
withFeature(({ entities }) =>
354+
withBooksFilter(entities)
355+
),
351356
);
352357

353358
</code-example>

0 commit comments

Comments
 (0)