@angular-architects/ngrx-toolkit 19.2.3 → 19.4.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 (123) hide show
  1. package/eslint.config.cjs +43 -0
  2. package/jest.config.ts +22 -0
  3. package/ng-package.json +7 -0
  4. package/package.json +4 -21
  5. package/project.json +37 -0
  6. package/redux-connector/docs/README.md +131 -0
  7. package/redux-connector/index.ts +6 -0
  8. package/redux-connector/ng-package.json +5 -0
  9. package/redux-connector/src/lib/create-redux.ts +102 -0
  10. package/redux-connector/src/lib/model.ts +89 -0
  11. package/redux-connector/src/lib/rxjs-interop/redux-method.ts +66 -0
  12. package/redux-connector/src/lib/signal-redux-store.ts +59 -0
  13. package/redux-connector/src/lib/util.ts +22 -0
  14. package/{index.d.ts → src/index.ts} +36 -4
  15. package/src/lib/assertions/assertions.ts +9 -0
  16. package/{lib/devtools/features/with-disabled-name-indicies.d.ts → src/lib/devtools/features/with-disabled-name-indicies.ts} +5 -1
  17. package/{lib/devtools/features/with-glitch-tracking.d.ts → src/lib/devtools/features/with-glitch-tracking.ts} +6 -1
  18. package/{lib/devtools/features/with-mapper.d.ts → src/lib/devtools/features/with-mapper.ts} +7 -1
  19. package/src/lib/devtools/internal/current-action-names.ts +1 -0
  20. package/src/lib/devtools/internal/default-tracker.ts +60 -0
  21. package/src/lib/devtools/internal/devtools-feature.ts +37 -0
  22. package/src/lib/devtools/internal/devtools-syncer.service.ts +202 -0
  23. package/src/lib/devtools/internal/glitch-tracker.service.ts +61 -0
  24. package/src/lib/devtools/internal/models.ts +29 -0
  25. package/{lib/devtools/provide-devtools-config.d.ts → src/lib/devtools/provide-devtools-config.ts} +16 -4
  26. package/src/lib/devtools/rename-devtools-name.ts +21 -0
  27. package/src/lib/devtools/tests/action-name.spec.ts +48 -0
  28. package/src/lib/devtools/tests/basic.spec.ts +111 -0
  29. package/src/lib/devtools/tests/connecting.spec.ts +37 -0
  30. package/src/lib/devtools/tests/helpers.spec.ts +43 -0
  31. package/src/lib/devtools/tests/naming.spec.ts +216 -0
  32. package/src/lib/devtools/tests/provide-devtools-config.spec.ts +25 -0
  33. package/src/lib/devtools/tests/types.spec.ts +19 -0
  34. package/src/lib/devtools/tests/update-state.spec.ts +29 -0
  35. package/src/lib/devtools/tests/with-devtools.spec.ts +5 -0
  36. package/src/lib/devtools/tests/with-glitch-tracking.spec.ts +272 -0
  37. package/src/lib/devtools/tests/with-mapper.spec.ts +69 -0
  38. package/src/lib/devtools/update-state.ts +38 -0
  39. package/{lib/devtools/with-dev-tools-stub.d.ts → src/lib/devtools/with-dev-tools-stub.ts} +2 -1
  40. package/src/lib/devtools/with-devtools.ts +81 -0
  41. package/src/lib/flattening-operator.ts +42 -0
  42. package/src/lib/immutable-state/deep-freeze.ts +43 -0
  43. package/src/lib/immutable-state/is-dev-mode.ts +6 -0
  44. package/src/lib/immutable-state/tests/with-immutable-state.spec.ts +260 -0
  45. package/src/lib/immutable-state/with-immutable-state.ts +115 -0
  46. package/src/lib/mutation/http-mutation.spec.ts +473 -0
  47. package/src/lib/mutation/http-mutation.ts +172 -0
  48. package/src/lib/mutation/mutation.ts +26 -0
  49. package/src/lib/mutation/rx-mutation.spec.ts +594 -0
  50. package/src/lib/mutation/rx-mutation.ts +208 -0
  51. package/src/lib/shared/prettify.ts +3 -0
  52. package/{lib/shared/signal-store-models.d.ts → src/lib/shared/signal-store-models.ts} +8 -4
  53. package/src/lib/shared/throw-if-null.ts +7 -0
  54. package/src/lib/storage-sync/features/with-indexed-db.ts +81 -0
  55. package/src/lib/storage-sync/features/with-local-storage.ts +58 -0
  56. package/src/lib/storage-sync/internal/indexeddb.service.ts +124 -0
  57. package/src/lib/storage-sync/internal/local-storage.service.ts +19 -0
  58. package/src/lib/storage-sync/internal/models.ts +62 -0
  59. package/src/lib/storage-sync/internal/session-storage.service.ts +18 -0
  60. package/src/lib/storage-sync/tests/indexeddb.service.spec.ts +99 -0
  61. package/src/lib/storage-sync/tests/with-storage-async.spec.ts +308 -0
  62. package/src/lib/storage-sync/tests/with-storage-sync.spec.ts +268 -0
  63. package/src/lib/storage-sync/with-storage-sync.ts +233 -0
  64. package/src/lib/with-call-state.spec.ts +42 -0
  65. package/src/lib/with-call-state.ts +195 -0
  66. package/src/lib/with-conditional.spec.ts +125 -0
  67. package/{lib/with-conditional.d.ts → src/lib/with-conditional.ts} +31 -7
  68. package/src/lib/with-data-service.spec.ts +564 -0
  69. package/src/lib/with-data-service.ts +433 -0
  70. package/src/lib/with-feature-factory.spec.ts +69 -0
  71. package/{lib/with-feature-factory.d.ts → src/lib/with-feature-factory.ts} +32 -4
  72. package/src/lib/with-mutations.spec.ts +537 -0
  73. package/src/lib/with-mutations.ts +146 -0
  74. package/src/lib/with-pagination.spec.ts +90 -0
  75. package/src/lib/with-pagination.ts +353 -0
  76. package/src/lib/with-redux.spec.ts +258 -0
  77. package/src/lib/with-redux.ts +387 -0
  78. package/src/lib/with-reset.spec.ts +112 -0
  79. package/src/lib/with-reset.ts +62 -0
  80. package/src/lib/with-undo-redo.spec.ts +287 -0
  81. package/src/lib/with-undo-redo.ts +199 -0
  82. package/src/test-setup.ts +8 -0
  83. package/tsconfig.json +29 -0
  84. package/tsconfig.lib.json +17 -0
  85. package/tsconfig.lib.prod.json +9 -0
  86. package/tsconfig.spec.json +17 -0
  87. package/fesm2022/angular-architects-ngrx-toolkit-redux-connector.mjs +0 -119
  88. package/fesm2022/angular-architects-ngrx-toolkit-redux-connector.mjs.map +0 -1
  89. package/fesm2022/angular-architects-ngrx-toolkit.mjs +0 -1787
  90. package/fesm2022/angular-architects-ngrx-toolkit.mjs.map +0 -1
  91. package/lib/assertions/assertions.d.ts +0 -2
  92. package/lib/devtools/internal/current-action-names.d.ts +0 -1
  93. package/lib/devtools/internal/default-tracker.d.ts +0 -13
  94. package/lib/devtools/internal/devtools-feature.d.ts +0 -24
  95. package/lib/devtools/internal/devtools-syncer.service.d.ts +0 -35
  96. package/lib/devtools/internal/glitch-tracker.service.d.ts +0 -18
  97. package/lib/devtools/internal/models.d.ts +0 -24
  98. package/lib/devtools/rename-devtools-name.d.ts +0 -7
  99. package/lib/devtools/update-state.d.ts +0 -15
  100. package/lib/devtools/with-devtools.d.ts +0 -24
  101. package/lib/immutable-state/deep-freeze.d.ts +0 -11
  102. package/lib/immutable-state/is-dev-mode.d.ts +0 -1
  103. package/lib/immutable-state/with-immutable-state.d.ts +0 -60
  104. package/lib/shared/throw-if-null.d.ts +0 -1
  105. package/lib/storage-sync/features/with-indexed-db.d.ts +0 -2
  106. package/lib/storage-sync/features/with-local-storage.d.ts +0 -3
  107. package/lib/storage-sync/internal/indexeddb.service.d.ts +0 -29
  108. package/lib/storage-sync/internal/local-storage.service.d.ts +0 -8
  109. package/lib/storage-sync/internal/models.d.ts +0 -45
  110. package/lib/storage-sync/internal/session-storage.service.d.ts +0 -8
  111. package/lib/storage-sync/with-storage-sync.d.ts +0 -45
  112. package/lib/with-call-state.d.ts +0 -58
  113. package/lib/with-data-service.d.ts +0 -109
  114. package/lib/with-pagination.d.ts +0 -98
  115. package/lib/with-redux.d.ts +0 -147
  116. package/lib/with-reset.d.ts +0 -29
  117. package/lib/with-undo-redo.d.ts +0 -31
  118. package/redux-connector/index.d.ts +0 -2
  119. package/redux-connector/src/lib/create-redux.d.ts +0 -13
  120. package/redux-connector/src/lib/model.d.ts +0 -40
  121. package/redux-connector/src/lib/rxjs-interop/redux-method.d.ts +0 -14
  122. package/redux-connector/src/lib/signal-redux-store.d.ts +0 -11
  123. package/redux-connector/src/lib/util.d.ts +0 -5
