@angular-architects/ngrx-toolkit 0.0.7 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +43 -0
- package/jest.config.ts +22 -0
- package/ng-package.json +7 -0
- package/package.json +3 -16
- package/project.json +37 -0
- package/{index.d.ts → src/index.ts} +3 -2
- package/src/lib/assertions/assertions.ts +9 -0
- package/src/lib/redux-connector/create-redux.ts +94 -0
- package/{lib/redux-connector/index.d.ts → src/lib/redux-connector/index.ts} +1 -0
- package/src/lib/redux-connector/model.ts +67 -0
- package/{lib/redux-connector/rxjs-interop/index.d.ts → src/lib/redux-connector/rxjs-interop/index.ts} +1 -0
- package/src/lib/redux-connector/rxjs-interop/redux-method.ts +61 -0
- package/src/lib/redux-connector/signal-redux-store.ts +54 -0
- package/src/lib/redux-connector/util.ts +22 -0
- package/src/lib/shared/empty.ts +2 -0
- package/src/lib/with-call-state.spec.ts +24 -0
- package/src/lib/with-call-state.ts +136 -0
- package/src/lib/with-data-service.ts +312 -0
- package/src/lib/with-devtools.spec.ts +157 -0
- package/src/lib/with-devtools.ts +128 -0
- package/src/lib/with-redux.spec.ts +100 -0
- package/src/lib/with-redux.ts +261 -0
- package/src/lib/with-storage-sync.spec.ts +237 -0
- package/src/lib/with-storage-sync.ts +160 -0
- package/src/lib/with-undo-redo.ts +184 -0
- package/src/test-setup.ts +8 -0
- package/tsconfig.json +29 -0
- package/tsconfig.lib.json +17 -0
- package/tsconfig.lib.prod.json +9 -0
- package/tsconfig.spec.json +16 -0
- package/esm2022/angular-architects-ngrx-toolkit.mjs +0 -5
- package/esm2022/index.mjs +0 -9
- package/esm2022/lib/assertions/assertions.mjs +0 -6
- package/esm2022/lib/redux-connector/create-redux.mjs +0 -41
- package/esm2022/lib/redux-connector/index.mjs +0 -2
- package/esm2022/lib/redux-connector/model.mjs +0 -2
- package/esm2022/lib/redux-connector/rxjs-interop/index.mjs +0 -2
- package/esm2022/lib/redux-connector/rxjs-interop/redux-method.mjs +0 -22
- package/esm2022/lib/redux-connector/signal-redux-store.mjs +0 -43
- package/esm2022/lib/redux-connector/util.mjs +0 -13
- package/esm2022/lib/shared/empty.mjs +0 -2
- package/esm2022/lib/with-call-state.mjs +0 -58
- package/esm2022/lib/with-data-service.mjs +0 -161
- package/esm2022/lib/with-devtools.mjs +0 -79
- package/esm2022/lib/with-redux.mjs +0 -95
- package/esm2022/lib/with-storage-sync.mjs +0 -56
- package/esm2022/lib/with-undo-redo.mjs +0 -93
- package/fesm2022/angular-architects-ngrx-toolkit.mjs +0 -653
- package/fesm2022/angular-architects-ngrx-toolkit.mjs.map +0 -1
- package/lib/assertions/assertions.d.ts +0 -2
- package/lib/redux-connector/create-redux.d.ts +0 -13
- package/lib/redux-connector/model.d.ts +0 -36
- package/lib/redux-connector/rxjs-interop/redux-method.d.ts +0 -11
- package/lib/redux-connector/signal-redux-store.d.ts +0 -11
- package/lib/redux-connector/util.d.ts +0 -5
- package/lib/shared/empty.d.ts +0 -1
- package/lib/with-call-state.d.ts +0 -56
- package/lib/with-data-service.d.ts +0 -115
- package/lib/with-devtools.d.ts +0 -32
- package/lib/with-redux.d.ts +0 -57
- package/lib/with-storage-sync.d.ts +0 -58
- package/lib/with-undo-redo.d.ts +0 -55
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { ProviderToken, Signal, computed, inject } from "@angular/core";
|
|
2
|
+
import { SignalStoreFeature, patchState, signalStoreFeature, withComputed, withMethods, withState } from "@ngrx/signals";
|
|
3
|
+
import { CallState, getCallStateKeys, setError, setLoaded, setLoading } from "./with-call-state";
|
|
4
|
+
import { setAllEntities, EntityId, addEntity, updateEntity, removeEntity } from "@ngrx/signals/entities";
|
|
5
|
+
import { EntityState, NamedEntitySignals } from "@ngrx/signals/entities/src/models";
|
|
6
|
+
import { StateSignal } from "@ngrx/signals/src/state-signal";
|
|
7
|
+
import { Emtpy } from "./shared/empty";
|
|
8
|
+
|
|
9
|
+
export type Filter = Record<string, unknown>;
|
|
10
|
+
export type Entity = { id: EntityId };
|
|
11
|
+
|
|
12
|
+
export interface DataService<E extends Entity, F extends Filter> {
|
|
13
|
+
load(filter: F): Promise<E[]>;
|
|
14
|
+
loadById(id: EntityId): Promise<E>;
|
|
15
|
+
create(entity: E): Promise<E>;
|
|
16
|
+
update(entity: E): Promise<E>;
|
|
17
|
+
updateAll(entity: E[]): Promise<E[]>;
|
|
18
|
+
delete(entity: E): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function capitalize(str: string): string {
|
|
22
|
+
return str ? str[0].toUpperCase() + str.substring(1) : str;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getDataServiceKeys(options: { collection?: string }) {
|
|
26
|
+
const filterKey = options.collection ? `${options.collection}Filter` : 'filter';
|
|
27
|
+
const selectedIdsKey = options.collection ? `selected${capitalize(options.collection)}Ids` : 'selectedIds';
|
|
28
|
+
const selectedEntitiesKey = options.collection ? `selected${capitalize(options.collection)}Entities` : 'selectedEntities';
|
|
29
|
+
|
|
30
|
+
const updateFilterKey = options.collection ? `update${capitalize(options.collection)}Filter` : 'updateFilter';
|
|
31
|
+
const updateSelectedKey = options.collection ? `updateSelected${capitalize(options.collection)}Entities` : 'updateSelected';
|
|
32
|
+
const loadKey = options.collection ? `load${capitalize(options.collection)}Entities` : 'load';
|
|
33
|
+
|
|
34
|
+
const currentKey = options.collection ? `current${capitalize(options.collection)}` : 'current';
|
|
35
|
+
const loadByIdKey = options.collection ? `load${capitalize(options.collection)}ById` : 'loadById';
|
|
36
|
+
const setCurrentKey = options.collection ? `setCurrent${capitalize(options.collection)}` : 'setCurrent';
|
|
37
|
+
const createKey = options.collection ? `create${capitalize(options.collection)}` : 'create';
|
|
38
|
+
const updateKey = options.collection ? `update${capitalize(options.collection)}` : 'update';
|
|
39
|
+
const updateAllKey = options.collection ? `updateAll${capitalize(options.collection)}` : 'updateAll';
|
|
40
|
+
const deleteKey = options.collection ? `delete${capitalize(options.collection)}` : 'delete';
|
|
41
|
+
|
|
42
|
+
// TODO: Take these from @ngrx/signals/entities, when they are exported
|
|
43
|
+
const entitiesKey = options.collection ? `${options.collection}Entities` : 'entities';
|
|
44
|
+
const entityMapKey = options.collection ? `${options.collection}EntityMap` : 'entityMap';
|
|
45
|
+
const idsKey = options.collection ? `${options.collection}Ids` : 'ids';
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
filterKey,
|
|
49
|
+
selectedIdsKey,
|
|
50
|
+
selectedEntitiesKey,
|
|
51
|
+
updateFilterKey,
|
|
52
|
+
updateSelectedKey,
|
|
53
|
+
loadKey,
|
|
54
|
+
entitiesKey,
|
|
55
|
+
entityMapKey,
|
|
56
|
+
idsKey,
|
|
57
|
+
|
|
58
|
+
currentKey,
|
|
59
|
+
loadByIdKey,
|
|
60
|
+
setCurrentKey,
|
|
61
|
+
createKey,
|
|
62
|
+
updateKey,
|
|
63
|
+
updateAllKey,
|
|
64
|
+
deleteKey
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type NamedDataServiceState<E extends Entity, F extends Filter, Collection extends string> =
|
|
69
|
+
{
|
|
70
|
+
[K in Collection as `${K}Filter`]: F;
|
|
71
|
+
} & {
|
|
72
|
+
[K in Collection as `selected${Capitalize<K>}Ids`]: Record<EntityId, boolean>;
|
|
73
|
+
} & {
|
|
74
|
+
[K in Collection as `current${Capitalize<K>}`]: E;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type DataServiceState<E extends Entity, F extends Filter> = {
|
|
78
|
+
filter: F;
|
|
79
|
+
selectedIds: Record<EntityId, boolean>;
|
|
80
|
+
current: E;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type NamedDataServiceSignals<E extends Entity, Collection extends string> =
|
|
84
|
+
{
|
|
85
|
+
[K in Collection as `selected${Capitalize<K>}Entities`]: Signal<E[]>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type DataServiceSignals<E extends Entity> =
|
|
89
|
+
{
|
|
90
|
+
selectedEntities: Signal<E[]>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type NamedDataServiceMethods<E extends Entity, F extends Filter, Collection extends string> =
|
|
94
|
+
{
|
|
95
|
+
[K in Collection as `update${Capitalize<K>}Filter`]: (filter: F) => void;
|
|
96
|
+
} &
|
|
97
|
+
{
|
|
98
|
+
[K in Collection as `updateSelected${Capitalize<K>}Entities`]: (id: EntityId, selected: boolean) => void;
|
|
99
|
+
} &
|
|
100
|
+
{
|
|
101
|
+
[K in Collection as `load${Capitalize<K>}Entities`]: () => Promise<void>;
|
|
102
|
+
} &
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
[K in Collection as `setCurrent${Capitalize<K>}`]: (entity: E) => void;
|
|
106
|
+
} &
|
|
107
|
+
{
|
|
108
|
+
[K in Collection as `load${Capitalize<K>}ById`]: (id: EntityId) => Promise<void>;
|
|
109
|
+
} &
|
|
110
|
+
{
|
|
111
|
+
[K in Collection as `create${Capitalize<K>}`]: (entity: E) => Promise<void>;
|
|
112
|
+
} &
|
|
113
|
+
{
|
|
114
|
+
[K in Collection as `update${Capitalize<K>}`]: (entity: E) => Promise<void>;
|
|
115
|
+
} &
|
|
116
|
+
{
|
|
117
|
+
[K in Collection as `updateAll${Capitalize<K>}`]: (entity: E[]) => Promise<void>;
|
|
118
|
+
} &
|
|
119
|
+
{
|
|
120
|
+
[K in Collection as `delete${Capitalize<K>}`]: (entity: E) => Promise<void>;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
export type DataServiceMethods<E extends Entity, F extends Filter> =
|
|
125
|
+
{
|
|
126
|
+
updateFilter: (filter: F) => void;
|
|
127
|
+
updateSelected: (id: EntityId, selected: boolean) => void;
|
|
128
|
+
load: () => Promise<void>;
|
|
129
|
+
|
|
130
|
+
setCurrent(entity: E): void;
|
|
131
|
+
loadById(id: EntityId): Promise<void>;
|
|
132
|
+
create(entity: E): Promise<void>;
|
|
133
|
+
update(entity: E): Promise<void>;
|
|
134
|
+
updateAll(entities: E[]): Promise<void>;
|
|
135
|
+
delete(entity: E): Promise<void>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type Empty = Record<string, never>
|
|
139
|
+
|
|
140
|
+
export function withDataService<E extends Entity, F extends Filter, Collection extends string>(options: { dataServiceType: ProviderToken<DataService<E, F>>, filter: F, collection: Collection }): SignalStoreFeature<
|
|
141
|
+
{
|
|
142
|
+
state: Emtpy,
|
|
143
|
+
// These alternatives break type inference:
|
|
144
|
+
// state: { callState: CallState } & NamedEntityState<E, Collection>,
|
|
145
|
+
// state: NamedEntityState<E, Collection>,
|
|
146
|
+
|
|
147
|
+
signals: NamedEntitySignals<E, Collection>,
|
|
148
|
+
methods: Emtpy,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
state: NamedDataServiceState<E, F, Collection>
|
|
152
|
+
signals: NamedDataServiceSignals<E, Collection>
|
|
153
|
+
methods: NamedDataServiceMethods<E, F, Collection>
|
|
154
|
+
}
|
|
155
|
+
>;
|
|
156
|
+
export function withDataService<E extends Entity, F extends Filter>(options: { dataServiceType: ProviderToken<DataService<E, F>>, filter: F }): SignalStoreFeature<
|
|
157
|
+
{
|
|
158
|
+
state: { callState: CallState } & EntityState<E>
|
|
159
|
+
signals: Emtpy,
|
|
160
|
+
methods: Emtpy,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
state: DataServiceState<E, F>
|
|
164
|
+
signals: DataServiceSignals<E>
|
|
165
|
+
methods: DataServiceMethods<E, F>
|
|
166
|
+
}>;
|
|
167
|
+
|
|
168
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
169
|
+
export function withDataService<E extends Entity, F extends Filter, Collection extends string>(options: { dataServiceType: ProviderToken<DataService<E, F>>, filter: F, collection?: Collection }): SignalStoreFeature<any, any> {
|
|
170
|
+
const { dataServiceType, filter, collection: prefix } = options;
|
|
171
|
+
const {
|
|
172
|
+
entitiesKey,
|
|
173
|
+
filterKey,
|
|
174
|
+
loadKey,
|
|
175
|
+
selectedEntitiesKey,
|
|
176
|
+
selectedIdsKey,
|
|
177
|
+
updateFilterKey,
|
|
178
|
+
updateSelectedKey,
|
|
179
|
+
|
|
180
|
+
currentKey,
|
|
181
|
+
createKey,
|
|
182
|
+
updateKey,
|
|
183
|
+
updateAllKey,
|
|
184
|
+
deleteKey,
|
|
185
|
+
loadByIdKey,
|
|
186
|
+
setCurrentKey
|
|
187
|
+
} = getDataServiceKeys(options);
|
|
188
|
+
|
|
189
|
+
const { callStateKey } = getCallStateKeys({ collection: prefix });
|
|
190
|
+
|
|
191
|
+
return signalStoreFeature(
|
|
192
|
+
withState(() => ({
|
|
193
|
+
[filterKey]: filter,
|
|
194
|
+
[selectedIdsKey]: {} as Record<EntityId, boolean>,
|
|
195
|
+
[currentKey]: undefined as E | undefined
|
|
196
|
+
})),
|
|
197
|
+
withComputed((store: Record<string, unknown>) => {
|
|
198
|
+
const entities = store[entitiesKey] as Signal<E[]>;
|
|
199
|
+
const selectedIds = store[selectedIdsKey] as Signal<Record<EntityId, boolean>>;
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
[selectedEntitiesKey]: computed(() => entities().filter(e => selectedIds()[e.id]))
|
|
203
|
+
}
|
|
204
|
+
}),
|
|
205
|
+
withMethods((store: Record<string, unknown> & StateSignal<object>) => {
|
|
206
|
+
const dataService = inject(dataServiceType)
|
|
207
|
+
return {
|
|
208
|
+
[updateFilterKey]: (filter: F): void => {
|
|
209
|
+
patchState(store, { [filterKey]: filter });
|
|
210
|
+
},
|
|
211
|
+
[updateSelectedKey]: (id: EntityId, selected: boolean): void => {
|
|
212
|
+
patchState(store, (state: Record<string, unknown>) => ({
|
|
213
|
+
[selectedIdsKey]: {
|
|
214
|
+
...state[selectedIdsKey] as Record<EntityId, boolean>,
|
|
215
|
+
[id]: selected,
|
|
216
|
+
}
|
|
217
|
+
}));
|
|
218
|
+
},
|
|
219
|
+
[loadKey]: async (): Promise<void> => {
|
|
220
|
+
const filter = store[filterKey] as Signal<F>;
|
|
221
|
+
store[callStateKey] && patchState(store, setLoading(prefix));
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const result = await dataService.load(filter());
|
|
225
|
+
patchState(store, prefix ? setAllEntities(result, { collection: prefix }) : setAllEntities(result));
|
|
226
|
+
store[callStateKey] && patchState(store, setLoaded(prefix));
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
store[callStateKey] && patchState(store, setError(e, prefix));
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
[loadByIdKey]: async (id: EntityId): Promise<void> => {
|
|
234
|
+
store[callStateKey] && patchState(store, setLoading(prefix));
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const current = await dataService.loadById(id);
|
|
238
|
+
store[callStateKey] && patchState(store, setLoaded(prefix));
|
|
239
|
+
patchState(store, { [currentKey]: current });
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
store[callStateKey] && patchState(store, setError(e, prefix));
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
[setCurrentKey]: (current: E): void => {
|
|
247
|
+
patchState(store, { [currentKey]: current });
|
|
248
|
+
},
|
|
249
|
+
[createKey]: async (entity: E): Promise<void> => {
|
|
250
|
+
patchState(store, { [currentKey]: entity });
|
|
251
|
+
store[callStateKey] && patchState(store, setLoading(prefix));
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const created = await dataService.create(entity);
|
|
255
|
+
patchState(store, { [currentKey]: created });
|
|
256
|
+
patchState(store, prefix ? addEntity(created, { collection: prefix }) : addEntity(created));
|
|
257
|
+
store[callStateKey] && patchState(store, setLoaded(prefix));
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
store[callStateKey] && patchState(store, setError(e, prefix));
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
[updateKey]: async (entity: E): Promise<void> => {
|
|
265
|
+
patchState(store, { [currentKey]: entity });
|
|
266
|
+
store[callStateKey] && patchState(store, setLoading(prefix));
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const updated = await dataService.update(entity);
|
|
270
|
+
patchState(store, { [currentKey]: updated });
|
|
271
|
+
// Why do we need this cast to Partial<Entity>?
|
|
272
|
+
const updateArg = { id: updated.id, changes: updated as Partial<Entity> };
|
|
273
|
+
patchState(store, prefix ? updateEntity(updateArg, { collection: prefix }) : updateEntity(updateArg));
|
|
274
|
+
store[callStateKey] && patchState(store, setLoaded(prefix));
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
store[callStateKey] && patchState(store, setError(e, prefix));
|
|
278
|
+
throw e;
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
[updateAllKey]: async (entities: E[]): Promise<void> => {
|
|
282
|
+
store[callStateKey] && patchState(store, setLoading(prefix));
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const result = await dataService.updateAll(entities);
|
|
286
|
+
patchState(store, prefix ? setAllEntities(result, { collection: prefix }) : setAllEntities(result));
|
|
287
|
+
store[callStateKey] && patchState(store, setLoaded(prefix));
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
store[callStateKey] && patchState(store, setError(e, prefix));
|
|
291
|
+
throw e;
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
[deleteKey]: async (entity: E): Promise<void> => {
|
|
295
|
+
patchState(store, { [currentKey]: entity });
|
|
296
|
+
store[callStateKey] && patchState(store, setLoading(prefix));
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
await dataService.delete(entity);
|
|
300
|
+
patchState(store, { [currentKey]: undefined });
|
|
301
|
+
patchState(store, prefix ? removeEntity(entity.id, { collection: prefix }) : removeEntity(entity.id));
|
|
302
|
+
store[callStateKey] && patchState(store, setLoaded(prefix));
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
store[callStateKey] && patchState(store, setError(e, prefix));
|
|
306
|
+
throw e;
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
})
|
|
311
|
+
);
|
|
312
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { signalStore } from '@ngrx/signals';
|
|
2
|
+
import { withEntities } from '@ngrx/signals/entities';
|
|
3
|
+
import { Action, withDevtools } from 'ngrx-toolkit';
|
|
4
|
+
import { TestBed } from '@angular/core/testing';
|
|
5
|
+
import { PLATFORM_ID } from '@angular/core';
|
|
6
|
+
import SpyInstance = jest.SpyInstance;
|
|
7
|
+
import Mock = jest.Mock;
|
|
8
|
+
import { reset } from './with-devtools';
|
|
9
|
+
|
|
10
|
+
type Flight = {
|
|
11
|
+
id: number;
|
|
12
|
+
from: string;
|
|
13
|
+
to: string;
|
|
14
|
+
date: Date;
|
|
15
|
+
delayed: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let currentFlightId = 1;
|
|
19
|
+
|
|
20
|
+
const createFlight = (flight: Partial<Flight> = {}) => ({
|
|
21
|
+
...{
|
|
22
|
+
id: ++currentFlightId,
|
|
23
|
+
from: 'Vienna',
|
|
24
|
+
to: 'London',
|
|
25
|
+
date: new Date(2024, 2, 1),
|
|
26
|
+
delayed: false,
|
|
27
|
+
},
|
|
28
|
+
...flight,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
interface SetupOptions {
|
|
32
|
+
extensionsAvailable: boolean;
|
|
33
|
+
inSsr: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface TestData {
|
|
37
|
+
store: unknown;
|
|
38
|
+
connectSpy: Mock;
|
|
39
|
+
sendSpy: SpyInstance;
|
|
40
|
+
runEffects: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function run(
|
|
44
|
+
fn: (testData: TestData) => void,
|
|
45
|
+
options: Partial<SetupOptions> = {}
|
|
46
|
+
): any {
|
|
47
|
+
return () => {
|
|
48
|
+
const defaultOptions: SetupOptions = {
|
|
49
|
+
inSsr: false,
|
|
50
|
+
extensionsAvailable: true,
|
|
51
|
+
};
|
|
52
|
+
const realOptions = { ...defaultOptions, ...options };
|
|
53
|
+
|
|
54
|
+
const sendSpy = jest.fn<void, [Action, Record<string, unknown>]>();
|
|
55
|
+
const connection = {
|
|
56
|
+
send: sendSpy,
|
|
57
|
+
};
|
|
58
|
+
const connectSpy = jest.fn(() => connection);
|
|
59
|
+
window.__REDUX_DEVTOOLS_EXTENSION__ = { connect: connectSpy };
|
|
60
|
+
|
|
61
|
+
TestBed.configureTestingModule({
|
|
62
|
+
providers: [
|
|
63
|
+
{
|
|
64
|
+
provide: PLATFORM_ID,
|
|
65
|
+
useValue: realOptions.inSsr ? 'server' : 'browser',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!realOptions.extensionsAvailable) {
|
|
71
|
+
window.__REDUX_DEVTOOLS_EXTENSION__ = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
TestBed.runInInjectionContext(() => {
|
|
75
|
+
const Store = signalStore(
|
|
76
|
+
withEntities<Flight>(),
|
|
77
|
+
withDevtools('flights')
|
|
78
|
+
);
|
|
79
|
+
const store = new Store();
|
|
80
|
+
fn({
|
|
81
|
+
connectSpy,
|
|
82
|
+
sendSpy,
|
|
83
|
+
store,
|
|
84
|
+
runEffects: () => TestBed.flushEffects(),
|
|
85
|
+
});
|
|
86
|
+
reset();
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('Devtools', () => {
|
|
92
|
+
it(
|
|
93
|
+
'should connection',
|
|
94
|
+
run(({ connectSpy }) => {
|
|
95
|
+
expect(connectSpy).toHaveBeenCalledTimes(1);
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
it(
|
|
100
|
+
'should not connect if no Redux Devtools are available',
|
|
101
|
+
run(
|
|
102
|
+
({ connectSpy }) => {
|
|
103
|
+
expect(connectSpy).toHaveBeenCalledTimes(0);
|
|
104
|
+
},
|
|
105
|
+
{ extensionsAvailable: false }
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
it(
|
|
110
|
+
'should not connect if it runs on the server',
|
|
111
|
+
run(
|
|
112
|
+
({ connectSpy }) => {
|
|
113
|
+
expect(connectSpy).toHaveBeenCalledTimes(0);
|
|
114
|
+
},
|
|
115
|
+
{ inSsr: true }
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
it(
|
|
120
|
+
'should dispatch todo state',
|
|
121
|
+
run(({ sendSpy, runEffects }) => {
|
|
122
|
+
runEffects();
|
|
123
|
+
expect(sendSpy).toHaveBeenCalledWith(
|
|
124
|
+
{ type: 'Store Update' },
|
|
125
|
+
{ flights: { entityMap: {}, ids: [] } }
|
|
126
|
+
);
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
it.skip(
|
|
131
|
+
'add multiple store as feature stores',
|
|
132
|
+
run(({ runEffects, sendSpy }) => {
|
|
133
|
+
signalStore(withDevtools('category'));
|
|
134
|
+
signalStore(withDevtools('bookings'));
|
|
135
|
+
runEffects();
|
|
136
|
+
const [, state] = sendSpy.mock.calls[0];
|
|
137
|
+
expect(Object.keys(state)).toContainEqual([
|
|
138
|
+
'category',
|
|
139
|
+
'bookings',
|
|
140
|
+
'flights',
|
|
141
|
+
]);
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
it.todo('should only send when store is initalisaed');
|
|
146
|
+
it.todo('should removed state once destroyed');
|
|
147
|
+
it.todo('should allow to set name afterwards');
|
|
148
|
+
it.todo('should allow to run with names');
|
|
149
|
+
it.todo('should provide a patch method with action names');
|
|
150
|
+
it.todo('should index store names by default');
|
|
151
|
+
it.todo('should fail, if indexing is disabled');
|
|
152
|
+
it.todo('should work with a signalStore added lazily, i.e. after a CD cycle');
|
|
153
|
+
it.todo('should patchState with action name');
|
|
154
|
+
it.todo('should use patchState with default action name');
|
|
155
|
+
it.todo('should group multiple patchStates (glitch-free) in one action');
|
|
156
|
+
it.todo('should not run if in prod mode');
|
|
157
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
patchState as originalPatchState,
|
|
3
|
+
SignalStoreFeature,
|
|
4
|
+
} from '@ngrx/signals';
|
|
5
|
+
import { SignalStoreFeatureResult } from '@ngrx/signals/src/signal-store-models';
|
|
6
|
+
import { effect, inject, PLATFORM_ID, signal, Signal } from '@angular/core';
|
|
7
|
+
import { isPlatformServer } from '@angular/common';
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
__REDUX_DEVTOOLS_EXTENSION__:
|
|
12
|
+
| {
|
|
13
|
+
connect: (options: { name: string }) => {
|
|
14
|
+
send: (action: Action, state: Record<string, unknown>) => void;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
| undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type EmptyFeatureResult = { state: {}; signals: {}; methods: {} };
|
|
22
|
+
export type Action = { type: string };
|
|
23
|
+
|
|
24
|
+
const storeRegistry = signal<Record<string, Signal<unknown>>>({});
|
|
25
|
+
|
|
26
|
+
let currentActionNames = new Set<string>();
|
|
27
|
+
|
|
28
|
+
let synchronizationInitialized = false;
|
|
29
|
+
|
|
30
|
+
function initSynchronization() {
|
|
31
|
+
effect(() => {
|
|
32
|
+
if (!connection) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const stores = storeRegistry();
|
|
37
|
+
const rootState: Record<string, unknown> = {};
|
|
38
|
+
for (const name in stores) {
|
|
39
|
+
const store = stores[name];
|
|
40
|
+
rootState[name] = store();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const names = Array.from(currentActionNames);
|
|
44
|
+
const type = names.length ? names.join(', ') : 'Store Update';
|
|
45
|
+
currentActionNames = new Set<string>();
|
|
46
|
+
|
|
47
|
+
connection.send({ type }, rootState);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getValueFromSymbol(obj: unknown, symbol: symbol) {
|
|
52
|
+
if (typeof obj === 'object' && obj && symbol in obj) {
|
|
53
|
+
return (obj as { [key: symbol]: any })[symbol];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getStoreSignal(store: unknown): Signal<unknown> {
|
|
58
|
+
const [signalStateKey] = Object.getOwnPropertySymbols(store);
|
|
59
|
+
if (!signalStateKey) {
|
|
60
|
+
throw new Error('Cannot find State Signal');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return getValueFromSymbol(store, signalStateKey);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ConnectResponse = {
|
|
67
|
+
send: (action: Action, state: Record<string, unknown>) => void;
|
|
68
|
+
};
|
|
69
|
+
let connection: ConnectResponse | undefined;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* required for testing. is not exported during build
|
|
73
|
+
*/
|
|
74
|
+
export function reset() {
|
|
75
|
+
connection = undefined;
|
|
76
|
+
synchronizationInitialized = false;
|
|
77
|
+
storeRegistry.set({});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param name store's name as it should appear in the DevTools
|
|
82
|
+
*/
|
|
83
|
+
export function withDevtools<Input extends SignalStoreFeatureResult>(
|
|
84
|
+
name: string
|
|
85
|
+
): SignalStoreFeature<Input, EmptyFeatureResult> {
|
|
86
|
+
return (store) => {
|
|
87
|
+
const isServer = isPlatformServer(inject(PLATFORM_ID));
|
|
88
|
+
if (isServer) {
|
|
89
|
+
return store;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const extensions = window.__REDUX_DEVTOOLS_EXTENSION__;
|
|
93
|
+
if (!extensions) {
|
|
94
|
+
return store;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!connection) {
|
|
98
|
+
connection = extensions.connect({
|
|
99
|
+
name: 'NgRx Signal Store',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const storeSignal = getStoreSignal(store);
|
|
104
|
+
storeRegistry.update((value) => ({
|
|
105
|
+
...value,
|
|
106
|
+
[name]: storeSignal,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
if (!synchronizationInitialized) {
|
|
110
|
+
initSynchronization();
|
|
111
|
+
synchronizationInitialized = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return store;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type PatchFn = typeof originalPatchState extends (
|
|
119
|
+
arg1: infer First,
|
|
120
|
+
...args: infer Rest
|
|
121
|
+
) => infer Returner
|
|
122
|
+
? (state: First, action: string, ...rest: Rest) => Returner
|
|
123
|
+
: never;
|
|
124
|
+
|
|
125
|
+
export const patchState: PatchFn = (state, action, ...rest) => {
|
|
126
|
+
currentActionNames.add(action);
|
|
127
|
+
return originalPatchState(state, ...rest);
|
|
128
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { patchState, signalStore, withState } from '@ngrx/signals';
|
|
2
|
+
import { inject } from '@angular/core';
|
|
3
|
+
import {
|
|
4
|
+
HttpClient,
|
|
5
|
+
HttpParams,
|
|
6
|
+
provideHttpClient,
|
|
7
|
+
} from '@angular/common/http';
|
|
8
|
+
import { map, switchMap } from 'rxjs';
|
|
9
|
+
import { noPayload, payload, withRedux } from './with-redux';
|
|
10
|
+
import { TestBed } from '@angular/core/testing';
|
|
11
|
+
import {
|
|
12
|
+
HttpTestingController,
|
|
13
|
+
provideHttpClientTesting,
|
|
14
|
+
} from '@angular/common/http/testing';
|
|
15
|
+
|
|
16
|
+
interface Flight {
|
|
17
|
+
id: number;
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
delayed: boolean;
|
|
21
|
+
date: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let currentId = 1;
|
|
25
|
+
|
|
26
|
+
const createFlight = (flight: Partial<Flight> = {}) => {
|
|
27
|
+
return {
|
|
28
|
+
...{
|
|
29
|
+
id: currentId++,
|
|
30
|
+
from: 'Vienna',
|
|
31
|
+
to: 'London',
|
|
32
|
+
delayed: false,
|
|
33
|
+
date: new Date(2024, 0, 1),
|
|
34
|
+
},
|
|
35
|
+
...flight,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('with redux', () => {
|
|
40
|
+
it('should load flights', () => {
|
|
41
|
+
TestBed.configureTestingModule({
|
|
42
|
+
providers: [provideHttpClient(), provideHttpClientTesting()],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
TestBed.runInInjectionContext(() => {
|
|
46
|
+
const controller = TestBed.inject(HttpTestingController);
|
|
47
|
+
const FlightsStore = signalStore(
|
|
48
|
+
withState({ flights: [] as Flight[] }),
|
|
49
|
+
withRedux({
|
|
50
|
+
actions: {
|
|
51
|
+
public: {
|
|
52
|
+
loadFlights: payload<{ from: string; to: string }>(),
|
|
53
|
+
delayFirst: noPayload,
|
|
54
|
+
},
|
|
55
|
+
private: {
|
|
56
|
+
flightsLoaded: payload<{ flights: Flight[] }>(),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
reducer: (actions, on) => {
|
|
61
|
+
on(actions.flightsLoaded, (state, { flights }) => {
|
|
62
|
+
patchState(state, { flights });
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
effects: (actions, create) => {
|
|
67
|
+
const httpClient = inject(HttpClient);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
loadFlights$: create(actions.loadFlights).pipe(
|
|
71
|
+
switchMap(({ from, to }) => {
|
|
72
|
+
return httpClient.get<Flight[]>(
|
|
73
|
+
'https://www.angulararchitects.io',
|
|
74
|
+
{
|
|
75
|
+
params: new HttpParams().set('from', from).set('to', to),
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
}),
|
|
79
|
+
map((flights) => actions.flightsLoaded({ flights })),
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const flightsStore = new FlightsStore();
|
|
87
|
+
flightsStore.loadFlights({ from: 'Vienna', to: 'London' });
|
|
88
|
+
const flight = createFlight();
|
|
89
|
+
controller
|
|
90
|
+
.expectOne((req) =>
|
|
91
|
+
req.url.startsWith('https://www.angulararchitects.io'),
|
|
92
|
+
)
|
|
93
|
+
.flush([flight]);
|
|
94
|
+
|
|
95
|
+
expect(flightsStore.flights()).toEqual([flight]);
|
|
96
|
+
|
|
97
|
+
controller.verify();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|