Commit 581f7a4b authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '329074-use-smart-query-for-jira-issues-list' into 'master'

Use smart query for jira issues list

See merge request gitlab-org/gitlab!63762
parents cce7f49a fcf0adaf
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
AvailableSortOptions, AvailableSortOptions,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
} from '~/issuable_list/constants'; } from '~/issuable_list/constants';
import { ISSUES_LIST_FETCH_ERROR } from '../constants';
import getJiraIssuesQuery from '../graphql/queries/get_jira_issues.query.graphql'; import getJiraIssuesQuery from '../graphql/queries/get_jira_issues.query.graphql';
import JiraIssuesListEmptyState from './jira_issues_list_empty_state.vue'; import JiraIssuesListEmptyState from './jira_issues_list_empty_state.vue';
...@@ -48,8 +49,6 @@ export default { ...@@ -48,8 +49,6 @@ export default {
return { return {
jiraLogo, jiraLogo,
issues: [], issues: [],
issuesListLoading: false,
issuesListLoadFailed: false,
totalIssues: 0, totalIssues: 0,
currentState: this.initialState, currentState: this.initialState,
filterParams: this.initialFilterParams, filterParams: this.initialFilterParams,
...@@ -63,67 +62,61 @@ export default { ...@@ -63,67 +62,61 @@ export default {
}; };
}, },
computed: { computed: {
issuesListLoading() {
return this.$apollo.queries.jiraIssues.loading;
},
showPaginationControls() { showPaginationControls() {
return Boolean( return Boolean(!this.issuesListLoading && this.issues.length && this.totalIssues > 1);
!this.issuesListLoading &&
!this.issuesListLoadFailed &&
this.issues.length &&
this.totalIssues > 1,
);
}, },
hasFiltersApplied() { hasFiltersApplied() {
return Boolean(this.filterParams.search || this.filterParams.labels); return Boolean(this.filterParams.search || this.filterParams.labels);
}, },
urlParams() { urlParams() {
return { return {
state: this.currentState,
page: this.currentPage,
sort: this.sortedBy,
'labels[]': this.filterParams.labels, 'labels[]': this.filterParams.labels,
page: this.currentPage,
search: this.filterParams.search, search: this.filterParams.search,
sort: this.sortedBy,
state: this.currentState,
}; };
}, },
}, },
mounted() { apollo: {
this.fetchIssues(); jiraIssues: {
},
methods: {
async fetchIssues() {
this.issuesListLoading = true;
this.issuesListLoadFailed = false;
try {
const { data } = await this.$apollo.query({
query: getJiraIssuesQuery, query: getJiraIssuesQuery,
variables: { variables() {
return {
issuesFetchPath: this.issuesFetchPath, issuesFetchPath: this.issuesFetchPath,
search: this.filterParams.search,
state: this.currentState,
sort: this.sortedBy,
labels: this.filterParams.labels, labels: this.filterParams.labels,
page: this.currentPage, page: this.currentPage,
search: this.filterParams.search,
sort: this.sortedBy,
state: this.currentState,
};
}, },
}); result({ data, error }) {
// let error() callback handle errors
if (error) {
return;
}
const { pageInfo, nodes, errors } = data?.jiraIssues ?? {}; const { pageInfo, nodes, errors } = data?.jiraIssues ?? {};
if (errors?.length > 0) throw new Error(errors[0]); if (errors?.length > 0) {
this.onJiraIssuesQueryError(new Error(errors[0]));
return;
}
this.issues = nodes;
this.currentPage = pageInfo.page; this.currentPage = pageInfo.page;
this.totalIssues = pageInfo.total; this.totalIssues = pageInfo.total;
this.issues = nodes; this.issuesCount[this.currentState] = nodes.length;
this.issuesCount[this.currentState] = this.issues.length; },
} catch (error) { error() {
this.issuesListLoadFailed = true; this.onJiraIssuesQueryError(new Error(ISSUES_LIST_FETCH_ERROR));
},
createFlash({
message: error.message,
captureError: true,
error,
});
}
this.issuesListLoading = false;
}, },
},
methods: {
getFilteredSearchValue() { getFilteredSearchValue() {
return [ return [
{ {
...@@ -134,11 +127,23 @@ export default { ...@@ -134,11 +127,23 @@ export default {
}, },
]; ];
}, },
fetchIssuesBy(propsName, propValue) { onJiraIssuesQueryError(error) {
this[propsName] = propValue; createFlash({
this.fetchIssues(); message: error.message,
captureError: true,
error,
});
},
onIssuableListClickTab(selectedIssueState) {
this.currentState = selectedIssueState;
},
onIssuableListPageChange(selectedPage) {
this.currentPage = selectedPage;
},
onIssuableListSort(selectedSort) {
this.sortedBy = selectedSort;
}, },
handleFilterIssues(filters = []) { onIssuableListFilter(filters = []) {
const filterParams = {}; const filterParams = {};
const plainText = []; const plainText = [];
...@@ -153,7 +158,6 @@ export default { ...@@ -153,7 +158,6 @@ export default {
} }
this.filterParams = filterParams; this.filterParams = filterParams;
this.fetchIssues();
}, },
}, },
}; };
...@@ -180,10 +184,10 @@ export default { ...@@ -180,10 +184,10 @@ export default {
:url-params="urlParams" :url-params="urlParams"
label-filter-param="labels" label-filter-param="labels"
recent-searches-storage-key="jira_issues" recent-searches-storage-key="jira_issues"
@click-tab="fetchIssuesBy('currentState', $event)" @click-tab="onIssuableListClickTab"
@page-change="fetchIssuesBy('currentPage', $event)" @page-change="onIssuableListPageChange"
@sort="fetchIssuesBy('sortedBy', $event)" @sort="onIssuableListSort"
@filter="handleFilterIssues" @filter="onIssuableListFilter"
> >
<template #nav-actions> <template #nav-actions>
<gl-button :href="issueCreateUrl" target="_blank" class="gl-my-5"> <gl-button :href="issueCreateUrl" target="_blank" class="gl-my-5">
......
import { __ } from '~/locale';
export const ISSUES_LIST_FETCH_ERROR = __('An error occurred while loading issues');
import { DEFAULT_PAGE_SIZE } from '~/issuable_list/constants'; import { DEFAULT_PAGE_SIZE } from '~/issuable_list/constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale'; import { ISSUES_LIST_FETCH_ERROR } from '../../constants';
const transformJiraIssueAssignees = (jiraIssue) => { const transformJiraIssueAssignees = (jiraIssue) => {
return jiraIssue.assignees.map((assignee) => ({ return jiraIssue.assignees.map((assignee) => ({
...@@ -78,7 +78,7 @@ export default function jiraIssuesResolver( ...@@ -78,7 +78,7 @@ export default function jiraIssuesResolver(
.catch((error) => { .catch((error) => {
return { return {
__typename: 'JiraIssues', __typename: 'JiraIssues',
errors: error?.response?.data?.errors || [__('An error occurred while loading issues')], errors: error?.response?.data?.errors || [ISSUES_LIST_FETCH_ERROR],
pageInfo: transformJiraIssuePageInfo(), pageInfo: transformJiraIssuePageInfo(),
nodes: [], nodes: [],
}; };
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JiraIssuesListRoot renders issuable-list component with correct props 1`] = ` exports[`JiraIssuesListRoot when request succeeds renders issuable-list component with correct props 1`] = `
Object { Object {
"currentPage": 1, "currentPage": 1,
"currentTab": "opened", "currentTab": "opened",
...@@ -12,14 +12,94 @@ Object { ...@@ -12,14 +12,94 @@ Object {
Object { Object {
"type": "filtered-search-term", "type": "filtered-search-term",
"value": Object { "value": Object {
"data": "foo", "data": "",
}, },
}, },
], ],
"initialSortBy": "created_desc", "initialSortBy": "created_desc",
"isManualOrdering": false, "isManualOrdering": false,
"issuableSymbol": "#", "issuableSymbol": "#",
"issuables": Array [], "issuables": Array [
Object {
"assignees": Array [
Object {
"avatarUrl": null,
"name": "Kushal Pandya",
"webUrl": "https://gitlab-jira.atlassian.net/people/1920938475",
},
],
"author": Object {
"avatarUrl": null,
"name": "jhope",
"webUrl": "https://gitlab-jira.atlassian.net/people/5e32f803e127810e82875bc1",
},
"closedAt": null,
"createdAt": "2020-03-19T14:31:51.281Z",
"externalTracker": "jira",
"gitlabWebUrl": "",
"id": 31596,
"labels": Array [
Object {
"color": "#0052CC",
"name": "backend",
"textColor": "#FFFFFF",
"title": "backend",
},
],
"projectId": 1,
"references": Object {
"relative": "IG-31596",
},
"status": "Selected for Development",
"title": "Eius fuga voluptates.",
"updatedAt": "2020-10-20T07:01:45.865Z",
"webUrl": "https://gitlab-jira.atlassian.net/browse/IG-31596",
},
Object {
"assignees": Array [],
"author": Object {
"avatarUrl": null,
"name": "Gabe Weaver",
"webUrl": "https://gitlab-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde",
},
"closedAt": null,
"createdAt": "2020-03-19T14:31:50.677Z",
"externalTracker": "jira",
"gitlabWebUrl": "",
"id": 31595,
"labels": Array [],
"projectId": 1,
"references": Object {
"relative": "IG-31595",
},
"status": "Backlog",
"title": "Hic sit sint ducimus ea et sint.",
"updatedAt": "2020-03-19T14:31:50.677Z",
"webUrl": "https://gitlab-jira.atlassian.net/browse/IG-31595",
},
Object {
"assignees": Array [],
"author": Object {
"avatarUrl": null,
"name": "Gabe Weaver",
"webUrl": "https://gitlab-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde",
},
"closedAt": null,
"createdAt": "2020-03-19T14:31:50.012Z",
"externalTracker": "jira",
"gitlabWebUrl": "",
"id": 31594,
"labels": Array [],
"projectId": 1,
"references": Object {
"relative": "IG-31594",
},
"status": "Backlog",
"title": "Alias ut modi est labore.",
"updatedAt": "2020-03-19T14:31:50.012Z",
"webUrl": "https://gitlab-jira.atlassian.net/browse/IG-31594",
},
],
"issuablesLoading": false, "issuablesLoading": false,
"labelFilterParam": "labels", "labelFilterParam": "labels",
"namespace": "gitlab-org/gitlab-test", "namespace": "gitlab-org/gitlab-test",
...@@ -29,7 +109,7 @@ Object { ...@@ -29,7 +109,7 @@ Object {
"searchInputPlaceholder": "Search Jira issues", "searchInputPlaceholder": "Search Jira issues",
"searchTokens": Array [], "searchTokens": Array [],
"showBulkEditSidebar": false, "showBulkEditSidebar": false,
"showPaginationControls": false, "showPaginationControls": true,
"sortOptions": Array [ "sortOptions": Array [
Object { Object {
"id": 1, "id": 1,
...@@ -69,11 +149,11 @@ Object { ...@@ -69,11 +149,11 @@ Object {
"titleTooltip": "Show all issues.", "titleTooltip": "Show all issues.",
}, },
], ],
"totalItems": 0, "totalItems": 3,
"urlParams": Object { "urlParams": Object {
"labels[]": undefined, "labels[]": undefined,
"page": 1, "page": 1,
"search": "foo", "search": undefined,
"sort": "created_desc", "sort": "created_desc",
"state": "opened", "state": "opened",
}, },
......
...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import VueApollo from 'vue-apollo'; 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 { ISSUES_LIST_FETCH_ERROR } from 'ee/integrations/jira/issues_list/constants';
import jiraIssues from 'ee/integrations/jira/issues_list/graphql/resolvers/jira_issues'; import jiraIssues from 'ee/integrations/jira/issues_list/graphql/resolvers/jira_issues';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
...@@ -11,7 +12,6 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -11,7 +12,6 @@ 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';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { mockProvide, mockJiraIssues } from '../mock_data'; import { mockProvide, mockJiraIssues } from '../mock_data';
...@@ -27,7 +27,7 @@ jest.mock('~/issuable_list/constants', () => ({ ...@@ -27,7 +27,7 @@ jest.mock('~/issuable_list/constants', () => ({
const resolvedValue = { const resolvedValue = {
headers: { headers: {
'x-page': 1, 'x-page': 1,
'x-total': 3, 'x-total': mockJiraIssues.length,
}, },
data: mockJiraIssues, data: mockJiraIssues,
}; };
...@@ -40,25 +40,29 @@ const resolvers = { ...@@ -40,25 +40,29 @@ const resolvers = {
}, },
}; };
function createMockApolloProvider() { function createMockApolloProvider(mockResolvers = resolvers) {
localVue.use(VueApollo); localVue.use(VueApollo);
return createMockApollo([], resolvers); return createMockApollo([], mockResolvers);
} }
describe('JiraIssuesListRoot', () => { describe('JiraIssuesListRoot', () => {
let wrapper; let wrapper;
let mock; let mock;
const findIssuableList = () => wrapper.find(IssuableList); const findIssuableList = () => wrapper.findComponent(IssuableList);
const createComponent = ({ provide = mockProvide, initialFilterParams = {} } = {}) => { const createComponent = ({
apolloProvider = createMockApolloProvider(),
provide = mockProvide,
initialFilterParams = {},
} = {}) => {
wrapper = shallowMount(JiraIssuesListRoot, { wrapper = shallowMount(JiraIssuesListRoot, {
propsData: { propsData: {
initialFilterParams, initialFilterParams,
}, },
provide, provide,
localVue, localVue,
apolloProvider: createMockApolloProvider(), apolloProvider,
}); });
}; };
...@@ -71,13 +75,11 @@ describe('JiraIssuesListRoot', () => { ...@@ -71,13 +75,11 @@ describe('JiraIssuesListRoot', () => {
mock.restore(); mock.restore();
}); });
describe('on mount', () => {
describe('while loading', () => { describe('while loading', () => {
it('sets issuesListLoading to `true`', async () => { it('sets issuesListLoading to `true`', async () => {
jest.spyOn(axios, 'get').mockResolvedValue(new Promise(() => {})); jest.spyOn(axios, 'get').mockResolvedValue(new Promise(() => {}));
createComponent(); createComponent();
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const issuableList = findIssuableList(); const issuableList = findIssuableList();
...@@ -86,8 +88,8 @@ describe('JiraIssuesListRoot', () => { ...@@ -86,8 +88,8 @@ describe('JiraIssuesListRoot', () => {
it('calls `axios.get` with `issuesFetchPath` and query params', async () => { it('calls `axios.get` with `issuesFetchPath` and query params', async () => {
jest.spyOn(axios, 'get'); jest.spyOn(axios, 'get');
createComponent();
createComponent();
await waitForPromises(); await waitForPromises();
expect(axios.get).toHaveBeenCalledWith( expect(axios.get).toHaveBeenCalledWith(
...@@ -106,80 +108,41 @@ describe('JiraIssuesListRoot', () => { ...@@ -106,80 +108,41 @@ describe('JiraIssuesListRoot', () => {
}); });
}); });
describe('when request succeeds', () => { describe('with `initialFilterParams` prop', () => {
const mockSearchTerm = 'foo';
beforeEach(async () => { beforeEach(async () => {
jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue); jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue);
createComponent(); createComponent({ initialFilterParams: { search: mockSearchTerm } });
await waitForPromises(); await waitForPromises();
}); });
it('sets `currentPage` and `totalIssues` from response headers and `issues` & `issuesCount` from response body when request is successful', async () => { it('renders issuable-list component with correct props', () => {
const issuableList = findIssuableList(); const issuableList = findIssuableList();
const issuablesProp = issuableList.props('issuables');
expect(issuableList.props()).toMatchObject({
currentPage: resolvedValue.headers['x-page'],
previousPage: resolvedValue.headers['x-page'] - 1,
nextPage: resolvedValue.headers['x-page'] + 1,
totalItems: resolvedValue.headers['x-total'],
});
expect(issuablesProp).toMatchObject(
convertObjectPropsToCamelCase(mockJiraIssues, { deep: true }),
);
});
it('sets issuesListLoading to `false`', () => { expect(issuableList.props('initialFilterValue')).toEqual([
const issuableList = findIssuableList(); { type: 'filtered-search-term', value: { data: mockSearchTerm } },
expect(issuableList.props('issuablesLoading')).toBe(false); ]);
expect(issuableList.props('urlParams').search).toBe(mockSearchTerm);
}); });
}); });
describe('when request fails', () => { describe('when request succeeds', () => {
it.each` beforeEach(async () => {
APIErrors | expectedRenderedErrorMessage jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue);
${['API error']} | ${'API error'}
${undefined} | ${'An error occurred while loading issues'}
`(
'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: APIErrors });
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: expectedRenderedErrorMessage,
captureError: true,
error: expect.any(Object),
}); });
},
);
});
});
it('renders issuable-list component with correct props', async () => {
createComponent({ initialFilterParams: { search: 'foo' } });
await waitForPromises();
it('renders issuable-list component with correct props', () => {
const issuableList = findIssuableList(); const issuableList = findIssuableList();
expect(issuableList.exists()).toBe(true); expect(issuableList.exists()).toBe(true);
expect(issuableList.props()).toMatchSnapshot(); expect(issuableList.props()).toMatchSnapshot();
}); });
describe('issuable-list events', () => { describe('issuable-list events', () => {
beforeEach(async () => {
jest.spyOn(axios, 'get');
createComponent();
await waitForPromises();
});
it('"click-tab" event executes GET request correctly', async () => { it('"click-tab" event executes GET request correctly', async () => {
const issuableList = findIssuableList(); const issuableList = findIssuableList();
...@@ -203,6 +166,10 @@ describe('JiraIssuesListRoot', () => { ...@@ -203,6 +166,10 @@ describe('JiraIssuesListRoot', () => {
it('"page-change" event executes GET request correctly', async () => { it('"page-change" event executes GET request correctly', async () => {
const mockPage = 2; const mockPage = 2;
const issuableList = findIssuableList(); const issuableList = findIssuableList();
jest.spyOn(axios, 'get').mockResolvedValue({
...resolvedValue,
headers: { 'x-page': mockPage, 'x-total': mockJiraIssues.length },
});
issuableList.vm.$emit('page-change', mockPage); issuableList.vm.$emit('page-change', mockPage);
await waitForPromises(); await waitForPromises();
...@@ -218,6 +185,8 @@ describe('JiraIssuesListRoot', () => { ...@@ -218,6 +185,8 @@ describe('JiraIssuesListRoot', () => {
with_labels_details: true, with_labels_details: true,
}, },
}); });
await wrapper.vm.$nextTick();
expect(issuableList.props()).toMatchObject({ expect(issuableList.props()).toMatchObject({
currentPage: mockPage, currentPage: mockPage,
previousPage: mockPage - 1, previousPage: mockPage - 1,
...@@ -273,6 +242,53 @@ describe('JiraIssuesListRoot', () => { ...@@ -273,6 +242,53 @@ describe('JiraIssuesListRoot', () => {
}); });
}); });
}); });
});
describe('error handling', () => {
describe('when request fails', () => {
it.each`
APIErrors | expectedRenderedErrorMessage
${['API error']} | ${'API error'}
${undefined} | ${ISSUES_LIST_FETCH_ERROR}
`(
'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: APIErrors });
createComponent();
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: expectedRenderedErrorMessage,
captureError: true,
error: expect.any(Object),
});
},
);
});
describe('when GraphQL network error is encountered', () => {
it('calls `createFlash` correctly with default error message', async () => {
createComponent({
apolloProvider: createMockApolloProvider({
Query: {
jiraIssues: jest.fn().mockRejectedValue(new Error('GraphQL networkError')),
},
}),
});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: ISSUES_LIST_FETCH_ERROR,
captureError: true,
error: expect.any(Object),
});
});
});
});
describe('pagination', () => { describe('pagination', () => {
it.each` it.each`
...@@ -291,12 +307,11 @@ describe('JiraIssuesListRoot', () => { ...@@ -291,12 +307,11 @@ describe('JiraIssuesListRoot', () => {
issues, issues,
{ {
'x-page': 1, 'x-page': 1,
'x-total': 3, 'x-total': issues.length,
}, },
); );
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
expect(findIssuableList().props('showPaginationControls')).toBe( expect(findIssuableList().props('showPaginationControls')).toBe(
......
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