@@ -0,0 +1,473 @@
1
+ import { HttpEventType, provideHttpClient } from '@angular/common/http';
2
+ import {
3
+ HttpTestingController,
4
+ provideHttpClientTesting,
5
+ } from '@angular/common/http/testing';
6
+ import { signal } from '@angular/core';
7
+ import { TestBed } from '@angular/core/testing';
8
+ import { HttpMutation, httpMutation } from './http-mutation';
9
+
10
+ interface User {
11
+ id: number;
12
+ name: string;
13
+ email: string;
14
+ }
15
+
16
+ interface CreateUserRequest {
17
+ name: string;
18
+ email: string;
19
+ }
20
+
21
+ interface AddUserEntry {
22
+ firstname: string;
23
+ name: string;
24
+ email: string;
25
+ }
26
+
27
+ describe('httpMutation', () => {
28
+ let httpTestingController: HttpTestingController;
29
+
30
+ beforeEach(() => {
31
+ TestBed.configureTestingModule({
32
+ providers: [provideHttpClient(), provideHttpClientTesting()],
33
+ });
34
+
35
+ httpTestingController = TestBed.inject(HttpTestingController);
36
+ });
37
+
38
+ afterEach(() => {
39
+ httpTestingController.verify();
40
+ });
41
+
42
+ it('should create httpMutation instance', () => {
43
+ const createUser = TestBed.runInInjectionContext(() =>
44
+ httpMutation<CreateUserRequest, User>({
45
+ request: (userData) => ({
46
+ url: '/api/users',
47
+ method: 'POST',
48
+ body: userData,
49
+ }),
50
+ }),
51
+ );
52
+
53
+ expect(createUser).toBeDefined();
54
+ expect(createUser.status()).toBe('idle');
55
+ expect(createUser.isPending()).toBe(false);
56
+ });
57
+
58
+ it('should perform successful POST request using shorthand syntax', () => {
59
+ const createUser = TestBed.runInInjectionContext(() =>
60
+ httpMutation<CreateUserRequest, User>((userData) => ({
61
+ url: '/api/users',
62
+ method: 'POST',
63
+ body: userData,
64
+ })),
65
+ );
66
+
67
+ expect(createUser.status()).toBe('idle');
68
+ expect(createUser.isPending()).toBe(false);
69
+
70
+ const newUser = { name: 'John Doe', email: 'john@example.com' };
71
+ createUser(newUser);
72
+
73
+ expect(createUser.isPending()).toBe(true);
74
+ expect(createUser.status()).toBe('pending');
75
+
76
+ const req = httpTestingController.expectOne('/api/users');
77
+ expect(req.request.method).toBe('POST');
78
+ expect(req.request.body).toEqual(newUser);
79
+
80
+ const mockUser: User = {
81
+ id: 1,
82
+ name: 'John Doe',
83
+ email: 'john@example.com',
84
+ };
85
+ req.flush(mockUser);
86
+
87
+ expect(createUser.status()).toBe('success');
88
+ expect(createUser.isPending()).toBe(false);
89
+ expect(createUser.value()).toEqual(mockUser);
90
+ });
91
+
92
+ it('should perform successful POST request', () => {
93
+ const userSignal = signal<User | null>(null);
94
+
95
+ const createUser = TestBed.runInInjectionContext(() =>
96
+ httpMutation<CreateUserRequest, User>({
97
+ request: (userData) => ({
98
+ url: '/api/users',
99
+ method: 'POST',
100
+ body: userData,
101
+ }),
102
+ onSuccess: (user) => {
103
+ userSignal.set(user);
104
+ },
105
+ }),
106
+ );
107
+
108
+ expect(createUser.status()).toBe('idle');
109
+ expect(createUser.isPending()).toBe(false);
110
+
111
+ const newUser = { name: 'John Doe', email: 'john@example.com' };
112
+ createUser(newUser);
113
+
114
+ expect(createUser.isPending()).toBe(true);
115
+ expect(createUser.status()).toBe('pending');
116
+
117
+ const req = httpTestingController.expectOne('/api/users');
118
+ expect(req.request.method).toBe('POST');
119
+ expect(req.request.body).toEqual(newUser);
120
+
121
+ const mockUser: User = {
122
+ id: 1,
123
+ name: 'John Doe',
124
+ email: 'john@example.com',
125
+ };
126
+ req.flush(mockUser);
127
+
128
+ expect(createUser.status()).toBe('success');
129
+ expect(createUser.isPending()).toBe(false);
130
+ expect(createUser.value()).toEqual(mockUser);
131
+ expect(userSignal()).toEqual(mockUser);
132
+ });
133
+
134
+ it('should handle HTTP errors', () => {
135
+ let errorCaptured: unknown = null;
136
+
137
+ const createUser = TestBed.runInInjectionContext(() =>
138
+ httpMutation<CreateUserRequest, User>({
139
+ request: (userData) => ({
140
+ url: '/api/users',
141
+ method: 'POST',
142
+ body: userData,
143
+ }),
144
+ onError: (error) => {
145
+ errorCaptured = error;
146
+ },
147
+ }),
148
+ );
149
+
150
+ const invalidUser = { name: '', email: 'invalid-email' };
151
+ createUser(invalidUser);
152
+
153
+ const req = httpTestingController.expectOne('/api/users');
154
+ expect(req.request.body).toEqual(invalidUser);
155
+ req.flush(
156
+ { message: 'Validation failed' },
157
+ { status: 400, statusText: 'Bad Request' },
158
+ );
159
+
160
+ expect(createUser.status()).toBe('error');
161
+ expect(createUser.error()).toBeDefined();
162
+ expect(errorCaptured).toBeDefined();
163
+ });
164
+
165
+ it('should perform successful POST request with upload and download', () => {
166
+ const createdUserSignal = signal<User | null>(null);
167
+
168
+ const createUser = TestBed.runInInjectionContext(() =>
169
+ httpMutation<CreateUserRequest, User>({
170
+ request: (userData) => ({
171
+ url: '/api/users',
172
+ method: 'POST',
173
+ body: userData,
174
+ headers: { 'Content-Type': 'application/json' },
175
+ reportProgress: true,
176
+ }),
177
+ onSuccess: (user) => {
178
+ createdUserSignal.set(user);
179
+ },
180
+ }),
181
+ );
182
+
183
+ expect(createUser.status()).toBe('idle');
184
+ expect(createUser.uploadProgress()).toBeUndefined();
185
+ expect(createUser.downloadProgress()).toBeUndefined();
186
+
187
+ const newUser = { name: 'Jane Doe', email: 'jane@example.com' };
188
+ createUser(newUser);
189
+
190
+ expect(createUser.isPending()).toBe(true);
191
+ expect(createUser.status()).toBe('pending');
192
+
193
+ const req = httpTestingController.expectOne('/api/users');
194
+ expect(req.request.method).toBe('POST');
195
+ expect(req.request.body).toEqual(newUser);
196
+ expect(req.request.headers.get('Content-Type')).toBe('application/json');
197
+
198
+ req.event({
199
+ type: HttpEventType.UploadProgress,
200
+ loaded: 50,
201
+ total: 100,
202
+ });
203
+
204
+ expect(createUser.uploadProgress()).toEqual({
205
+ type: HttpEventType.UploadProgress,
206
+ loaded: 50,
207
+ total: 100,
208
+ });
209
+
210
+ req.event({
211
+ type: HttpEventType.DownloadProgress,
212
+ loaded: 80,
213
+ total: 100,
214
+ });
215
+
216
+ expect(createUser.downloadProgress()).toEqual({
217
+ type: HttpEventType.DownloadProgress,
218
+ loaded: 80,
219
+ total: 100,
220
+ });
221
+
222
+ const mockCreatedUser: User = { id: 2, ...newUser };
223
+ req.flush(mockCreatedUser);
224
+
225
+ expect(createUser.status()).toBe('success');
226
+ expect(createUser.isPending()).toBe(false);
227
+ expect(createUser.value()).toEqual(mockCreatedUser);
228
+ expect(createdUserSignal()).toEqual(mockCreatedUser);
229
+ });
230
+
231
+ it('should perform successful DELETE request', () => {
232
+ let deletedUserId: number | null = null;
233
+
234
+ const deleteUser = TestBed.runInInjectionContext(() =>
235
+ httpMutation<number, { success: boolean; message: string }>({
236
+ request: (userId) => ({
237
+ url: `/api/users/${userId}`,
238
+ method: 'DELETE',
239
+ headers: { Authorization: 'Bearer token123' },
240
+ }),
241
+ onSuccess: (response) => {
242
+ deletedUserId = response.success ? 1 : null;
243
+ },
244
+ }),
245
+ );
246
+
247
+ expect(deleteUser.status()).toBe('idle');
248
+ expect(deleteUser.isPending()).toBe(false);
249
+
250
+ deleteUser(1);
251
+
252
+ expect(deleteUser.isPending()).toBe(true);
253
+ expect(deleteUser.status()).toBe('pending');
254
+
255
+ const req = httpTestingController.expectOne('/api/users/1');
256
+ expect(req.request.method).toBe('DELETE');
257
+ expect(req.request.headers.get('Authorization')).toBe('Bearer token123');
258
+ expect(req.request.body).toBeNull();
259
+
260
+ const mockResponse = {
261
+ success: true,
262
+ message: 'User deleted successfully',
263
+ };
264
+ req.flush(mockResponse);
265
+
266
+ expect(deleteUser.status()).toBe('success');
267
+ expect(deleteUser.isPending()).toBe(false);
268
+ expect(deleteUser.value()).toEqual(mockResponse);
269
+ expect(deletedUserId).toBe(1);
270
+ });
271
+
272
+ it('should handle DELETE request with error', () => {
273
+ let errorCaptured: unknown = null;
274
+
275
+ const deleteUser = TestBed.runInInjectionContext(() =>
276
+ httpMutation<number, { success: boolean }>({
277
+ request: (userId) => ({
278
+ url: `/api/users/${userId}`,
279
+ method: 'DELETE',
280
+ }),
281
+ onError: (error) => {
282
+ errorCaptured = error;
283
+ },
284
+ }),
285
+ );
286
+
287
+ deleteUser(999);
288
+
289
+ const req = httpTestingController.expectOne('/api/users/999');
290
+ expect(req.request.method).toBe('DELETE');
291
+
292
+ req.flush(
293
+ { error: 'Forbidden', message: 'You cannot delete this user' },
294
+ { status: 403, statusText: 'Forbidden' },
295
+ );
296
+
297
+ expect(deleteUser.status()).toBe('error');
298
+ expect(deleteUser.error()).toBeDefined();
299
+ expect(errorCaptured).toBeDefined();
300
+ });
301
+
302
+ it('should track large data upload with progress', () => {
303
+ interface LargeDataUpload {
304
+ title: string;
305
+ content: string;
306
+ metadata: {
307
+ author: string;
308
+ tags: string[];
309
+ size: number;
310
+ };
311
+ }
312
+
313
+ const uploadData = TestBed.runInInjectionContext(() =>
314
+ httpMutation<
315
+ LargeDataUpload,
316
+ { id: string; title: string; uploadedSize: number }
317
+ >({
318
+ request: (data) => ({
319
+ url: '/api/documents',
320
+ method: 'POST',
321
+ body: data,
322
+ headers: { 'Content-Type': 'application/json' },
323
+ reportProgress: true,
324
+ }),
325
+ }),
326
+ );
327
+
328
+ const mockData: LargeDataUpload = {
329
+ title: 'Large Document',
330
+ content:
331
+ 'This is a very large document content with lots of text data...',
332
+ metadata: {
333
+ author: 'John Doe',
334
+ tags: ['important', 'document', 'large'],
335
+ size: 1024000,
336
+ },
337
+ };
338
+
339
+ expect(uploadData.uploadProgress()).toBeUndefined();
340
+ expect(uploadData.downloadProgress()).toBeUndefined();
341
+
342
+ uploadData(mockData);
343
+
344
+ const req = httpTestingController.expectOne('/api/documents');
345
+ expect(req.request.method).toBe('POST');
346
+ expect(req.request.body).toEqual(mockData);
347
+ expect(req.request.headers.get('Content-Type')).toBe('application/json');
348
+
349
+ req.event({
350
+ type: HttpEventType.UploadProgress,
351
+ loaded: 256000,
352
+ total: 1024000,
353
+ });
354
+
355
+ expect(uploadData.uploadProgress()).toEqual({
356
+ type: HttpEventType.UploadProgress,
357
+ loaded: 256000,
358
+ total: 1024000,
359
+ });
360
+
361
+ req.event({
362
+ type: HttpEventType.UploadProgress,
363
+ loaded: 768000,
364
+ total: 1024000,
365
+ });
366
+
367
+ expect(uploadData.uploadProgress()).toEqual({
368
+ type: HttpEventType.UploadProgress,
369
+ loaded: 768000,
370
+ total: 1024000,
371
+ });
372
+
373
+ req.event({
374
+ type: HttpEventType.UploadProgress,
375
+ loaded: 1024000,
376
+ total: 1024000,
377
+ });
378
+
379
+ const mockResponse = {
380
+ id: 'doc-456',
381
+ title: 'Large Document',
382
+ uploadedSize: 1024000,
383
+ };
384
+
385
+ req.flush(mockResponse);
386
+
387
+ expect(uploadData.status()).toBe('success');
388
+ expect(uploadData.value()).toEqual(mockResponse);
389
+ });
390
+
391
+ it('can be explicitly typed', () => {
392
+ TestBed.runInInjectionContext(() => {
393
+ httpMutation<AddUserEntry, boolean>((userData: AddUserEntry) => ({
394
+ url: 'api/users',
395
+ method: 'POST',
396
+ body: userData,
397
+ })) satisfies HttpMutation<AddUserEntry, boolean>;
398
+ });
399
+ });
400
+
401
+ it('can be implicitly typed via request and parse', () => {
402
+ TestBed.runInInjectionContext(() => {
403
+ httpMutation({
404
+ request: (userData: AddUserEntry) => ({
405
+ url: 'api/users',
406
+ method: 'POST',
407
+ body: userData,
408
+ }),
409
+ parse: Boolean,
410
+ }) satisfies HttpMutation<AddUserEntry, boolean>;
411
+ });
412
+ });
413
+
414
+ it('can be implicitly typed via a request without a body, and parse', () => {
415
+ TestBed.runInInjectionContext(() => {
416
+ httpMutation({
417
+ request: (id: number) => ({
418
+ url: `api/users/${id}`,
419
+ method: 'DELETE',
420
+ }),
421
+ parse: Boolean,
422
+ }) satisfies HttpMutation<number, boolean>;
423
+ });
424
+ });
425
+
426
+ it('can not be implicitly typed with both onSuccess and parse having different types', () => {
427
+ TestBed.runInInjectionContext(() => {
428
+ httpMutation({
429
+ request: (userData: AddUserEntry) => ({
430
+ url: 'api/users',
431
+ method: 'POST',
432
+ body: userData,
433
+ }),
434
+ parse: Boolean,
435
+ // @ts-expect-error onSuccess need to use the type of parse
436
+ onSuccess: (result: { userId: number }) => {
437
+ console.log('User created:', result);
438
+ },
439
+ });
440
+ });
441
+ });
442
+
443
+ it('can be implicitly typed with both onSuccess and parse having same type', () => {
444
+ TestBed.runInInjectionContext(() => {
445
+ httpMutation({
446
+ request: (userData: AddUserEntry) => ({
447
+ url: 'api/users',
448
+ method: 'POST',
449
+ body: userData,
450
+ }),
451
+ parse: (result) => result as { id: number },
452
+ onSuccess: (result) => {
453
+ console.log('User created:', result);
454
+ },
455
+ }) satisfies HttpMutation<AddUserEntry, { id: number }>;
456
+ });
457
+ });
458
+
459
+ it('can be implicitly typed by defining onSuccess only', () => {
460
+ TestBed.runInInjectionContext(() => {
461
+ httpMutation({
462
+ request: (userData: AddUserEntry) => ({
463
+ url: 'api/users',
464
+ method: 'POST',
465
+ body: userData,
466
+ }),
467
+ onSuccess: (result: { id: number }) => {
468
+ console.log('User created:', result);
469
+ },
470
+ }) satisfies HttpMutation<AddUserEntry, { id: number }>;
471
+ });
472
+ });
473
+ });
@@ -0,0 +1,172 @@
1
+ import {
2
+ HttpClient,
3
+ HttpContext,
4
+ HttpEventType,
5
+ HttpHeaders,
6
+ HttpParams,
7
+ HttpProgressEvent,
8
+ HttpResponse,
9
+ } from '@angular/common/http';
10
+ import { inject, Signal, signal } from '@angular/core';
11
+ import { defer, filter, map, tap } from 'rxjs';
12
+ import { Mutation } from './mutation';
13
+ import { rxMutation, RxMutationOptions } from './rx-mutation';
14
+
15
+ // The HttpClient defines these types as part of the method signature and
16
+ // not as a named type
17
+ export type HttpMutationRequest = {
18
+ url: string;
19
+ method: string;
20
+ body?: unknown;
21
+ headers?: HttpHeaders | Record<string, string | string[]>;
22
+ context?: HttpContext;
23
+ reportProgress?: boolean;
24
+ params?:
25
+ | HttpParams
26
+ | Record<
27
+ string,
28
+ string | number | boolean | ReadonlyArray<string | number | boolean>
29
+ >;
30
+ withCredentials?: boolean;
31
+ credentials?: RequestCredentials;
32
+ keepalive?: boolean;
33
+ priority?: RequestPriority;
34
+ cache?: RequestCache;
35
+ mode?: RequestMode;
36
+ redirect?: RequestRedirect;
37
+ transferCache?:
38
+ | {
39
+ includeHeaders?: string[];
40
+ }
41
+ | boolean;
42
+ };
43
+
44
+ export type HttpMutationOptions<Parameter, Result> = Omit<
45
+ RxMutationOptions<Parameter, NoInfer<Result>>,
46
+ 'operation'
47
+ > & {
48
+ request: (param: Parameter) => HttpMutationRequest;
49
+ parse?: (response: unknown) => Result;
50
+ };
51
+
52
+ export type HttpMutation<Parameter, Result> = Mutation<Parameter, Result> & {
53
+ uploadProgress: Signal<HttpProgressEvent | undefined>;
54
+ downloadProgress: Signal<HttpProgressEvent | undefined>;
55
+ headers: Signal<HttpHeaders | undefined>;
56
+ statusCode: Signal<string | undefined>;
57
+ };
58
+
59
+ /**
60
+ * Creates an HTTP mutation.
61
+ *
62
+ * export type Params = {
63
+ * value: number;
64
+ * };
65
+ *
66
+ * export type CounterResponse = {
67
+ * // httpbin.org echos the request using the
68
+ * // json property
69
+ * json: { counter: number };
70
+ * };
71
+ *
72
+ * const simpleSaveUser = httpMutation({
73
+ * request: (userData: AddUserEntry) => ({
74
+ * url: 'api/users',
75
+ * body: userData,
76
+ * }),
77
+ * parse: Boolean,
78
+ * })
79
+ *
80
+ * const saveUser = httpMutation({
81
+ * request: (p: Params) => ({
82
+ * url: `https://httpbin.org/post`,
83
+ * method: 'POST',
84
+ * body: { counter: p.value },
85
+ * headers: { 'Content-Type': 'application/json' },
86
+ * }),
87
+ * onSuccess: (response: CounterResponse) => {
88
+ * console.log('Counter sent to server:', response);
89
+ * },
90
+ * onError: (error) => {
91
+ * console.error('Failed to send counter:', error);
92
+ * },
93
+ * });
94
+ *
95
+ * ...
96
+ *
97
+ * const result = await this.saveUser({ value: 17 });
98
+ * if (result.status === 'success') {
99
+ * console.log('Successfully saved to server:', result.value);
100
+ * }
101
+ * else if (result.status === 'error') {
102
+ * console.log('Failed to save:', result.error);
103
+ * }
104
+ * else {
105
+ * console.log('Operation aborted');
106
+ * }
107
+ *
108
+ * @param options The options for the HTTP mutation.
109
+ * @returns The HTTP mutation.
110
+ */
111
+ export function httpMutation<Parameter, Result>(
112
+ optionsOrRequest:
113
+ | HttpMutationOptions<Parameter, Result>
114
+ | ((param: Parameter) => HttpMutationRequest),
115
+ ): HttpMutation<Parameter, Result> {
116
+ const httpClient = inject(HttpClient);
117
+
118
+ const options =
119
+ typeof optionsOrRequest === 'function'
120
+ ? { request: optionsOrRequest }
121
+ : optionsOrRequest;
122
+
123
+ const parse = options.parse ?? ((raw: unknown) => raw as Result);
124
+
125
+ const uploadProgress = signal<HttpProgressEvent | undefined>(undefined);
126
+ const downloadProgress = signal<HttpProgressEvent | undefined>(undefined);
127
+ const headers = signal<HttpHeaders | undefined>(undefined);
128
+ const statusCode = signal<string | undefined>(undefined);
129
+
130
+ const mutation = rxMutation({
131
+ ...options,
132
+ operation: (param: Parameter) => {
133
+ const httpRequest = options.request(param);
134
+
135
+ return defer(() => {
136
+ uploadProgress.set(undefined);
137
+ downloadProgress.set(undefined);
138
+ headers.set(undefined);
139
+ statusCode.set(undefined);
140
+
141
+ return httpClient
142
+ .request<Result>(httpRequest.method, httpRequest.url, {
143
+ ...httpRequest,
144
+ observe: 'events',
145
+ responseType: 'json',
146
+ })
147
+ .pipe(
148
+ tap((response) => {
149
+ if (response.type === HttpEventType.UploadProgress) {
150
+ uploadProgress.set(response);
151
+ } else if (response.type === HttpEventType.DownloadProgress) {
152
+ downloadProgress.set(response);
153
+ }
154
+ }),
155
+ filter((event) => event instanceof HttpResponse),
156
+ tap((response) => {
157
+ headers.set(response.headers);
158
+ statusCode.set(response.status.toString());
159
+ }),
160
+ map((event) => parse(event.body)),
161
+ );
162
+ });
163
+ },
164
+ }) as HttpMutation<Parameter, Result>;
165
+
166
+ mutation.uploadProgress = uploadProgress;
167
+ mutation.downloadProgress = downloadProgress;
168
+ mutation.statusCode = statusCode;
169
+ mutation.headers = headers;
170
+
171
+ return mutation;
172
+ }
@@ -0,0 +1,26 @@
1
+ import { Signal } from '@angular/core';
2
+
3
+ export type MutationResult<Result> =
4
+ | {
5
+ status: 'success';
6
+ value: Result;
7
+ }
8
+ | {
9
+ status: 'error';
10
+ error: unknown;
11
+ }
12
+ | {
13
+ status: 'aborted';
14
+ };
15
+
16
+ export type MutationStatus = 'idle' | 'pending' | 'error' | 'success';
17
+
18
+ export type Mutation<Parameter, Result> = {
19
+ (params: Parameter): Promise<MutationResult<Result>>;
20
+ status: Signal<MutationStatus>;
21
+ value: Signal<Result | undefined>;
22
+ isPending: Signal<boolean>;
23
+ isSuccess: Signal<boolean>;
24
+ error: Signal<unknown>;
25
+ hasValue(): this is Mutation<Exclude<Parameter, undefined>, Result>;
26
+ };