Commit 56ff9a45 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '329074-fe-use-graphql-for-jira-issues-list' into 'master'

Add Apollo to "Jira Issues List" Vue app

See merge request gitlab-org/gitlab!62162
parents 6fdb932c d71e0d8c
......@@ -183,8 +183,8 @@ export default {
:title="__('Confidential')"
:aria-label="__('Confidential')"
/>
<gl-link :href="webUrl" v-bind="issuableTitleProps"
>{{ issuable.title
<gl-link :href="webUrl" v-bind="issuableTitleProps">
{{ issuable.title
}}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
/></gl-link>
</span>
......
......@@ -62,7 +62,7 @@ export default {
<gl-sprintf :message="emptyStateDescription" />
</template>
<template v-if="!hasIssues" #actions>
<gl-button :href="issueCreateUrl" target="_blank" category="primary" variant="success">
<gl-button :href="issueCreateUrl" target="_blank" variant="confirm">
{{ s__('Integrations|Create new issue in Jira') }}
<gl-icon name="external-link" />
</gl-button>
......
......@@ -10,10 +10,7 @@ import {
AvailableSortOptions,
DEFAULT_PAGE_SIZE,
} from '~/issuable_list/constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import getJiraIssuesQuery from '../graphql/queries/get_jira_issues.query.graphql';
import JiraIssuesListEmptyState from './jira_issues_list_empty_state.vue';
export default {
......@@ -91,55 +88,41 @@ export default {
this.fetchIssues();
},
methods: {
fetchIssues() {
async fetchIssues() {
this.issuesListLoading = true;
this.issuesListLoadFailed = false;
return axios
.get(this.issuesFetchPath, {
params: {
with_labels_details: true,
page: this.currentPage,
per_page: this.$options.defaultPageSize,
try {
const { data } = await this.$apollo.query({
query: getJiraIssuesQuery,
variables: {
issuesFetchPath: this.issuesFetchPath,
search: this.filterParams.search,
state: this.currentState,
sort: this.sortedBy,
labels: this.filterParams.labels,
search: this.filterParams.search,
},
})
.then((res) => {
const { headers, data } = res;
this.currentPage = parseInt(headers['x-page'], 10);
this.totalIssues = parseInt(headers['x-total'], 10);
this.issues = data.map((rawIssue, index) => {
const issue = convertObjectPropsToCamelCase(rawIssue, { deep: true });
return {
...issue,
// JIRA issues don't have ID so we extract
// an ID equivalent from references.relative
id: parseInt(rawIssue.references.relative.split('-').pop(), 10),
author: {
...issue.author,
id: index,
page: this.currentPage,
},
};
});
const { pageInfo, nodes, errors } = data?.jiraIssues ?? {};
if (errors?.length > 0) throw new Error(errors[0]);
this.currentPage = pageInfo.page;
this.totalIssues = pageInfo.total;
this.issues = nodes;
this.issuesCount[this.currentState] = this.issues.length;
})
.catch((error) => {
} catch (error) {
this.issuesListLoadFailed = true;
const errors = error?.response?.data?.errors || [];
const errorMessage = errors[0] || __('An error occurred while loading issues');
createFlash({
message: errorMessage,
message: error.message,
captureError: true,
error,
});
})
.finally(() => {
}
this.issuesListLoading = false;
});
},
getFilteredSearchValue() {
return [
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import jiraIssues from './resolvers/jira_issues';
Vue.use(VueApollo);
const resolvers = {
Query: {
jiraIssues,
},
};
const defaultClient = createDefaultClient(resolvers, { assumeImmutableResults: true });
export default new VueApollo({
defaultClient,
});
#import "../fragments/jira_label.fragment.graphql"
#import "../fragments/jira_user.fragment.graphql"
query jiraIssues(
$issuesFetchPath: String
$search: String
$labels: String
$sort: String
$state: String
$page: Integer
) {
jiraIssues(
issuesFetchPath: $issuesFetchPath
search: $search
labels: $labels
sort: $sort
state: $state
page: $page
) @client {
errors
pageInfo {
total
page
}
nodes {
id
projectId
createdAt
updatedAt
closedAt
title
webUrl
gitlabWebUrl
status
references
externalTracker
labels {
...JiraLabel
}
assignees {
...JiraUser
}
author {
...JiraUser
}
}
}
}
import { DEFAULT_PAGE_SIZE } from '~/issuable_list/constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
const transformJiraIssueAssignees = (jiraIssue) => {
return jiraIssue.assignees.map((assignee) => ({
__typename: 'UserCore',
...assignee,
}));
};
const transformJiraIssueAuthor = (jiraIssue, authorId) => {
return {
__typename: 'UserCore',
...jiraIssue.author,
id: authorId,
};
};
const transformJiraIssueLabels = (jiraIssue) => {
return jiraIssue.labels.map((label) => ({
__typename: 'Label', // eslint-disable-line @gitlab/require-i18n-strings
...label,
}));
};
const transformJiraIssuePageInfo = (responseHeaders = {}) => {
return {
__typename: 'JiraIssuesPageInfo',
page: parseInt(responseHeaders['x-page'], 10) ?? 1,
total: parseInt(responseHeaders['x-total'], 10) ?? 0,
};
};
export const transformJiraIssuesREST = (response) => {
const { headers, data: jiraIssues } = response;
return {
__typename: 'JiraIssues',
errors: [],
pageInfo: transformJiraIssuePageInfo(headers),
nodes: jiraIssues.map((rawIssue, index) => {
const jiraIssue = convertObjectPropsToCamelCase(rawIssue, { deep: true });
return {
__typename: 'JiraIssue',
...jiraIssue,
// JIRA issues don't have ID so we extract
// an ID equivalent from references.relative
id: parseInt(rawIssue.references.relative.split('-').pop(), 10),
author: transformJiraIssueAuthor(jiraIssue, index),
labels: transformJiraIssueLabels(jiraIssue),
assignees: transformJiraIssueAssignees(jiraIssue),
};
}),
};
};
export default function jiraIssuesResolver(
_,
{ issuesFetchPath, search, page, state, sort, labels },
) {
return axios
.get(issuesFetchPath, {
params: {
with_labels_details: true,
per_page: DEFAULT_PAGE_SIZE,
page,
state,
sort,
labels,
search,
},
})
.then((res) => {
return transformJiraIssuesREST(res);
})
.catch((error) => {
return {
__typename: 'JiraIssues',
errors: error?.response?.data?.errors || [__('An error occurred while loading issues')],
pageInfo: transformJiraIssuePageInfo(),
nodes: [],
};
});
}
......@@ -4,6 +4,7 @@ import { IssuableStates } from '~/issuable_list/constants';
import { urlParamsToObject, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import JiraIssuesListApp from './components/jira_issues_list_root.vue';
import apolloProvider from './graphql';
export default function initJiraIssuesList({ mountPointSelector }) {
const mountPointEl = document.querySelector(mountPointSelector);
......@@ -32,6 +33,7 @@ export default function initJiraIssuesList({ mountPointSelector }) {
initialState,
initialSortBy,
},
apolloProvider,
render: (createElement) =>
createElement(JiraIssuesListApp, {
props: {
......
import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import VueApollo from 'vue-apollo';
import JiraIssuesListRoot from 'ee/integrations/jira/issues_list/components/jira_issues_list_root.vue';
import jiraIssues from 'ee/integrations/jira/issues_list/graphql/resolvers/jira_issues';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import axios from '~/lib/utils/axios_utils';
......@@ -27,6 +32,19 @@ const resolvedValue = {
data: mockJiraIssues,
};
const localVue = createLocalVue();
const resolvers = {
Query: {
jiraIssues,
},
};
function createMockApolloProvider() {
localVue.use(VueApollo);
return createMockApollo([], resolvers);
}
describe('JiraIssuesListRoot', () => {
let wrapper;
let mock;
......@@ -39,6 +57,8 @@ describe('JiraIssuesListRoot', () => {
initialFilterParams,
},
provide,
localVue,
apolloProvider: createMockApolloProvider(),
});
};
......@@ -64,10 +84,12 @@ describe('JiraIssuesListRoot', () => {
expect(issuableList.props('issuablesLoading')).toBe(true);
});
it('calls `axios.get` with `issuesFetchPath` and query params', () => {
it('calls `axios.get` with `issuesFetchPath` and query params', async () => {
jest.spyOn(axios, 'get');
createComponent();
await waitForPromises();
expect(axios.get).toHaveBeenCalledWith(
mockProvide.issuesFetchPath,
expect.objectContaining({
......@@ -102,17 +124,10 @@ describe('JiraIssuesListRoot', () => {
nextPage: resolvedValue.headers['x-page'] + 1,
totalItems: resolvedValue.headers['x-total'],
});
expect(issuablesProp).toHaveLength(mockJiraIssues.length);
const firstIssue = convertObjectPropsToCamelCase(mockJiraIssues[0], { deep: true });
expect(issuablesProp[0]).toEqual({
...firstIssue,
id: 31596,
author: {
...firstIssue.author,
id: 0,
},
});
expect(issuablesProp).toMatchObject(
convertObjectPropsToCamelCase(mockJiraIssues, { deep: true }),
);
});
it('sets issuesListLoading to `false`', () => {
......@@ -123,16 +138,16 @@ describe('JiraIssuesListRoot', () => {
describe('when request fails', () => {
it.each`
APIErrorMessage | expectedRenderedErrorMessage
${'API error'} | ${'API error'}
APIErrors | expectedRenderedErrorMessage
${['API error']} | ${'API error'}
${undefined} | ${'An error occurred while loading issues'}
`(
'calls `createFlash` with "$expectedRenderedErrorMessage" when API responds with "$APIErrorMessage"',
async ({ APIErrorMessage, expectedRenderedErrorMessage }) => {
'calls `createFlash` with "$expectedRenderedErrorMessage" when API responds with "$APIErrors"',
async ({ APIErrors, expectedRenderedErrorMessage }) => {
jest.spyOn(axios, 'get');
mock
.onGet(mockProvide.issuesFetchPath)
.replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { errors: [APIErrorMessage] });
.replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { errors: APIErrors });
createComponent();
......
......@@ -17,6 +17,7 @@ export const mockJiraIssue1 = {
status: 'Selected for Development',
labels: [
{
title: 'backend',
name: 'backend',
color: '#0052CC',
text_color: '#FFFFFF',
......@@ -25,13 +26,17 @@ export const mockJiraIssue1 = {
author: {
name: 'jhope',
web_url: 'https://gitlab-jira.atlassian.net/people/5e32f803e127810e82875bc1',
avatar_url: null,
},
assignees: [
{
name: 'Kushal Pandya',
web_url: 'https://gitlab-jira.atlassian.net/people/1920938475',
avatar_url: null,
},
],
web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31596',
gitlab_web_url: '',
references: {
relative: 'IG-31596',
},
......@@ -49,9 +54,11 @@ export const mockJiraIssue2 = {
author: {
name: 'Gabe Weaver',
web_url: 'https://gitlab-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde',
avatar_url: null,
},
assignees: [],
web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31595',
gitlab_web_url: '',
references: {
relative: 'IG-31595',
},
......@@ -69,9 +76,11 @@ export const mockJiraIssue3 = {
author: {
name: 'Gabe Weaver',
web_url: 'https://gitlab-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde',
avatar_url: null,
},
assignees: [],
web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31594',
gitlab_web_url: '',
references: {
relative: 'IG-31594',
},
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment