@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.
Files changed (62) hide show
  1. package/.eslintrc.json +43 -0
  2. package/jest.config.ts +22 -0
  3. package/ng-package.json +7 -0
  4. package/package.json +3 -16
  5. package/project.json +37 -0
  6. package/{index.d.ts → src/index.ts} +3 -2
  7. package/src/lib/assertions/assertions.ts +9 -0
  8. package/src/lib/redux-connector/create-redux.ts +94 -0
  9. package/{lib/redux-connector/index.d.ts → src/lib/redux-connector/index.ts} +1 -0
  10. package/src/lib/redux-connector/model.ts +67 -0
  11. package/{lib/redux-connector/rxjs-interop/index.d.ts → src/lib/redux-connector/rxjs-interop/index.ts} +1 -0
  12. package/src/lib/redux-connector/rxjs-interop/redux-method.ts +61 -0
  13. package/src/lib/redux-connector/signal-redux-store.ts +54 -0
  14. package/src/lib/redux-connector/util.ts +22 -0
  15. package/src/lib/shared/empty.ts +2 -0
  16. package/src/lib/with-call-state.spec.ts +24 -0
  17. package/src/lib/with-call-state.ts +136 -0
  18. package/src/lib/with-data-service.ts +312 -0
  19. package/src/lib/with-devtools.spec.ts +157 -0
  20. package/src/lib/with-devtools.ts +128 -0
  21. package/src/lib/with-redux.spec.ts +100 -0
  22. package/src/lib/with-redux.ts +261 -0
  23. package/src/lib/with-storage-sync.spec.ts +237 -0
  24. package/src/lib/with-storage-sync.ts +160 -0
  25. package/src/lib/with-undo-redo.ts +184 -0
  26. package/src/test-setup.ts +8 -0
  27. package/tsconfig.json +29 -0
  28. package/tsconfig.lib.json +17 -0
  29. package/tsconfig.lib.prod.json +9 -0
  30. package/tsconfig.spec.json +16 -0
  31. package/esm2022/angular-architects-ngrx-toolkit.mjs +0 -5
  32. package/esm2022/index.mjs +0 -9
  33. package/esm2022/lib/assertions/assertions.mjs +0 -6
  34. package/esm2022/lib/redux-connector/create-redux.mjs +0 -41
  35. package/esm2022/lib/redux-connector/index.mjs +0 -2
  36. package/esm2022/lib/redux-connector/model.mjs +0 -2
  37. package/esm2022/lib/redux-connector/rxjs-interop/index.mjs +0 -2
  38. package/esm2022/lib/redux-connector/rxjs-interop/redux-method.mjs +0 -22
  39. package/esm2022/lib/redux-connector/signal-redux-store.mjs +0 -43
  40. package/esm2022/lib/redux-connector/util.mjs +0 -13
  41. package/esm2022/lib/shared/empty.mjs +0 -2
  42. package/esm2022/lib/with-call-state.mjs +0 -58
  43. package/esm2022/lib/with-data-service.mjs +0 -161
  44. package/esm2022/lib/with-devtools.mjs +0 -79
  45. package/esm2022/lib/with-redux.mjs +0 -95
  46. package/esm2022/lib/with-storage-sync.mjs +0 -56
  47. package/esm2022/lib/with-undo-redo.mjs +0 -93
  48. package/fesm2022/angular-architects-ngrx-toolkit.mjs +0 -653
  49. package/fesm2022/angular-architects-ngrx-toolkit.mjs.map +0 -1
  50. package/lib/assertions/assertions.d.ts +0 -2
  51. package/lib/redux-connector/create-redux.d.ts +0 -13
  52. package/lib/redux-connector/model.d.ts +0 -36
  53. package/lib/redux-connector/rxjs-interop/redux-method.d.ts +0 -11
  54. package/lib/redux-connector/signal-redux-store.d.ts +0 -11
  55. package/lib/redux-connector/util.d.ts +0 -5
  56. package/lib/shared/empty.d.ts +0 -1
  57. package/lib/with-call-state.d.ts +0 -56
  58. package/lib/with-data-service.d.ts +0 -115
  59. package/lib/with-devtools.d.ts +0 -32
  60. package/lib/with-redux.d.ts +0 -57
  61. package/lib/with-storage-sync.d.ts +0 -58
  62. 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
+ });