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 { ...@@ -183,8 +183,8 @@ export default {
:title="__('Confidential')" :title="__('Confidential')"
:aria-label="__('Confidential')" :aria-label="__('Confidential')"
/> />
<gl-link :href="webUrl" v-bind="issuableTitleProps" <gl-link :href="webUrl" v-bind="issuableTitleProps">
>{{ issuable.title {{ issuable.title
}}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
/></gl-link> /></gl-link>
</span> </span>
......
...@@ -62,7 +62,7 @@ export default { ...@@ -62,7 +62,7 @@ export default {
<gl-sprintf :message="emptyStateDescription" /> <gl-sprintf :message="emptyStateDescription" />
</template> </template>
<template v-if="!hasIssues" #actions> <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') }} {{ s__('Integrations|Create new issue in Jira') }}
<gl-icon name="external-link" /> <gl-icon name="external-link" />
</gl-button> </gl-button>
......
...@@ -10,10 +10,7 @@ import { ...@@ -10,10 +10,7 @@ import {
AvailableSortOptions, AvailableSortOptions,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
} from '~/issuable_list/constants'; } from '~/issuable_list/constants';
import axios from '~/lib/utils/axios_utils'; import getJiraIssuesQuery from '../graphql/queries/get_jira_issues.query.graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import JiraIssuesListEmptyState from './jira_issues_list_empty_state.vue'; import JiraIssuesListEmptyState from './jira_issues_list_empty_state.vue';
export default { export default {
...@@ -91,55 +88,41 @@ export default { ...@@ -91,55 +88,41 @@ export default {
this.fetchIssues(); this.fetchIssues();
}, },
methods: { methods: {
fetchIssues() { async fetchIssues() {
this.issuesListLoading = true; this.issuesListLoading = true;
this.issuesListLoadFailed = false; this.issuesListLoadFailed = false;
return axios
.get(this.issuesFetchPath, { try {
params: { const { data } = await this.$apollo.query({
with_labels_details: true, query: getJiraIssuesQuery,
page: this.currentPage, variables: {
per_page: this.$options.defaultPageSize, issuesFetchPath: this.issuesFetchPath,
search: this.filterParams.search,
state: this.currentState, state: this.currentState,
sort: this.sortedBy, sort: this.sortedBy,
labels: this.filterParams.labels, labels: this.filterParams.labels,
search: this.filterParams.search, page: this.currentPage,
}, },
}) });
.then((res) => {
const { headers, data } = res; const { pageInfo, nodes, errors } = data?.jiraIssues ?? {};
this.currentPage = parseInt(headers['x-page'], 10); if (errors?.length > 0) throw new Error(errors[0]);
this.totalIssues = parseInt(headers['x-total'], 10);
this.issues = data.map((rawIssue, index) => {
const issue = convertObjectPropsToCamelCase(rawIssue, { deep: true });
return { this.currentPage = pageInfo.page;
...issue, this.totalIssues = pageInfo.total;
// JIRA issues don't have ID so we extract this.issues = nodes;
// an ID equivalent from references.relative this.issuesCount[this.currentState] = this.issues.length;
id: parseInt(rawIssue.references.relative.split('-').pop(), 10), } catch (error) {
author: { this.issuesListLoadFailed = true;
...issue.author,
id: index,
},
};
});
this.issuesCount[this.currentState] = this.issues.length;
})
.catch((error) => {
this.issuesListLoadFailed = true;
const errors = error?.response?.data?.errors || [];
const errorMessage = errors[0] || __('An error occurred while loading issues');
createFlash({ createFlash({
message: errorMessage, message: error.message,
captureError: true, captureError: true,
error, error,
});
})
.finally(() => {
this.issuesListLoading = false;
}); });
}
this.issuesListLoading = false;
}, },
getFilteredSearchValue() { getFilteredSearchValue() {
return [ 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'; ...@@ -4,6 +4,7 @@ import { IssuableStates } from '~/issuable_list/constants';
import { urlParamsToObject, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { urlParamsToObject, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import JiraIssuesListApp from './components/jira_issues_list_root.vue'; import JiraIssuesListApp from './components/jira_issues_list_root.vue';
import apolloProvider from './graphql';
export default function initJiraIssuesList({ mountPointSelector }) { export default function initJiraIssuesList({ mountPointSelector }) {
const mountPointEl = document.querySelector(mountPointSelector); const mountPointEl = document.querySelector(mountPointSelector);
...@@ -32,6 +33,7 @@ export default function initJiraIssuesList({ mountPointSelector }) { ...@@ -32,6 +33,7 @@ export default function initJiraIssuesList({ mountPointSelector }) {
initialState, initialState,
initialSortBy, initialSortBy,
}, },
apolloProvider,
render: (createElement) => render: (createElement) =>
createElement(JiraIssuesListApp, { createElement(JiraIssuesListApp, {
props: { props: {
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; 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 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 waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -27,6 +32,19 @@ const resolvedValue = { ...@@ -27,6 +32,19 @@ const resolvedValue = {
data: mockJiraIssues, data: mockJiraIssues,
}; };
const localVue = createLocalVue();
const resolvers = {
Query: {
jiraIssues,
},
};
function createMockApolloProvider() {
localVue.use(VueApollo);
return createMockApollo([], resolvers);
}
describe('JiraIssuesListRoot', () => { describe('JiraIssuesListRoot', () => {
let wrapper; let wrapper;
let mock; let mock;
...@@ -39,6 +57,8 @@ describe('JiraIssuesListRoot', () => { ...@@ -39,6 +57,8 @@ describe('JiraIssuesListRoot', () => {
initialFilterParams, initialFilterParams,
}, },
provide, provide,
localVue,
apolloProvider: createMockApolloProvider(),
}); });
}; };
...@@ -64,10 +84,12 @@ describe('JiraIssuesListRoot', () => { ...@@ -64,10 +84,12 @@ describe('JiraIssuesListRoot', () => {
expect(issuableList.props('issuablesLoading')).toBe(true); 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'); jest.spyOn(axios, 'get');
createComponent(); createComponent();
await waitForPromises();
expect(axios.get).toHaveBeenCalledWith( expect(axios.get).toHaveBeenCalledWith(
mockProvide.issuesFetchPath, mockProvide.issuesFetchPath,
expect.objectContaining({ expect.objectContaining({
...@@ -102,17 +124,10 @@ describe('JiraIssuesListRoot', () => { ...@@ -102,17 +124,10 @@ describe('JiraIssuesListRoot', () => {
nextPage: resolvedValue.headers['x-page'] + 1, nextPage: resolvedValue.headers['x-page'] + 1,
totalItems: resolvedValue.headers['x-total'], totalItems: resolvedValue.headers['x-total'],
}); });
expect(issuablesProp).toHaveLength(mockJiraIssues.length);
expect(issuablesProp).toMatchObject(
const firstIssue = convertObjectPropsToCamelCase(mockJiraIssues[0], { deep: true }); convertObjectPropsToCamelCase(mockJiraIssues, { deep: true }),
expect(issuablesProp[0]).toEqual({ );
...firstIssue,
id: 31596,
author: {
...firstIssue.author,
id: 0,
},
});
}); });
it('sets issuesListLoading to `false`', () => { it('sets issuesListLoading to `false`', () => {
...@@ -123,16 +138,16 @@ describe('JiraIssuesListRoot', () => { ...@@ -123,16 +138,16 @@ describe('JiraIssuesListRoot', () => {
describe('when request fails', () => { describe('when request fails', () => {
it.each` it.each`
APIErrorMessage | expectedRenderedErrorMessage APIErrors | expectedRenderedErrorMessage
${'API error'} | ${'API error'} ${['API error']} | ${'API error'}
${undefined} | ${'An error occurred while loading issues'} ${undefined} | ${'An error occurred while loading issues'}
`( `(
'calls `createFlash` with "$expectedRenderedErrorMessage" when API responds with "$APIErrorMessage"', 'calls `createFlash` with "$expectedRenderedErrorMessage" when API responds with "$APIErrors"',
async ({ APIErrorMessage, expectedRenderedErrorMessage }) => { async ({ APIErrors, expectedRenderedErrorMessage }) => {
jest.spyOn(axios, 'get'); jest.spyOn(axios, 'get');
mock mock
.onGet(mockProvide.issuesFetchPath) .onGet(mockProvide.issuesFetchPath)
.replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { errors: [APIErrorMessage] }); .replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { errors: APIErrors });
createComponent(); createComponent();
......
...@@ -17,6 +17,7 @@ export const mockJiraIssue1 = { ...@@ -17,6 +17,7 @@ export const mockJiraIssue1 = {
status: 'Selected for Development', status: 'Selected for Development',
labels: [ labels: [
{ {
title: 'backend',
name: 'backend', name: 'backend',
color: '#0052CC', color: '#0052CC',
text_color: '#FFFFFF', text_color: '#FFFFFF',
...@@ -25,13 +26,17 @@ export const mockJiraIssue1 = { ...@@ -25,13 +26,17 @@ export const mockJiraIssue1 = {
author: { author: {
name: 'jhope', name: 'jhope',
web_url: 'https://gitlab-jira.atlassian.net/people/5e32f803e127810e82875bc1', web_url: 'https://gitlab-jira.atlassian.net/people/5e32f803e127810e82875bc1',
avatar_url: null,
}, },
assignees: [ assignees: [
{ {
name: 'Kushal Pandya', 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', web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31596',
gitlab_web_url: '',
references: { references: {
relative: 'IG-31596', relative: 'IG-31596',
}, },
...@@ -49,9 +54,11 @@ export const mockJiraIssue2 = { ...@@ -49,9 +54,11 @@ export const mockJiraIssue2 = {
author: { author: {
name: 'Gabe Weaver', name: 'Gabe Weaver',
web_url: 'https://gitlab-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde', web_url: 'https://gitlab-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde',
avatar_url: null,
}, },
assignees: [], assignees: [],
web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31595', web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31595',
gitlab_web_url: '',
references: { references: {
relative: 'IG-31595', relative: 'IG-31595',
}, },
...@@ -69,9 +76,11 @@ export const mockJiraIssue3 = { ...@@ -69,9 +76,11 @@ export const mockJiraIssue3 = {
author: { author: {
name: 'Gabe Weaver', name: 'Gabe Weaver',
web_url: 'https://gitlab-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde', web_url: 'https://gitlab-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde',
avatar_url: null,
}, },
assignees: [], assignees: [],
web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31594', web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31594',
gitlab_web_url: '',
references: { references: {
relative: 'IG-31594', 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