Skip to content

Commit 74871a9

Browse files
feat(signals): make EntityId type an optional argument in withEntities
1 parent a23a0a1 commit 74871a9

File tree

4 files changed

+212
-16
lines changed

4 files changed

+212
-16
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { expecter } from 'ts-snippet';
2+
import { compilerOptions } from './helpers';
3+
4+
describe('withEntities', () => {
5+
const expectSnippet = expecter(
6+
(code) => `
7+
import { signalStore, type } from '@ngrx/signals';
8+
import { withEntities, entityConfig} from '@ngrx/signals/entities';
9+
10+
enum UserId {
11+
One = '1',
12+
Two = '2',
13+
Three = '3',
14+
}
15+
16+
${code}
17+
`,
18+
compilerOptions()
19+
);
20+
21+
it('succeeds when only Entity type is provided as a generic', () => {
22+
const snippet = `
23+
type User = { id: UserId; name: string };
24+
25+
const Store = signalStore(
26+
withEntities<User>()
27+
);
28+
29+
const store = new Store();
30+
const ids = store.ids();
31+
const entityMap = store.entityMap();
32+
`;
33+
34+
expectSnippet(snippet).toSucceed();
35+
expectSnippet(snippet).toInfer('ids', 'EntityId[]');
36+
expectSnippet(snippet).toInfer('entityMap', 'EntityMap<User, EntityId>');
37+
});
38+
39+
it('succeeds when both Entity and Id types are provided as generics', () => {
40+
const snippet = `
41+
type User = { id: UserId; name: string };
42+
43+
const Store = signalStore(
44+
withEntities<User, UserId>()
45+
);
46+
47+
const store = new Store();
48+
const ids = store.ids();
49+
const entityMap = store.entityMap();
50+
`;
51+
52+
expectSnippet(snippet).toSucceed();
53+
expectSnippet(snippet).toInfer('ids', 'UserId[]');
54+
expectSnippet(snippet).toInfer('entityMap', 'EntityMap<User, UserId>');
55+
});
56+
57+
it('succeeds when Entity type is provided using entityConfig', () => {
58+
const snippet = `
59+
type User = { id: UserId; name: string };
60+
61+
const Store = signalStore(
62+
withEntities(entityConfig({entity: type<User>()}))
63+
);
64+
65+
const store = new Store();
66+
const ids = store.ids();
67+
const entityMap = store.entityMap();
68+
`;
69+
70+
expectSnippet(snippet).toSucceed();
71+
expectSnippet(snippet).toInfer('ids', 'EntityId[]');
72+
expectSnippet(snippet).toInfer('entityMap', 'EntityMap<User, EntityId>');
73+
});
74+
75+
it('succeeds when Entity type is provided as both a generic and in entityConfig', () => {
76+
const snippet = `
77+
type User = { id: UserId; name: string };
78+
79+
const Store = signalStore(
80+
withEntities<User>(entityConfig({entity: type<User>()}))
81+
);
82+
83+
const store = new Store();
84+
const ids = store.ids();
85+
const entityMap = store.entityMap();
86+
`;
87+
88+
expectSnippet(snippet).toSucceed();
89+
expectSnippet(snippet).toInfer('ids', 'EntityId[]');
90+
expectSnippet(snippet).toInfer('entityMap', 'EntityMap<User, EntityId>');
91+
});
92+
93+
it('succeeds when Entity type is provided as both a generic and in entityConfig, with Id type as a generic', () => {
94+
const snippet = `
95+
type User = { id: UserId; name: string };
96+
97+
const Store = signalStore(
98+
withEntities<User, UserId>(entityConfig({entity: type<User>()}))
99+
);
100+
101+
const store = new Store();
102+
const ids = store.ids();
103+
const entityMap = store.entityMap();
104+
`;
105+
106+
expectSnippet(snippet).toSucceed();
107+
expectSnippet(snippet).toInfer('ids', 'UserId[]');
108+
expectSnippet(snippet).toInfer('entityMap', 'EntityMap<User, UserId>');
109+
});
110+
111+
it('succeeds when Entity type is provided with a custom selectId function', () => {
112+
const snippet = `
113+
type User = { key: UserId; name: string };
114+
115+
const Store = signalStore(
116+
withEntities<User, UserId>(entityConfig({
117+
entity: type<User>(),
118+
selectId: (user) => user.key
119+
}))
120+
);
121+
122+
const store = new Store();
123+
const ids = store.ids();
124+
const entityMap = store.entityMap();
125+
`;
126+
127+
expectSnippet(snippet).toSucceed();
128+
expectSnippet(snippet).toInfer('ids', 'UserId[]');
129+
expectSnippet(snippet).toInfer('entityMap', 'EntityMap<User, UserId>');
130+
});
131+
132+
it('succeeds when Entity type is provided with a custom collection name', () => {
133+
const snippet = `
134+
type User = { id: UserId; name: string };
135+
136+
const Store = signalStore(
137+
withEntities(entityConfig({
138+
entity: type<User>(),
139+
collection: 'user'
140+
}))
141+
);
142+
143+
const store = new Store();
144+
const ids = store.userIds();
145+
const entityMap = store.userEntityMap();
146+
`;
147+
148+
expectSnippet(snippet).toSucceed();
149+
expectSnippet(snippet).toInfer('ids', 'EntityId[]');
150+
expectSnippet(snippet).toInfer('entityMap', 'EntityMap<User, EntityId>');
151+
});
152+
153+
it('succeeds when Entity, Id type, and custom collection name are provided', () => {
154+
const snippet = `
155+
type User = { id: UserId; name: string };
156+
157+
const Store = signalStore(
158+
withEntities<User, 'user', UserId>(entityConfig({
159+
entity: type<User>(),
160+
collection: 'user'
161+
}))
162+
);
163+
164+
const store = new Store();
165+
const ids = store.userIds();
166+
const entityMap = store.userEntityMap();
167+
`;
168+
169+
expectSnippet(snippet).toSucceed();
170+
expectSnippet(snippet).toInfer('ids', 'UserId[]');
171+
expectSnippet(snippet).toInfer('entityMap', 'EntityMap<User, UserId>');
172+
});
173+
});

