Skip to content

Commit 8c4ca53

Browse files
ScriptTypemelloware
authored andcommitted
chore(angular-query): add mutationInvalidates verification sample
1 parent daeddef commit 8c4ca53

File tree

4 files changed

+250
-5
lines changed

4 files changed

+250
-5
lines changed

samples/angular-query/orval.config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ export default defineConfig({
1515
override: {
1616
query: {
1717
useInvalidate: true,
18+
/**
19+
* mutationInvalidates: Auto-invalidate queries when mutations succeed
20+
*
21+
* - Simple array: `createPets: ['listPets']`
22+
* - Multiple targets: `updatePetById: ['listPets', 'showPetById']`
23+
*
24+
* After each mutation succeeds, the specified queries are automatically
25+
* invalidated via queryClient.invalidateQueries(), triggering a refetch.
26+
*/
27+
mutationInvalidates: {
28+
// After creating a pet, invalidate the pets list so it refetches
29+
createPets: ['listPets'],
30+
// After uploading a file, invalidate multiple queries
31+
uploadFile: ['listPets', 'showPetById'],
32+
},
1833
},
1934
operations: {
2035
listPets: {

samples/angular-query/src/api/endpoints/pets/pets.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
99
import { inject } from '@angular/core';
1010

1111
import {
12+
QueryClient,
1213
injectMutation,
1314
injectQuery,
1415
} from '@tanstack/angular-query-experimental';
@@ -19,7 +20,7 @@ import type {
1920
CreateQueryResult,
2021
InvalidateOptions,
2122
MutationFunction,
22-
QueryClient,
23+
MutationFunctionContext,
2324
QueryFunction,
2425
} from '@tanstack/angular-query-experimental';
2526

@@ -303,6 +304,7 @@ export const getCreatePetsMutationOptions = <
303304
: { ...options, mutation: { ...options.mutation, mutationKey } }
304305
: { mutation: { mutationKey }, fetch: undefined };
305306
const http = inject(HttpClient);
307+
const queryClient = inject(QueryClient);
306308

307309
const mutationFn: MutationFunction<
308310
Awaited<ReturnType<typeof createPets>>,
@@ -313,7 +315,17 @@ export const getCreatePetsMutationOptions = <
313315
return createPets(http, data, version, fetchOptions);
314316
};
315317

316-
return { mutationFn, ...mutationOptions };
318+
const onSuccess = (
319+
data: Awaited<ReturnType<typeof createPets>>,
320+
variables: { data: CreatePetsBody; version?: number },
321+
onMutateResult: TContext,
322+
context: MutationFunctionContext,
323+
) => {
324+
queryClient.invalidateQueries({ queryKey: getListPetsQueryKey() });
325+
mutationOptions?.onSuccess?.(data, variables, onMutateResult, context);
326+
};
327+
328+
return { mutationFn, onSuccess, ...mutationOptions };
317329
};
318330

319331
export type CreatePetsMutationResult = NonNullable<
@@ -604,6 +616,7 @@ export const getUploadFileMutationOptions = <
604616
: { ...options, mutation: { ...options.mutation, mutationKey } }
605617
: { mutation: { mutationKey }, fetch: undefined };
606618
const http = inject(HttpClient);
619+
const queryClient = inject(QueryClient);
607620

608621
const mutationFn: MutationFunction<
609622
Awaited<ReturnType<typeof uploadFile>>,
@@ -614,7 +627,18 @@ export const getUploadFileMutationOptions = <
614627
return uploadFile(http, petId, data, version, fetchOptions);
615628
};
616629

617-
return { mutationFn, ...mutationOptions };
630+
const onSuccess = (
631+
data: Awaited<ReturnType<typeof uploadFile>>,
632+
variables: { petId: number; data: Blob; version?: number },
633+
onMutateResult: TContext,
634+
context: MutationFunctionContext,
635+
) => {
636+
queryClient.invalidateQueries({ queryKey: getListPetsQueryKey() });
637+
queryClient.invalidateQueries({ queryKey: getShowPetByIdQueryKey() });
638+
mutationOptions?.onSuccess?.(data, variables, onMutateResult, context);
639+
};
640+
641+
return { mutationFn, onSuccess, ...mutationOptions };
618642
};
619643

620644
export type UploadFileMutationResult = NonNullable<
Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,81 @@
11
import { Component, signal } from '@angular/core';
2-
import { injectListPets } from '../api/endpoints/pets/pets';
2+
import { FormsModule } from '@angular/forms';
3+
import { injectListPets, injectCreatePets } from '../api/endpoints/pets/pets';
34

45
@Component({
56
selector: 'app-root',
7+
standalone: true,
8+
imports: [FormsModule],
69
template: `
710
<div class="App">
811
<h1>Hello, {{ title() }}</h1>
12+
913
<header class="App-header">
1014
<img src="logo.svg" class="App-logo" alt="logo" />
15+
16+
<!-- Demo: Add Pet Form -->
17+
<div class="add-pet-form">
18+
<h2>Add a New Pet</h2>
19+
<p class="notice">
20+
<strong>Note:</strong> MSW mocks the API, so new pets won't persist.
21+
Watch the pet list reload after clicking "Add" - that's
22+
mutationInvalidates working!
23+
</p>
24+
<input
25+
[(ngModel)]="newPetName"
26+
placeholder="Pet name"
27+
[disabled]="createPetMutation.isPending()"
28+
/>
29+
<button
30+
(click)="addPet()"
31+
[disabled]="!newPetName() || createPetMutation.isPending()"
32+
>
33+
{{ createPetMutation.isPending() ? 'Adding...' : 'Add Pet' }}
34+
</button>
35+
@if (createPetMutation.isError()) {
36+
<p class="error">Error adding pet</p>
37+
}
38+
</div>
39+
40+
<!-- Pet List (auto-refreshes after mutation via mutationInvalidates) -->
41+
<h2>Pets List</h2>
42+
@if (pets.isPending()) {
43+
<p>Loading pets...</p>
44+
}
1145
@for (pet of pets.data(); track pet.id) {
1246
<p>{{ pet.name }}</p>
1347
}
48+
@if (pets.data()?.length === 0) {
49+
<p>No pets yet. Add one above!</p>
50+
}
1451
</header>
1552
</div>
1653
`,
1754
})
1855
export class App {
19-
protected readonly pets = injectListPets();
56+
// Query: Fetches pets list
57+
protected readonly pets = injectListPets({ limit: '10' });
58+
59+
// Mutation: Creates a new pet
60+
// The mutationInvalidates config causes listPets to auto-refresh on success
61+
protected readonly createPetMutation = injectCreatePets();
2062

2163
protected readonly title = signal('angular-app');
64+
protected readonly newPetName = signal('');
65+
66+
addPet() {
67+
const name = this.newPetName();
68+
if (!name) return;
69+
70+
this.createPetMutation.mutate(
71+
{ data: { name, tag: 'new' } },
72+
{
73+
onSuccess: () => {
74+
// Clear the input after successful creation
75+
this.newPetName.set('');
76+
// No need to manually invalidate - mutationInvalidates does it!
77+
},
78+
},
79+
);
80+
}
2281
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Tests for mutationInvalidates feature
3+
*
4+
* This test verifies that mutations with `mutationInvalidates` configuration
5+
* correctly invalidate the specified queries when the mutation succeeds.
6+
*
7+
* Following TanStack Angular Query testing patterns:
8+
* https://tanstack.com/query/latest/docs/framework/angular/guides/testing
9+
*/
10+
import { provideZonelessChangeDetection } from '@angular/core';
11+
import { provideHttpClient } from '@angular/common/http';
12+
import {
13+
HttpTestingController,
14+
provideHttpClientTesting,
15+
} from '@angular/common/http/testing';
16+
import { TestBed } from '@angular/core/testing';
17+
import {
18+
provideTanStackQuery,
19+
QueryClient,
20+
} from '@tanstack/angular-query-experimental';
21+
22+
import {
23+
getCreatePetsMutationOptions,
24+
getListPetsQueryKey,
25+
} from '../api/endpoints/pets/pets';
26+
import type { MutationFunctionContext } from '@tanstack/angular-query-experimental';
27+
28+
describe('mutationInvalidates feature', () => {
29+
let queryClient: QueryClient;
30+
let httpCtrl: HttpTestingController;
31+
32+
beforeEach(() => {
33+
// Create a fresh QueryClient for each test with retry disabled for faster tests
34+
queryClient = new QueryClient({
35+
defaultOptions: {
36+
queries: { retry: false },
37+
mutations: { retry: false },
38+
},
39+
});
40+
41+
TestBed.configureTestingModule({
42+
providers: [
43+
provideZonelessChangeDetection(),
44+
provideHttpClient(),
45+
provideHttpClientTesting(),
46+
provideTanStackQuery(queryClient),
47+
],
48+
});
49+
50+
httpCtrl = TestBed.inject(HttpTestingController);
51+
});
52+
53+
afterEach(() => {
54+
queryClient.clear();
55+
httpCtrl.verify();
56+
});
57+
58+
describe('getCreatePetsMutationOptions', () => {
59+
it('should include onSuccess callback that invalidates listPets query', () => {
60+
const options = TestBed.runInInjectionContext(() =>
61+
getCreatePetsMutationOptions(),
62+
);
63+
64+
// Verify the mutation options include an onSuccess callback
65+
expect(options.onSuccess).toBeDefined();
66+
expect(typeof options.onSuccess).toBe('function');
67+
});
68+
69+
it('should have mutationFn defined', () => {
70+
const options = TestBed.runInInjectionContext(() =>
71+
getCreatePetsMutationOptions(),
72+
);
73+
74+
expect(options.mutationFn).toBeDefined();
75+
expect(typeof options.mutationFn).toBe('function');
76+
});
77+
});
78+
79+
describe('query invalidation behavior', () => {
80+
it('should invalidate listPets query when onSuccess is called', () => {
81+
// Set up an initial query cache entry
82+
const queryKey = getListPetsQueryKey();
83+
queryClient.setQueryData(queryKey, [{ id: 1, name: 'Existing Pet' }]);
84+
85+
// Get mutation options
86+
const options = TestBed.runInInjectionContext(() =>
87+
getCreatePetsMutationOptions(),
88+
);
89+
90+
// Create a mock MutationFunctionContext
91+
const mockContext: MutationFunctionContext = {
92+
client: queryClient,
93+
meta: undefined,
94+
};
95+
96+
// Call onSuccess synchronously (no race condition - this is testing the callback directly)
97+
options.onSuccess!(
98+
undefined, // data (createPets returns void)
99+
{ data: { name: 'New Pet', tag: 'dog' } }, // variables
100+
undefined as never, // onMutateResult
101+
mockContext, // context
102+
);
103+
104+
// After invalidation, the query should be marked as stale
105+
const queryState = queryClient.getQueryState(queryKey);
106+
expect(queryState?.isInvalidated).toBe(true);
107+
});
108+
109+
it('should call user-provided onSuccess callback with correct arguments', () => {
110+
let userCallbackCalled = false;
111+
let receivedData: unknown;
112+
let receivedVariables: unknown;
113+
114+
const options = TestBed.runInInjectionContext(() =>
115+
getCreatePetsMutationOptions({
116+
mutation: {
117+
onSuccess: (data, variables, _onMutateResult, _context) => {
118+
userCallbackCalled = true;
119+
receivedData = data;
120+
receivedVariables = variables;
121+
},
122+
},
123+
}),
124+
);
125+
126+
const mockContext: MutationFunctionContext = {
127+
client: queryClient,
128+
meta: undefined,
129+
};
130+
131+
const mockVariables = { data: { name: 'Test Pet', tag: 'cat' } };
132+
133+
// Call the generated onSuccess
134+
options.onSuccess!(
135+
undefined,
136+
mockVariables,
137+
undefined as never,
138+
mockContext,
139+
);
140+
141+
// User's onSuccess should have been called with the same arguments
142+
expect(userCallbackCalled).toBe(true);
143+
expect(receivedData).toBe(undefined);
144+
expect(receivedVariables).toEqual(mockVariables);
145+
});
146+
});
147+
});

0 commit comments

Comments
 (0)