Skip to content

Commit 6e9b19e

Browse files
committed
chore: wip resource snapshots for withEntityResource
1 parent 12ab13f commit 6e9b19e

2 files changed

Lines changed: 215 additions & 29 deletions

File tree

libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
setAllEntities,
88
} from '@ngrx/signals/entities';
99
import { withEntityResources } from './with-entity-resources';
10+
import { withPreviousValue } from './with-resource/tests/util/snapshot';
1011

1112
type Todo = { id: number; title: string; completed: boolean };
1213
const wait = (ms = 0) => new Promise((r) => setTimeout(r, ms));
@@ -199,6 +200,49 @@ describe('withEntityResources', () => {
199200
]);
200201
});
201202

203+
it('does not support setAllEntities/addEntity/removeEntity for unnamed which are of type Resource', async () => {
204+
const Store = signalStore(
205+
{ providedIn: 'root', protectedState: false },
206+
withEntityResources(() =>
207+
withPreviousValue(
208+
resource({
209+
loader: () => Promise.resolve([] as Todo[]),
210+
defaultValue: [],
211+
}),
212+
),
213+
),
214+
);
215+
const store = TestBed.inject(Store);
216+
217+
await wait();
218+
219+
const invalidSetAll = () =>
220+
patchState(
221+
store,
222+
// @ts-expect-error Resources which are of type Resource should not support entity updaters
223+
setAllEntities([
224+
{ id: 1, title: 'A', completed: false },
225+
{ id: 2, title: 'B', completed: true },
226+
] as Todo[]),
227+
);
228+
229+
const invalidAdd = () =>
230+
patchState(
231+
store,
232+
// @ts-expect-error Resources which are of type Resource should not support entity updaters
233+
addEntity({ id: 3, title: 'C', completed: false } as Todo),
234+
);
235+
236+
// @ts-expect-error Resources which are of type Resource should not support entity updaters
237+
const invalidRemove = () => patchState(store, removeEntity(2));
238+
239+
expect(invalidSetAll).toBeDefined();
240+
expect(invalidAdd).toBeDefined();
241+
expect(invalidRemove).toBeDefined();
242+
expect(store.ids()).toEqual([]);
243+
expect(store.entities()).toEqual([]);
244+
});
245+
202246
it('supports setAllEntities/addEntity/removeEntity for named', async () => {
203247
const Store = signalStore(
204248
{ providedIn: 'root', protectedState: false },

libs/ngrx-toolkit/src/lib/with-entity-resources.ts

Lines changed: 171 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ResourceSnapshot,
55
ResourceStatus,
66
Signal,
7+
computed,
78
isSignal,
89
linkedSignal,
910
} from '@angular/core';
@@ -14,6 +15,7 @@ import {
1415
signalStoreFeature,
1516
withComputed,
1617
withLinkedState,
18+
withProps,
1719
} from '@ngrx/signals';
1820
import {
1921
EntityId,
@@ -103,7 +105,16 @@ export function withEntityResources<
103105
>(
104106
resourceFactory: (
105107
store: Input['props'] & Input['methods'] & StateSignals<Input['state']>,
106-
) => WidenedResource<TypedEntityResourceValue<Entity>>,
108+
) => ResourceRef<TypedEntityResourceValue<Entity>>,
109+
): SignalStoreFeature<Input, EntityResourceRefResult<Entity>>;
110+
111+
export function withEntityResources<
112+
Input extends SignalStoreFeatureResult,
113+
Entity extends { id: EntityId },
114+
>(
115+
resourceFactory: (
116+
store: Input['props'] & Input['methods'] & StateSignals<Input['state']>,
117+
) => Resource<TypedEntityResourceValue<Entity>>,
107118
): SignalStoreFeature<Input, EntityResourceResult<Entity>>;
108119

109120
export function withEntityResources<
@@ -131,7 +142,7 @@ export function withEntityResources<
131142
});
132143