modules/signals/entities/src/models.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,25 @@ import { Signal } from '@angular/core';
22

33
export type EntityId = string | number;
44

5-
export type EntityMap<Entity> = Record<EntityId, Entity>;
6-
7-
export type EntityState<Entity> = {
8-
entityMap: EntityMap<Entity>;
9-
ids: EntityId[];
5+
export type EntityMap<Entity, Id extends EntityId = EntityId> = Record<
6+
Id,
7+
Entity
8+
>;
9+
10+
export type EntityState<Entity, Id extends EntityId = EntityId> = {
11+
entityMap: EntityMap<Entity, Id>;
12+
ids: Id[];
1013
};
1114

12-
export type NamedEntityState<Entity, Collection extends string> = {
13-
[K in keyof EntityState<Entity> as `${Collection}${Capitalize<K>}`]: EntityState<Entity>[K];
15+
export type NamedEntityState<
16+
Entity,
17+
Collection extends string,
18+
Id extends EntityId = EntityId
19+
> = {
20+
[K in keyof EntityState<
21+
Entity,
22+
Id
23+
> as `${Collection}${Capitalize<K>}`]: EntityState<Entity, Id>[K];
1424
};
1525

1626
export type EntityProps<Entity> = {

modules/signals/entities/src/with-entities.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,36 +16,43 @@ import {
1616
} from './models';
1717
import { getEntityStateKeys } from './helpers';
1818

19-
export function withEntities<Entity>(): SignalStoreFeature<
19+
export function withEntities<
20+
Entity,
21+
Id extends EntityId = EntityId
22+
>(): SignalStoreFeature<
2023
EmptyFeatureResult,
2124
{
22-
state: EntityState<Entity>;
25+
state: EntityState<Entity, Id>;
2326
props: EntityProps<Entity>;
2427
methods: {};
2528
}
2629
>;
27-
export function withEntities<Entity, Collection extends string>(config: {
30+
export function withEntities<
31+
Entity,
32+
Collection extends string,
33+
Id extends EntityId = EntityId
34+
>(config: {
2835
entity: Entity;
2936
collection: Collection;
3037
}): SignalStoreFeature<
3138
EmptyFeatureResult,
3239
{
33-
state: NamedEntityState<Entity, Collection>;
40+
state: NamedEntityState<Entity, Collection, Id>;
3441
props: NamedEntityProps<Entity, Collection>;
3542
methods: {};
3643
}
3744
>;
38-
export function withEntities<Entity>(config: {
45+
export function withEntities<Entity, Id extends EntityId = EntityId>(config: {
3946
entity: Entity;
4047
}): SignalStoreFeature<
4148
EmptyFeatureResult,
4249
{
43-
state: EntityState<Entity>;
50+
state: EntityState<Entity, Id>;
4451
props: EntityProps<Entity>;
4552
methods: {};
4653
}
4754
>;
48-
export function withEntities<Entity>(config?: {
55+
export function withEntities<Entity, Id extends EntityId = EntityId>(config?: {
4956
entity: Entity;
5057
collection?: string;
5158
}): SignalStoreFeature {
@@ -58,8 +65,8 @@ export function withEntities<Entity>(config?: {
5865
}),
5966
withComputed((store: Record<string, Signal<unknown>>) => ({
6067
[entitiesKey]: computed(() => {
61-
const entityMap = store[entityMapKey]() as EntityMap<Entity>;
62-
const ids = store[idsKey]() as EntityId[];
68+
const entityMap = store[entityMapKey]() as EntityMap<Entity, Id>;
69+
const ids = store[idsKey]() as Id[];
6370

6471
return ids.map((id) => entityMap[id]);
6572
}),

projects/ngrx.io/content/guide/signals/signal-store/entity-management.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ The `withEntities` feature adds the following signals to the `TodosStore`:
3434

3535
The `ids` and `entityMap` are state slices, while `entities` is a computed signal.
3636

37+
If you need to use a custom type for `ids` and `entityMap` keys that extends `number` or `string`, you can pass this type as a generic:
38+
39+
```ts
40+
withEntities<User, UserId>();
41+
```
42+
3743
## Entity Updaters
3844

3945
The `entities` plugin provides a set of standalone entity updaters.

0 commit comments

Comments
 (0)