1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
|
---
type: reference, dev
stage: none
group: Development
info: "See the Technical Writers assigned to Development Guidelines: https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-development-guidelines"
---
# GraphQL
## Getting Started
### Helpful Resources
**General resources**:
- [📚 Official Introduction to GraphQL](https://graphql.org/learn/)
- [📚 Official Introduction to Apollo](https://www.apollographql.com/docs/tutorial/introduction/)
**GraphQL at GitLab**:
- [🎬 GitLab Unfiltered GraphQL playlist](https://www.youtube.com/watch?v=wHPKZBDMfxE&list=PL05JrBw4t0KpcjeHjaRMB7IGB2oDWyJzv)
- [🎬 GraphQL at GitLab: Deep Dive](../api_graphql_styleguide.md#deep-dive) (video) by Nick Thomas
- An overview of the history of GraphQL at GitLab (not frontend-specific)
- [🎬 GitLab Feature Walkthrough with GraphQL and Vue Apollo](https://www.youtube.com/watch?v=6yYp2zB7FrM) (video) by Natalia Tepluhina
- A real-life example of implementing a frontend feature in GitLab using GraphQL
- [🎬 History of client-side GraphQL at GitLab](https://www.youtube.com/watch?v=mCKRJxvMnf0) (video) Illya Klymov and Natalia Tepluhina
- [🎬 From Vuex to Apollo](https://www.youtube.com/watch?v=9knwu87IfU8) (video) by Natalia Tepluhina
- A useful overview of when Apollo might be a better choice than Vuex, and how one could go about the transition
- [🛠Vuex -> Apollo Migration: a proof-of-concept project](https://gitlab.com/ntepluhina/vuex-to-apollo/blob/master/README.md)
- A collection of examples that show the possible approaches for state management with Vue+GraphQL+(Vuex or Apollo) apps
### Libraries
We use [Apollo](https://www.apollographql.com/) (specifically [Apollo Client](https://www.apollographql.com/docs/react/)) and [Vue Apollo](https://github.com/vuejs/vue-apollo)
when using GraphQL for frontend development.
If you are using GraphQL within a Vue application, the [Usage in Vue](#usage-in-vue) section
can help you learn how to integrate Vue Apollo.
For other use cases, check out the [Usage outside of Vue](#usage-outside-of-vue) section.
We use [Immer](https://immerjs.github.io/immer/docs/introduction) for immutable cache updates;
see [Immutability and cache updates](#immutability-and-cache-updates) for more information.
### Tooling
- [Apollo Client Devtools](https://github.com/apollographql/apollo-client-devtools)
#### [Apollo GraphQL VS Code extension](https://marketplace.visualstudio.com/items?itemName=apollographql.vscode-apollo)
If you use VS Code, the Apollo GraphQL extension supports autocompletion in `.graphql` files. To set up
the GraphQL extension, follow these steps:
1. Add an `apollo.config.js` file to the root of your `gitlab` local directory.
1. Populate the file with the following content:
```javascript
module.exports = {
client: {
includes: ['./app/assets/javascripts/**/*.graphql', './ee/app/assets/javascripts/**/*.graphql'],
service: {
name: 'GitLab',
localSchemaFile: './doc/api/graphql/reference/gitlab_schema.graphql',
},
},
};
```
1. Restart VS Code.
### Exploring the GraphQL API
Our GraphQL API can be explored via GraphiQL at your instance's
`/-/graphql-explorer` or at [GitLab.com](https://gitlab.com/-/graphql-explorer). Consult the
[GitLab GraphQL API Reference documentation](../../api/graphql/reference)
where needed.
You can check all existing queries and mutations on the right side
of GraphiQL in its **Documentation explorer**. It's also possible to
write queries and mutations directly on the left tab and check
their execution by clicking **Execute query** button on the top left:
![GraphiQL interface](img/graphiql_explorer_v12_4.png)
## Apollo Client
To save duplicated clients getting created in different apps, we have a
[default client](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/javascripts/lib/graphql.js) that should be used. This sets up the
Apollo client with the correct URL and also sets the CSRF headers.
Default client accepts two parameters: `resolvers` and `config`.
- `resolvers` parameter is created to accept an object of resolvers for [local state management](#local-state-with-apollo) queries and mutations
- `config` parameter takes an object of configuration settings:
- `cacheConfig` field accepts an optional object of settings to [customize Apollo cache](https://www.apollographql.com/docs/react/caching/cache-configuration/#configuring-the-cache)
- `baseUrl` allows us to pass a URL for GraphQL endpoint different from our main endpoint (i.e.`${gon.relative_url_root}/api/graphql`)
- `assumeImmutableResults` (set to `false` by default) - this setting, when set to `true`, will assume that every single operation on updating Apollo Cache is immutable. It also sets `freezeResults` to `true`, so any attempt on mutating Apollo Cache will throw a console warning in development environment. Please ensure you're following the immutability pattern on cache update operations before setting this option to `true`.
- `fetchPolicy` determines how you want your component to interact with the Apollo cache. Defaults to "cache-first".
## GraphQL Queries
To save query compilation at runtime, webpack can directly import `.graphql`
files. This allows webpack to pre-process the query at compile time instead
of the client doing compilation of queries.
To distinguish queries from mutations and fragments, the following naming convention is recommended:
- `all_users.query.graphql` for queries;
- `add_user.mutation.graphql` for mutations;
- `basic_user.fragment.graphql` for fragments.
### Fragments
[Fragments](https://graphql.org/learn/queries/#fragments) are a way to make your complex GraphQL queries more readable and re-usable. Here is an example of GraphQL fragment:
```javascript
fragment DesignListItem on Design {
id
image
event
filename
notesCount
}
```
Fragments can be stored in separate files, imported and used in queries, mutations, or other fragments.
```javascript
#import "./design_list.fragment.graphql"
#import "./diff_refs.fragment.graphql"
fragment DesignItem on Design {
...DesignListItem
fullPath
diffRefs {
...DesignDiffRefs
}
}
```
More about fragments:
[GraphQL Docs](https://graphql.org/learn/queries/#fragments)
## Global IDs
The GitLab GraphQL API expresses `id` fields as Global IDs rather than the PostgreSQL
primary key `id`. Global ID is [a convention](https://graphql.org/learn/global-object-identification/)
used for caching and fetching in client-side libraries.
To convert a Global ID to the primary key `id`, you can use `getIdFromGraphQLId`:
```javascript
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
const primaryKeyId = getIdFromGraphQLId(data.id);
```
## Immutability and cache updates
From Apollo version 3.0.0 all the cache updates need to be immutable; it needs to be replaced entirely
with a **new and updated** object.
To facilitate the process of updating the cache and returning the new object we use the library [Immer](https://immerjs.github.io/immer/docs/introduction).
When possible, follow these conventions:
- The updated cache is named `data`.
- The original cache data is named `sourceData`.
A typical update process looks like this:
```javascript
...
const sourceData = client.readQuery({ query });
const data = produce(sourceData, draftState => {
draftState.commits.push(newCommit);
});
client.writeQuery({
query,
data,
});
...
```
As shown in the code example by using `produce`, we can perform any kind of direct manipulation of the
`draftState`. Besides, `immer` guarantees that a new state which includes the changes to `draftState` will be generated.
Finally, to verify whether the immutable cache update is working properly, we need to change
`assumeImmutableResults` to `true` in the default client configuration (see [Apollo Client](#apollo-client) for more information).
If everything is working properly `assumeImmutableResults` should remain set to `true`.
## Usage in Vue
To use Vue Apollo, import the [Vue Apollo](https://github.com/vuejs/vue-apollo) plugin as well
as the default client. This should be created at the same point
the Vue application is mounted.
```javascript
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
new Vue({
...,
apolloProvider,
...
});
```
Read more about [Vue Apollo](https://github.com/vuejs/vue-apollo) in the [Vue Apollo documentation](https://vue-apollo.netlify.app/guide/).
### Local state with Apollo
It is possible to manage an application state with Apollo by passing
in a resolvers object when creating the default client. The default state can be set by writing
to the cache after setting up the default client.
```javascript
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const defaultClient = createDefaultClient();
defaultClient.cache.writeData({
data: {
user: {
name: 'John',
surname: 'Doe',
age: 30
},
},
});
const apolloProvider = new VueApollo({
defaultClient,
});
```
We can query local data with `@client` Apollo directive:
```javascript
// user.query.graphql
query User {
user @client {
name
surname
age
}
}
```
Along with creating local data, we can also extend existing GraphQL types with `@client` fields. This is extremely useful when we need to mock an API responses for fields not yet added to our GraphQL API.
#### Mocking API response with local Apollo cache
Using local Apollo Cache is handy when we have a need to mock some GraphQL API responses, queries or mutations locally (e.g. when they're still not added to our actual API).
For example, we have a [fragment](#fragments) on `DesignVersion` used in our queries:
```javascript
fragment VersionListItem on DesignVersion {
id
sha
}
```
We need to fetch also version author and the 'created at' property to display them in the versions dropdown but these changes are still not implemented in our API. We can change the existing fragment to get a mocked response for these new fields:
```javascript
fragment VersionListItem on DesignVersion {
id
sha
author @client {
avatarUrl
name
}
createdAt @client
}
```
Now Apollo will try to find a _resolver_ for every field marked with `@client` directive. Let's create a resolver for `DesignVersion` type (why `DesignVersion`? because our fragment was created on this type).
```javascript
// resolvers.js
const resolvers = {
DesignVersion: {
author: () => ({
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
name: 'Administrator',
__typename: 'User',
}),
createdAt: () => '2019-11-13T16:08:11Z',
},
};
export default resolvers;
```
We need to pass a resolvers object to our existing Apollo Client:
```javascript
// graphql.js
import createDefaultClient from '~/lib/graphql';
import resolvers from './graphql/resolvers';
const defaultClient = createDefaultClient(resolvers);
```
For each attempt to fetch a version, our client will fetch `id` and `sha` from the remote API endpoint and will assign our hardcoded values to the `author` and `createdAt` version properties. With this data, frontend developers are able to work on their UI without being blocked by backend. When the actual response is added to the API, our custom local resolver can be removed and the only change to the query/fragment is to remove the `@client` directive.
Read more about local state management with Apollo in the [Vue Apollo documentation](https://vue-apollo.netlify.app/guide/local-state.html#local-state).
### Using with Vuex
When Apollo Client is used within Vuex and fetched data is stored in the Vuex store, there is no need to keep Apollo Client cache enabled. Otherwise we would have data from the API stored in two places - Vuex store and Apollo Client cache. With Apollo's default settings, a subsequent fetch from the GraphQL API could result in fetching data from Apollo cache (in the case where we have the same query and variables). To prevent this behavior, we need to disable Apollo Client cache by passing a valid `fetchPolicy` option to its constructor:
```javascript
import fetchPolicies from '~/graphql_shared/fetch_policy_constants';
export const gqClient = createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
```
### Feature flags in queries
Sometimes it may be useful to have an entity in the GraphQL query behind a feature flag.
For example, when working on a feature where the backend has already been merged but the frontend
hasn't you might want to put the GraphQL entity behind a feature flag to allow for smaller
merge requests to be created and merged.
To do this we can use the `@include` directive to exclude an entity if the `if` statement passes.
```graphql
query getAuthorData($authorNameEnabled: Boolean = false) {
username
name @include(if: $authorNameEnabled)
}
```
Then in the Vue (or JavaScript) call to the query we can pass in our feature flag. This feature
flag will need to be already setup correctly. See the [feature flag documentation](../feature_flags/development.md)
for the correct way to do this.
```javascript
export default {
apollo: {
user: {
query: QUERY_IMPORT,
variables() {
return {
authorNameEnabled: gon?.features?.authorNameEnabled,
};
},
}
},
};
```
### Manually triggering queries
Queries on a component's `apollo` property are made automatically when the component is created.
Some components instead want the network request made on-demand, for example a dropdown with lazy-loaded items.
There are two ways to do this:
1. Use the `skip` property
```javascript
export default {
apollo: {
user: {
query: QUERY_IMPORT,
skip() {
// only make the query when dropdown is open
return !this.isOpen;
},
}
},
};
```
1. Using `addSmartQuery`
You can manually create the Smart Query in your method.
```javascript
handleClick() {
this.$apollo.addSmartQuery('user', {
// this takes the same values as you'd have in the `apollo` section
query: QUERY_IMPORT,
}),
};
```
### Working with pagination
The GitLab GraphQL API uses [Relay-style cursor pagination](https://www.apollographql.com/docs/react/pagination/overview/#cursor-based)
for connection types. This means a "cursor" is used to keep track of where in the data
set the next items should be fetched from. [GraphQL Ruby Connection Concepts](https://graphql-ruby.org/pagination/connection_concepts.html)
is a good overview and introduction to connections.
Every connection type (for example, `DesignConnection` and `DiscussionConnection`) has a field `pageInfo` that contains an information required for pagination:
```javascript
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
```
Here:
- `startCursor` and `endCursor` display the cursor of the first and last items
respectively.
- `hasPreviousPage` and `hasNextPage` allow us to check if there are more pages
available before or after the current page.
When we fetch data with a connection type, we can pass cursor as `after` or `before`
parameter, indicating a starting or ending point of our pagination. They should be
followed with `first` or `last` parameter respectively to indicate _how many_ items
we want to fetch after or before a given endpoint.
For example, here we're fetching 10 designs after a cursor (let us call this `projectQuery`):
```javascript
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query {
project(fullPath: "root/my-project") {
id
issue(iid: "42") {
designCollection {
designs(atVersion: null, after: "Ihwffmde0i", first: 10) {
edges {
node {
id
}
}
pageInfo {
...PageInfo
}
}
}
}
}
}
```
Note that we are using the [`pageInfo.fragment.graphql`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql) to populate the `pageInfo` information.
#### Using `fetchMore` method in components
This approach makes sense to use with user-handled pagination (e.g. when the scrolls to fetch more data or explicitly clicks a "Next Page"-button).
When we need to fetch all the data initially, it is recommended to use [a (non-smart) query, instead](#using-a-recursive-query-in-components).
When making an initial fetch, we usually want to start a pagination from the beginning.
In this case, we can either:
- Skip passing a cursor.
- Pass `null` explicitly to `after`.
After data is fetched, we can use the `update`-hook as an opportunity [to customize
the data that is set in the Vue component property](https://apollo.vuejs.org/api/smart-query.html#options), getting a hold of the `pageInfo` object among other data.
In the `result`-hook, we can inspect the `pageInfo` object to see if we need to fetch
the next page. Note that we also keep a `requestCount` to ensure that the application
does not keep requesting the next page, indefinitely:
```javascript
data() {
return {
pageInfo: null,
requestCount: 0,
}
},
apollo: {
designs: {
query: projectQuery,
variables() {
return {
// ... The rest of the design variables
first: 10,
};
},
update(data) {
const { id = null, issue = {} } = data.project || {};
const { edges = [], pageInfo } = issue.designCollection?.designs || {};
return {
id,
edges,
pageInfo,
};
},
result() {
const { pageInfo } = this.designs;
// Increment the request count with each new result
this.requestCount += 1;
// Only fetch next page if we have more requests and there is a next page to fetch
if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
this.fetchNextPage(pageInfo.endCursor);
}
},
},
},
```
When we want to move to the next page, we use an Apollo `fetchMore` method, passing a
new cursor (and, optionally, new variables) there. In the `updateQuery` hook, we have
to return a result we want to see in the Apollo cache after fetching the next page.
[`Immer`s `produce`](#immutability-and-cache-updates)-function can help us with the immutability here:
```javascript
fetchNextPage(endCursor) {
this.$apollo.queries.designs.fetchMore({
variables: {
// ... The rest of the design variables
first: 10,
after: endCursor,
},
updateQuery(previousResult, { fetchMoreResult }) {
// Here we can implement the logic of adding new designs to existing ones
// (for example, if we use infinite scroll) or replacing old result
// with the new one if we use numbered pages
const { designs: previousDesigns } = previousResult.project.issue.designCollection;
const { designs: newDesigns } = fetchMoreResult.project.issue.designCollection
return produce(previousResult, draftData => {
// `produce` gives us a working copy, `draftData`, that we can modify
// as we please and from it will produce the next immutable result for us
draftData.project.issue.designCollection.designs = [...previousDesigns, ...newDesigns];
});
},
});
}
```
#### Using a recursive query in components
When it is necessary to fetch all paginated data initially an Apollo query can do the trick for us.
If we need to fetch the next page based on user interactions, it is recommend to use a [`smartQuery`](https://apollo.vuejs.org/api/smart-query.html) along with the [`fetchMore`-hook](#using-fetchmore-method-in-components).
When the query resolves we can update the component data and inspect the `pageInfo` object
to see if we need to fetch the next page, i.e. call the method recursively.
Note that we also keep a `requestCount` to ensure that the application does not keep
requesting the next page, indefinitely.
```javascript
data() {
return {
requestCount: 0,
isLoading: false,
designs: {
edges: [],
pageInfo: null,
},
}
},
created() {
this.fetchDesigns();
},
methods: {
handleError(error) {
this.isLoading = false;
// Do something with `error`
},
fetchDesigns(endCursor) {
this.isLoading = true;
return this.$apollo
.query({
query: projectQuery,
variables() {
return {
// ... The rest of the design variables
first: 10,
endCursor,
};
},
})
.then(({ data }) => {
const { id = null, issue = {} } = data.project || {};
const { edges = [], pageInfo } = issue.designCollection?.designs || {};
// Update data
this.designs = {
id,
edges: [...this.designs.edges, ...edges];
pageInfo: pageInfo;
};
// Increment the request count with each new result
this.requestCount += 1;
// Only fetch next page if we have more requests and there is a next page to fetch
if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
this.fetchDesigns(pageInfo.endCursor);
} else {
this.isLoading = false;
}
})
.catch(this.handleError);
},
},
```
#### Pagination and optimistic updates
When Apollo caches paginated data client-side, it includes `pageInfo` variables in the cache key.
If you wanted to optimistically update that data, you'd have to provide `pageInfo` variables
when interacting with the cache via [`.readQuery()`](https://www.apollographql.com/docs/react/v2/api/apollo-client/#ApolloClient.readQuery)
or [`.writeQuery()`](https://www.apollographql.com/docs/react/v2/api/apollo-client/#ApolloClient.writeQuery).
This can be tedious and counter-intuitive.
To make it easier to deal with cached paginated queries, Apollo provides the `@connection` directive.
The directive accepts a `key` parameter that will be used as a static key when caching the data.
You'd then be able to retrieve the data without providing any pagination-specific variables.
Here's an example of a query using the `@connection` directive:
```graphql
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
project(fullPath: $fullPath) {
siteProfiles: dastSiteProfiles(after: $after, before: $before, first: $first, last: $last)
@connection(key: "dastSiteProfiles") {
pageInfo {
...PageInfo
}
edges {
cursor
node {
id
# ...
}
}
}
}
}
```
In this example, Apollo will store the data with the stable `dastSiteProfiles` cache key.
To retrieve that data from the cache, you'd then only need to provide the `$fullPath` variable,
omitting pagination-specific variables like `after` or `before`:
```javascript
const data = store.readQuery({
query: dastSiteProfilesQuery,
variables: {
fullPath: 'namespace/project',
},
});
```
Read more about the `@connection` directive in [Apollo's documentation](https://www.apollographql.com/docs/react/v2/caching/cache-interaction/#the-connection-directive).
### Managing performance
The Apollo client will batch queries by default. This means that if you have 3 queries defined,
Apollo will group them into one request, send the single request off to the server and only
respond once all 3 queries have completed.
If you need to have queries sent as individual requests, additional context can be provided
to tell Apollo to do this.
```javascript
export default {
apollo: {
user: {
query: QUERY_IMPORT,
context: {
isSingleRequest: true,
}
}
},
};
```
### Testing
#### Mocking response as component data
With [Vue test utils](https://vue-test-utils.vuejs.org/) it is easy to quickly test components that
fetch GraphQL queries. The simplest way is to use `shallowMount` and then set
the data on the component
```javascript
it('tests apollo component', () => {
const vm = shallowMount(App);
vm.setData({
...mockData
});
});
```
#### Testing loading state
If we need to test how our component renders when results from the GraphQL API are still loading, we can mock a loading state into respective Apollo queries/mutations:
```javascript
function createComponent({
loading = false,
} = {}) {
const $apollo = {
queries: {
designs: {
loading,
},
},
};
wrapper = shallowMount(Index, {
sync: false,
mocks: { $apollo }
});
}
it('renders loading icon', () => {
createComponent({ loading: true });
expect(wrapper.element).toMatchSnapshot();
})
```
#### Testing Apollo components
If we use `ApolloQuery` or `ApolloMutation` in our components, in order to test their functionality we need to add a stub first:
```javascript
import { ApolloMutation } from 'vue-apollo';
function createComponent(props = {}) {
wrapper = shallowMount(MyComponent, {
sync: false,
propsData: {
...props,
},
stubs: {
ApolloMutation,
},
});
}
```
`ApolloMutation` component exposes `mutate` method via scoped slot. If we want to test this method, we need to add it to mocks:
```javascript
const mutate = jest.fn().mockResolvedValue();
const $apollo = {
mutate,
};
function createComponent(props = {}) {
wrapper = shallowMount(MyComponent, {
sync: false,
propsData: {
...props,
},
stubs: {
ApolloMutation,
},
mocks: {
$apollo,
}
});
}
```
Then we can check if `mutate` is called with correct variables:
```javascript
const mutationVariables = {
mutation: createNoteMutation,
update: expect.anything(),
variables: {
input: {
noteableId: 'noteable-id',
body: 'test',
discussionId: '0',
},
},
};
it('calls mutation on submitting form ', () => {
createComponent()
findReplyForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables);
});
```
### Testing with mocked Apollo Client
To test the logic of Apollo cache updates, we might want to mock an Apollo Client in our unit tests. We use [`mock-apollo-client`](https://www.npmjs.com/package/mock-apollo-client) library to mock Apollo client and [`createMockApollo` helper](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/frontend/__helpers__/mock_apollo_helper.js) we created on top of it.
To separate tests with mocked client from 'usual' unit tests, it's recommended to create an additional factory and pass the created `mockApollo` as an option to the `createComponent`-factory. This way we only create Apollo Client instance when it's necessary.
We need to inject `VueApollo` to the Vue local instance and, likewise, it is recommended to call `localVue.use()` within `createMockApolloProvider()` to only load it when it is necessary.
```javascript
import VueApollo from 'vue-apollo';
import { createLocalVue } from '@vue/test-utils';
const localVue = createLocalVue();
function createMockApolloProvider() {
localVue.use(VueApollo);
return createMockApollo(requestHandlers);
}
function createComponent(options = {}) {
const { mockApollo } = options;
...
return shallowMount(..., {
localVue,
apolloProvider: mockApollo,
...
});
}
```
After this, you can control whether you need a variable for `mockApollo` and assign it in the appropriate `describe`-scope:
```javascript
describe('Some component', () => {
let wrapper;
describe('with Apollo mock', () => {
let mockApollo;
beforeEach(() => {
mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
});
});
});
```
Within `createMockApolloProvider`-factory, we need to define an array of _handlers_ for every query or mutation:
```javascript
import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
describe('Some component with Apollo mock', () => {
let wrapper;
let mockApollo;
function createMockApolloProvider() {
Vue.use(VueApollo);
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
...
}
})
```
After this, we need to create a mock Apollo Client instance using a helper:
```javascript
import createMockApollo from 'helpers/mock_apollo_helper';
describe('Some component', () => {
let wrapper;
function createMockApolloProvider() {
Vue.use(VueApollo);
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
return createMockApollo(requestHandlers);
}
function createComponent(options = {}) {
const { mockApollo } = options;
return shallowMount(Index, {
localVue,
apolloProvider: mockApollo,
});
}
describe('with Apollo mock', () => {
let mockApollo;
beforeEach(() => {
mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
});
});
});
```
When mocking resolved values, ensure the structure of the response is the same
as the actual API response. For example, root property should be `data`.
When testing queries, please keep in mind they are promises, so they need to be _resolved_ to render a result. Without resolving, we can check the `loading` state of the query:
```javascript
it('renders a loading state', () => {
const mockApollo = createMockApolloProvider();
const wrapper = createComponent({ mockApollo });
expect(wrapper.find(LoadingSpinner).exists()).toBe(true)
});
it('renders designs list', async () => {
const mockApollo = createMockApolloProvider();
const wrapper = createComponent({ mockApollo });
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(findDesigns()).toHaveLength(3);
});
```
If we need to test a query error, we need to mock a rejected value as request handler:
```javascript
function createMockApolloProvider() {
...
const requestHandlers = [
[getDesignListQuery, jest.fn().mockRejectedValue(new Error('GraphQL error')],
];
...
}
...
it('renders error if query fails', async () => {
const wrapper = createComponent();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(wrapper.find('.test-error').exists()).toBe(true)
})
```
Request handlers can also be passed to component factory as a parameter.
Mutations could be tested the same way with a few additional `nextTick`s to get the updated result:
```javascript
function createMockApolloProvider({
moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
}) {
Vue.use(VueApollo);
moveDesignHandler = moveHandler;
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
[moveDesignMutation, moveDesignHandler],
];
return createMockApollo(requestHandlers);
}
function createComponent(options = {}) {
const { mockApollo } = options;
return shallowMount(Index, {
localVue,
apolloProvider: mockApollo,
});
}
...
it('calls a mutation with correct parameters and reorders designs', async () => {
const mockApollo = createMockApolloProvider({});
const wrapper = createComponent({ mockApollo });
wrapper.find(VueDraggable).vm.$emit('change', {
moved: {
newIndex: 0,
element: designToMove,
},
});
expect(moveDesignHandler).toHaveBeenCalled();
await wrapper.vm.$nextTick();
expect(
findDesigns()
.at(0)
.props('id'),
).toBe('2');
});
```
#### Testing `@client` queries
##### Using mock resolvers
If your application contains `@client` queries, you get
the following Apollo Client warning when passing only handlers:
```shell
Unexpected call of console.warn() with:
Warning: mock-apollo-client - The query is entirely client-side (using @client directives) and resolvers have been configured. The request handler will not be called.
```
To fix this you should define mock `resolvers` instead of
mock `handlers`. For example, given the following `@client` query:
```graphql
query getBlobContent($path: String, $ref: String!) {
blobContent(path: $path, ref: $ref) @client {
rawData
}
}
```
And its actual client-side resolvers:
```javascript
import Api from '~/api';
export const resolvers = {
Query: {
blobContent(_, { path, ref }) {
return {
__typename: 'BlobContent',
rawData: Api.getRawFile(path, { ref }).then(({ data }) => {
return data;
}),
};
},
},
};
export default resolvers;
```
We can use a **mock resolver** that returns data with the
same shape, while mock the result with a mock function:
```javascript
let mockApollo;
let mockBlobContentData; // mock function, jest.fn();
const mockResolvers = {
Query: {
blobContent() {
return {
__typename: 'BlobContent',
rawData: mockBlobContentData(), // the mock function can resolve mock data
};
},
},
};
const createComponentWithApollo = ({ props = {} } = {}) => {
mockApollo = createMockApollo([], mockResolvers); // resolvers are the second parameter
wrapper = shallowMount(MyComponent, {
localVue,
propsData: {},
apolloProvider: mockApollo,
// ...
})
};
```
After which, you can resolve or reject the value needed.
```javascript
beforeEach(() => {
mockBlobContentData = jest.fn();
});
it('shows data', async() => {
mockBlobContentData.mockResolvedValue(data); // you may resolve or reject to mock the result
createComponentWithApollo();
await waitForPromises(); // wait on the resolver mock to execute
expect(findContent().text()).toBe(mockCiYml);
});
```
##### Using `cache.writeQuery`
Sometimes we want to test a `result` hook of the local query. In order to have it triggered, we need to populate a cache with correct data to be fetched with this query:
```javascript
query fetchLocalUser {
fetchLocalUser @client {
name
}
}
```
```javascript
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';
function createMockApolloProvider() {
Vue.use(VueApollo);
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
const mockApollo = createMockApollo(requestHandlers, {});
mockApollo.clients.defaultClient.cache.writeQuery({
query: fetchLocalUserQuery,
data: {
fetchLocalUser: {
__typename: 'User',
name: 'Test',
},
},
});
return mockApollo;
}
function createComponent(options = {}) {
const { mockApollo } = options;
return shallowMount(Index, {
localVue,
apolloProvider: mockApollo,
});
}
```
Sometimes it is necessary to control what the local resolver returns and inspect how it is called by the component. This can be done by mocking your local resolver:
```javascript
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';
function createMockApolloProvider(options = {}) {
Vue.use(VueApollo);
const { fetchLocalUserSpy } = options;
const mockApollo = createMockApollo([], {
Query: {
fetchLocalUser: fetchLocalUserSpy,
},
});
// Necessary for local resolvers to be activated
mockApollo.clients.defaultClient.cache.writeQuery({
query: fetchLocalUserQuery,
data: {},
});
return mockApollo;
}
```
In the test you can then control what the spy is supposed to do and inspect the component after the request have returned:
```javascript
describe('My Index test with `createMockApollo`', () => {
let wrapper;
let fetchLocalUserSpy;
afterEach(() => {
wrapper.destroy();
wrapper = null;
fetchLocalUserSpy = null;
});
describe('when loading', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
});
it('displays the loader', () => {
// Assess that the loader is present
});
});
describe('with data', () => {
beforeEach(async () => {
fetchLocalUserSpy = jest.fn().mockResolvedValue(localUserQueryResponse);
const mockApollo = createMockApolloProvider(fetchLocalUserSpy);
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('should fetch data once', () => {
expect(fetchLocalUserSpy).toHaveBeenCalledTimes(1);
});
it('displays data', () => {
// Assess that data is present
});
});
describe('with error', () => {
const error = 'Error!';
beforeEach(async () => {
fetchLocalUserSpy = jest.fn().mockRejectedValueOnce(error);
const mockApollo = createMockApolloProvider(fetchLocalUserSpy);
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('should fetch data once', () => {
expect(fetchLocalUserSpy).toHaveBeenCalledTimes(1);
});
it('displays the error', () => {
// Assess that the error is displayed
});
});
});
```
## Handling errors
The GitLab GraphQL mutations currently have two distinct error modes: [Top-level](#top-level-errors) and [errors-as-data](#errors-as-data).
When utilising a GraphQL mutation, we must consider handling **both of these error modes** to ensure that the user receives the appropriate feedback when an error occurs.
### Top-level errors
These errors are located at the "top level" of a GraphQL response. These are non-recoverable errors including argument errors and syntax errors, and should not be presented directly to the user.
#### Handling top-level errors
Apollo is aware of top-level errors, so we are able to leverage Apollo's various error-handling mechanisms to handle these errors (e.g. handling Promise rejections after invoking the [`mutate`](https://www.apollographql.com/docs/react/api/core/ApolloClient/#ApolloClient.mutate) method, or handling the `error` event emitted from the [`ApolloMutation`](https://apollo.vuejs.org/api/apollo-mutation.html#events) component).
Because these errors are not intended for users, error messages for top-level errors should be defined client-side.
### Errors-as-data
These errors are nested within the `data` object of a GraphQL response. These are recoverable errors that, ideally, can be presented directly to the user.
#### Handling errors-as-data
First, we must add `errors` to our mutation object:
```diff
mutation createNoteMutation($input: String!) {
createNoteMutation(input: $input) {
note {
id
+ errors
}
}
```
Now, when we commit this mutation and errors occur, the response will include `errors` for us to handle:
```javascript
{
data: {
mutationName: {
errors: ["Sorry, we were not able to update the note."]
}
}
}
```
When handling errors-as-data, use your best judgement to determine whether to present the error message in the response, or another message defined client-side, to the user.
## Usage outside of Vue
It is also possible to use GraphQL outside of Vue by directly importing
and using the default client with queries.
```javascript
import createDefaultClient from '~/lib/graphql';
import query from './query.graphql';
const defaultClient = createDefaultClient();
defaultClient.query({ query })
.then(result => console.log(result));
```
When [using Vuex](#using-with-vuex), disable the cache when:
- The data is being cached elsewhere
- The use case does not need caching
if the data is being cached elsewhere, or if there is simply no need for it for the given use case.
```javascript
import createDefaultClient from '~/lib/graphql';
import fetchPolicies from '~/graphql_shared/fetch_policy_constants';
const defaultClient = createDefaultClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
```
## Making initial queries early with GraphQL startup calls
To improve performance, sometimes we want to make initial GraphQL queries early. In order to do this, we can add them to **startup calls** with the following steps:
- Move all the queries you need initially in your application to `app/graphql/queries`;
- Add `__typename` property to every nested query level:
```javascript
query getPermissions($projectPath: ID!) {
project(fullPath: $projectPath) {
__typename
userPermissions {
__typename
pushCode
forkProject
createMergeRequestIn
}
}
}
```
- If queries contain fragments, you need to move fragments to the query file directly instead of importing them:
```javascript
fragment PageInfo on PageInfo {
__typename
hasNextPage
hasPreviousPage
startCursor
endCursor
}
query getFiles(
$projectPath: ID!
$path: String
$ref: String!
) {
project(fullPath: $projectPath) {
__typename
repository {
__typename
tree(path: $path, ref: $ref) {
__typename
pageInfo {
...PageInfo
}
}
}
}
}
}
```
- If the fragment is used only once, we can also remove the fragment altogether:
```javascript
query getFiles(
$projectPath: ID!
$path: String
$ref: String!
) {
project(fullPath: $projectPath) {
__typename
repository {
__typename
tree(path: $path, ref: $ref) {
__typename
pageInfo {
__typename
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
}
```
- Add startup call(s) with correct variables to the HAML file that serves as a view
for your application. To add GraphQL startup calls, we use
`add_page_startup_graphql_call` helper where the first parameter is a path to the
query, the second one is an object containing query variables. Path to the query is
relative to `app/graphql/queries` folder: for example, if we need a
`app/graphql/queries/repository/files.query.graphql` query, the path will be
`repository/files`.
```yaml
- current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1]
- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" })
- add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path })
- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"})
```
|