133144
if (isResourceRef(resourceOrDict)) {
134-
return createUnnamedEntityResource(resourceOrDict)(store);
145+
return createUnnamedEntityResourceRef(resourceOrDict)(store);
135146
} else if (isResource(resourceOrDict)) {
136147
return createUnnamedEntityResource(resourceOrDict)(store);
137148
}
@@ -145,10 +156,8 @@ export function withEntityResources<
145156
* because {@link withResource} creates a Proxy around the resource value
146157
* to avoid the error throwing behavior of the Resource API.
147158
*/
148-
function createUnnamedEntityResource<E extends Entity>(
149-
resource:
150-
| ResourceRef<TypedEntityResourceValue<E>>
151-
| Resource<TypedEntityResourceValue<E>>,
159+
function createUnnamedEntityResourceRef<E extends Entity>(
160+
resource: ResourceRef<TypedEntityResourceValue<E>>,
152161
) {
153162
return signalStoreFeature(
154163
withResource(() => resource),
@@ -182,6 +191,38 @@ function createUnnamedEntityResource<E extends Entity>(
182191
);
183192
}
184193

194+
function createUnnamedEntityResource<E extends Entity>(
195+
resource: Resource<TypedEntityResourceValue<E>>,
196+
) {
197+
return signalStoreFeature(
198+
withResource(() => resource),
199+
withProps((store) => {
200+
const propsStore = store as {
201+
value: Signal<TypedEntityResourceValue<E>>;
202+
status: Signal<ResourceStatus>;
203+
error: Signal<Error | undefined>;
204+
isLoading: Signal<boolean>;
205+
snapshot: Signal<ResourceSnapshot<TypedEntityResourceValue<E>>>;
206+
};
207+
208+
const resourceValue = propsStore.value as Signal<EntityResourceValue>;
209+
if (!isSignal(resourceValue)) {
210+
throw new Error(`Resource's value signal does not exist`);
211+
}
212+
213+
const { ids, entityMap } = createReadonlyEntityDerivations(resourceValue);
214+
215+
return {
216+
ids,
217+
entityMap,
218+
};
219+
}),
220+
withComputed(({ ids, entityMap }) => ({
221+
entities: createComputedEntities(ids, entityMap),
222+
})),
223+
);
224+
}
225+
185226
/**
186227
* See {@link createUnnamedEntityResource} for why we cannot use the value of `resource` directly.
187228
*/
@@ -190,23 +231,46 @@ function createNamedEntityResources<Dictionary extends EntityDictionary>(
190231
) {
191232
const keys = Object.keys(dictionary);
192233

193-
const stateFactories = keys.map((name) => {
194-
return (store: Record<string, unknown>) => {
195-
const resourceValue = store[
196-
`${name}Value`
197-
] as Signal<EntityResourceValue>;
198-
if (!isSignal(resourceValue)) {
199-
throw new Error(`Resource's value ${name}Value does not exist`);
200-
}
234+
const stateFactories = keys
235+
.filter((name) => isResourceRef(dictionary[name]))
236+
.map((name) => {
237+
return (store: Record<string, unknown>) => {
238+
const resourceValue = store[
239+
`${name}Value`
240+
] as Signal<EntityResourceValue>;
241+
if (!isSignal(resourceValue)) {
242+
throw new Error(`Resource's value ${name}Value does not exist`);
243+
}
201244

202-
const { ids, entityMap } = createEntityDerivations(resourceValue);
245+
const { ids, entityMap } = createEntityDerivations(resourceValue);
203246

204-
return {
205-
[`${name}EntityMap`]: entityMap,
206-
[`${name}Ids`]: ids,
247+
return {
248+
[`${name}EntityMap`]: entityMap,
249+
[`${name}Ids`]: ids,
250+
};
207251
};
208-
};
209-
});
252+
});
253+
254+
const propsFactories = keys
255+
.filter((name) => !isResourceRef(dictionary[name]))
256+
.map((name) => {
257+
return (store: Record<string, unknown>) => {
258+
const resourceValue = store[
259+
`${name}Value`
260+
] as Signal<EntityResourceValue>;
261+
if (!isSignal(resourceValue)) {
262+
throw new Error(`Resource's value ${name}Value does not exist`);
263+
}
264+
265+
const { ids, entityMap } =
266+
createReadonlyEntityDerivations(resourceValue);
267+
268+
return {
269+
[`${name}EntityMap`]: entityMap,
270+
[`${name}Ids`]: ids,
271+
};
272+
};
273+
});
210274

211275
const computedFactories = keys.map((name) => {
212276
return (store: Record<string, unknown>) => {
@@ -234,13 +298,28 @@ function createNamedEntityResources<Dictionary extends EntityDictionary>(
234298
withResource(() => dictionary),
235299
withLinkedState((store) =>
236300
stateFactories.reduce(
237-
(acc, factory) => ({ ...acc, ...factory(store) }),
301+
(acc, factory) => ({
302+
...acc,
303+
...factory(store),
304+
}),
305+
{},
306+
),
307+
),
308+
withProps((store) =>
309+
propsFactories.reduce(
310+
(acc, factory) => ({
311+
...acc,
312+
...factory(store),
313+
}),
238314
{},
239315
),
240316
),
241317
withComputed((store) =>
242318
computedFactories.reduce(
243-
(acc, factory) => ({ ...acc, ...factory(store) }),
319+
(acc, factory) => ({
320+
...acc,
321+
...factory(store),
322+
}),
244323
{},
245324
),
246325
),
@@ -268,12 +347,37 @@ function createNamedEntityResources<Dictionary extends EntityDictionary>(
268347
* - For named resources we return `NamedResourceResult<T>` intersected with
269348
* `NamedEntityState<E, Name>` and `NamedEntityProps<E, Name>` for each entry.
270349
*/
271-
export type EntityResourceResult<Entity> = {
350+
declare const NON_PATCHABLE_ENTITY_RESOURCE_STATE: unique symbol;
351+
352+
type NonPatchableEntityResourceStateMarker = {
353+
[NON_PATCHABLE_ENTITY_RESOURCE_STATE]?: never;
354+
};
355+
356+
type ReadonlyEntityProps<E extends Entity> = {
357+
ids: Signal<EntityId[]>;
358+
entityMap: Signal<Record<EntityId, E>>;
359+
};
360+
361+
type NamedReadonlyEntityProps<E extends Entity, Name extends string> = {
362+
[Prop in `${Name}Ids`]: Signal<EntityId[]>;
363+
} & {
364+
[Prop in `${Name}EntityMap`]: Signal<Record<EntityId, E>>;
365+
};
366+
367+
export type EntityResourceRefResult<Entity> = {
272368
state: ResourceResult<Entity>['state'] & EntityState<Entity>;
273369
props: ResourceResult<Entity>['props'] & EntityProps<Entity>;
274370
methods: ResourceResult<Entity>['methods'];
275371
};
276372

373+
export type EntityResourceResult<Entity extends { id: EntityId }> = {
374+
state: NonPatchableEntityResourceStateMarker;
375+
props: ResourceResult<Entity>['props'] &
376+
EntityProps<Entity> &
377+
ReadonlyEntityProps<Entity>;
378+
methods: ResourceResult<Entity>['methods'];
379+
};
380+
277381
// Generic helpers for inferring entity types and merging unions
278382
type ArrayElement<T> = T extends readonly (infer E)[] | (infer E)[] ? E : never;
279383

@@ -300,15 +404,33 @@ export type EntityDictionary = Record<
300404
type MergeNamedEntityStates<T extends EntityDictionary> = MergeUnion<
301405
{
302406
[Prop in keyof T]: Prop extends string
303-
? InferEntityFromSignal<T[Prop]['value']> extends infer E
304-
? E extends never
305-
? never
306-
: NamedEntityState<E, Prop>
407+
? T[Prop] extends ResourceRef<unknown>
408+
? InferEntityFromSignal<T[Prop]['value']> extends infer E
409+
? E extends never
410+
? never
411+
: NamedEntityState<E, Prop>
412+
: never
307413
: never
308414
: never;
309415
}[keyof T]
310416
>;
311417

418+
type MergeNamedReadonlyEntityProps<T extends EntityDictionary> = MergeUnion<
419+
{
420+
[Prop in keyof T]: Prop extends string
421+
? T[Prop] extends ResourceRef<unknown>
422+
? never
423+
: InferEntityFromSignal<T[Prop]['value']> extends infer E
424+
? E extends Entity
425+
? NamedReadonlyEntityProps<E, Prop>
426+
: E extends never
427+
? never
428+
: never
429+
: never
430+
: never;
431+
}[keyof T]
432+
>;
433+
312434
type MergeNamedEntityProps<T extends EntityDictionary> = MergeUnion<
313435
{
314436
[Prop in keyof T]: Prop extends string
@@ -322,8 +444,12 @@ type MergeNamedEntityProps<T extends EntityDictionary> = MergeUnion<
322444
>;
323445

324446
export type NamedEntityResourceResult<T extends EntityDictionary> = {
325-
state: NamedResourceResult<T, false>['state'] & MergeNamedEntityStates<T>;
326-
props: NamedResourceResult<T, false>['props'] & MergeNamedEntityProps<T>;
447+
state: NamedResourceResult<T, false>['state'] &
448+
MergeNamedEntityStates<T> &
449+
NonPatchableEntityResourceStateMarker;
450+
props: NamedResourceResult<T, false>['props'] &
451+
MergeNamedEntityProps<T> &
452+
MergeNamedReadonlyEntityProps<T>;
327453
methods: NamedResourceResult<T, false>['methods'];
328454
};
329455

@@ -374,6 +500,22 @@ function createEntityDerivations<E extends Entity>(
374500
return { ids, entityMap };
375501
}
376502

503+
function createReadonlyEntityDerivations<E extends Entity>(
504+
source: Signal<TypedEntityResourceValue<E>>,
505+
) {
506+
const ids = computed(() => (source() ?? []).map((e) => e.id));
507+
508+
const entityMap = computed(() => {
509+
const map = {} as Record<EntityId, E>;
510+
for (const item of source() ?? []) {
511+
map[item.id] = item as E;
512+
}
513+
return map;
514+
});
515+
516+
return { ids, entityMap };
517+
}
518+
377519
function createComputedEntities<E extends Entity>(
378520
ids: Signal<EntityId[]>,
379521
entityMap: Signal<Record<EntityId, E>>,

0 commit comments

Comments
 (0)