summaryrefslogtreecommitdiff
path: root/doc/development/fe_guide/graphql.md
diff options
context:
space:
mode:
Diffstat (limited to 'doc/development/fe_guide/graphql.md')
-rw-r--r--doc/development/fe_guide/graphql.md527
1 files changed, 452 insertions, 75 deletions
diff --git a/doc/development/fe_guide/graphql.md b/doc/development/fe_guide/graphql.md
index cae2435e4ff..b1896863af9 100644
--- a/doc/development/fe_guide/graphql.md
+++ b/doc/development/fe_guide/graphql.md
@@ -143,7 +143,7 @@ More about fragments:
## Global IDs
-GitLab's GraphQL API expresses `id` fields as Global IDs rather than the PostgreSQL
+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.
@@ -187,7 +187,7 @@ As shown in the code example by using `produce`, we can perform any kind of dire
`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 config` (see [Apollo Client](#apollo-client) for more info).
+`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`.
@@ -411,7 +411,7 @@ handleClick() {
### Working with pagination
-GitLab's GraphQL API uses [Relay-style cursor pagination](https://www.apollographql.com/docs/react/data/pagination/#cursor-based)
+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.
@@ -439,9 +439,11 @@ parameter, indicating a starting or ending point of our pagination. They should
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:
+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
@@ -453,6 +455,9 @@ query {
id
}
}
+ pageInfo {
+ ...PageInfo
+ }
}
}
}
@@ -460,21 +465,31 @@ query {
}
```
+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 should save a `pageInfo` object. Let's assume we're storing
-it to Vue component `data`:
+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: {
@@ -482,13 +497,29 @@ apollo: {
query: projectQuery,
variables() {
return {
- // rest of design variables
- ...
+ // ... The rest of the design variables
first: 10,
};
},
- result(res) {
- this.pageInfo = res.data?.project?.issue?.designCollection?.designs?.pageInfo;
+ 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);
+ }
},
},
},
@@ -497,34 +528,154 @@ apollo: {
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() {
- // as a first step, we're checking if we have more pages to move forward
- if (this.pageInfo?.hasNextPage) {
- this.$apollo.queries.designs.fetchMore({
- variables: {
- // rest of design variables
- ...
- first: 10,
- after: this.pageInfo?.endCursor,
- },
- updateQuery(previousResult, { fetchMoreResult }) {
- // here we can implement the logic of adding new designs to fetched one (for example, if we use infinite scroll)
- // or replacing old result with the new one if we use numbered pages
+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];
+ });
+ },
+ });
+}
+```
- const newDesigns = fetchMoreResult.project.issue.designCollection.designs;
- previousResult.project.issue.designCollection.designs.push(...newDesigns)
+#### Using a recursive query in components
- return previousResult;
- },
- });
+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
+ # ...
+ }
+ }
+ }
}
}
```
-Please note we don't have to save `pageInfo` one more time; `fetchMore` triggers a query
-`result` hook as well.
+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
@@ -561,7 +712,7 @@ it('tests apollo component', () => {
const vm = shallowMount(App);
vm.setData({
- ...mock data
+ ...mockData
});
});
```
@@ -633,7 +784,7 @@ function createComponent(props = {}) {
ApolloMutation,
},
mocks: {
- $apollo:
+ $apollo,
}
});
}
@@ -666,34 +817,51 @@ it('calls mutation on submitting form ', () => {
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 component factory. This way we only create Apollo Client instance when it's necessary:
-
-```javascript
-function createComponent() {...}
-
-function createComponentWithApollo() {...}
-```
+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.
-Then we need to inject `VueApollo` to Vue local instance (`localVue.use()` can also be called within `createComponentWithApollo()`)
+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();
-localVue.use(VueApollo);
+
+function createMockApolloProvider() {
+ localVue.use(VueApollo);
+
+ return createMockApollo(requestHandlers);
+}
+
+function createComponent(options = {}) {
+ const { mockApollo } = options;
+ ...
+ return shallowMount(..., {
+ localVue,
+ apolloProvider: mockApollo,
+ ...
+ });
+}
```
-After this, on the global `describe`, we should create a variable for `fakeApollo`:
+After this, you can control whether you need a variable for `mockApollo` and assign it in the appropriate `describe`-scope:
```javascript
-describe('Some component with Apollo mock', () => {
+describe('Some component', () => {
let wrapper;
- let fakeApollo
-})
+
+ describe('with Apollo mock', () => {
+ let mockApollo;
+
+ beforeEach(() => {
+ mockApollo = createMockApolloProvider();
+ wrapper = createComponent({ mockApollo });
+ });
+ });
+});
```
-Within component factory, we need to define an array of _handlers_ for every query or mutation:
+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';
@@ -702,13 +870,16 @@ import moveDesignMutation from '~/design_management/graphql/mutations/move_desig
describe('Some component with Apollo mock', () => {
let wrapper;
- let fakeApollo;
+ let mockApollo;
+
+ function createMockApolloProvider() {
+ Vue.use(VueApollo);
- function createComponentWithApollo() {
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
+ ...
}
})
```
@@ -718,23 +889,38 @@ After this, we need to create a mock Apollo Client instance using a helper:
```javascript
import createMockApollo from 'jest/helpers/mock_apollo_helper';
-describe('Some component with Apollo mock', () => {
+describe('Some component', () => {
let wrapper;
- let fakeApollo;
- function createComponentWithApollo() {
+ function createMockApolloProvider() {
+ Vue.use(VueApollo);
+
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
- fakeApollo = createMockApollo(requestHandlers);
- wrapper = shallowMount(Index, {
+ return createMockApollo(requestHandlers);
+ }
+
+ function createComponent(options = {}) {
+ const { mockApollo } = options;
+
+ return shallowMount(Index, {
localVue,
- apolloProvider: fakeApollo,
+ 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
@@ -744,13 +930,15 @@ When testing queries, please keep in mind they are promises, so they need to be
```javascript
it('renders a loading state', () => {
- createComponentWithApollo();
+ const mockApollo = createMockApolloProvider();
+ const wrapper = createComponent({ mockApollo });
expect(wrapper.find(LoadingSpinner).exists()).toBe(true)
});
it('renders designs list', async () => {
- createComponentWithApollo();
+ const mockApollo = createMockApolloProvider();
+ const wrapper = createComponent({ mockApollo });
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
@@ -762,7 +950,7 @@ it('renders designs list', async () => {
If we need to test a query error, we need to mock a rejected value as request handler:
```javascript
-function createComponentWithApollo() {
+function createMockApolloProvider() {
...
const requestHandlers = [
[getDesignListQuery, jest.fn().mockRejectedValue(new Error('GraphQL error')],
@@ -772,7 +960,7 @@ function createComponentWithApollo() {
...
it('renders error if query fails', async () => {
- createComponent()
+ const wrapper = createComponent();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
@@ -786,9 +974,11 @@ 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 createComponentWithApollo({
+function createMockApolloProvider({
moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
}) {
+ Vue.use(VueApollo);
+
moveDesignHandler = moveHandler;
const requestHandlers = [
@@ -797,15 +987,21 @@ function createComponentWithApollo({
[moveDesignMutation, moveDesignHandler],
];
- fakeApollo = createMockApollo(requestHandlers);
- wrapper = shallowMount(Index, {
+ return createMockApollo(requestHandlers);
+}
+
+function createComponent(options = {}) {
+ const { mockApollo } = options;
+
+ return shallowMount(Index, {
localVue,
- apolloProvider: fakeApollo,
+ apolloProvider: mockApollo,
});
}
...
it('calls a mutation with correct parameters and reorders designs', async () => {
- createComponentWithApollo({});
+ const mockApollo = createMockApolloProvider({});
+ const wrapper = createComponent({ mockApollo });
wrapper.find(VueDraggable).vm.$emit('change', {
moved: {
@@ -828,14 +1024,100 @@ it('calls a mutation with correct parameters and reorders designs', async () =>
#### Testing `@client` queries
-If your application contains `@client` queries, most probably you will have an Apollo Client warning saying that you have a local query but no resolvers are defined. In order to fix it, you need to pass resolvers to the mocked client with a second parameter (bare minimum is an empty object):
+##### 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 createMockApollo from 'jest/helpers/mock_apollo_helper';
-...
-fakeApollo = createMockApollo(requestHandlers, {});
+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
@@ -849,14 +1131,16 @@ query fetchLocalUser {
```javascript
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';
-function createComponentWithApollo() {
+function createMockApolloProvider() {
+ Vue.use(VueApollo);
+
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
- fakeApollo = createMockApollo(requestHandlers, {});
- fakeApollo.clients.defaultClient.cache.writeQuery({
+ const mockApollo = createMockApollo(requestHandlers, {});
+ mockApollo.clients.defaultClient.cache.writeQuery({
query: fetchLocalUserQuery,
data: {
fetchLocalUser: {
@@ -864,18 +1148,111 @@ function createComponentWithApollo() {
name: 'Test',
},
},
- })
+ });
+
+ return mockApollo;
+}
+
+function createComponent(options = {}) {
+ const { mockApollo } = options;
- wrapper = shallowMount(Index, {
+ return shallowMount(Index, {
localVue,
- apolloProvider: fakeApollo,
+ 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
-GitLab's GraphQL mutations currently have two distinct error modes: [Top-level](#top-level-errors) and [errors-as-data](#errors-as-data).
+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.