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 {
AvailableSortOptions,
DEFAULT_PAGE_SIZE,
} from '~/issuable_list/constants';
import { ISSUES_LIST_FETCH_ERROR } from '../constants';
import getJiraIssuesQuery from '../graphql/queries/get_jira_issues.query.graphql';
import JiraIssuesListEmptyState from './jira_issues_list_empty_state.vue';
......@@ -48,8 +49,6 @@ export default {
return {
jiraLogo,
issues: [],
issuesListLoading: false,
issuesListLoadFailed: false,
totalIssues: 0,
currentState: this.initialState,
filterParams: this.initialFilterParams,
......@@ -63,67 +62,61 @@ export default {
};
},
computed: {
issuesListLoading() {
return this.$apollo.queries.jiraIssues.loading;
},
showPaginationControls() {
return Boolean(
!this.issuesListLoading &&
!this.issuesListLoadFailed &&
this.issues.length &&
this.totalIssues > 1,
);
return Boolean(!this.issuesListLoading && this.issues.length && this.totalIssues > 1);
},
hasFiltersApplied() {
return Boolean(this.filterParams.search || this.filterParams.labels);
},
urlParams() {
return {
state: this.currentState,
page: this.currentPage,
sort: this.sortedBy,
'labels[]': this.filterParams.labels,
page: this.currentPage,
search: this.filterParams.search,
sort: this.sortedBy,
state: this.currentState,
};
},
},
mounted() {
this.fetchIssues();
},
methods: {
async fetchIssues() {
this.issuesListLoading = true;
this.issuesListLoadFailed = false;
try {
const { data } = await this.$apollo.query({
apollo: {
jiraIssues: {
query: getJiraIssuesQuery,
variables: {
variables() {
return {
issuesFetchPath: this.issuesFetchPath,
search: this.filterParams.search,
state: this.currentState,
sort: this.sortedBy,
labels: this.filterParams.labels,
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 ?? {};
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.totalIssues = pageInfo.total;
this.issues = nodes;
this.issuesCount[this.currentState] = this.issues.length;
} catch (error) {
this.issuesListLoadFailed = true;
createFlash({
message: error.message,
captureError: true,
error,
});
}
this.issuesListLoading = false;
this.issuesCount[this.currentState] = nodes.length;
},
error() {
this.onJiraIssuesQueryError(new Error(ISSUES_LIST_FETCH_ERROR));
},
},
},
methods: {
getFilteredSearchValue() {
return [
{
......@@ -134,11 +127,23 @@ export default {
},
];
},
fetchIssuesBy(propsName, propValue) {
this[propsName] = propValue;
this.fetchIssues();
onJiraIssuesQueryError(error) {
createFlash({
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 plainText = [];
......@@ -153,7 +158,6 @@ export default {
}
this.filterParams = filterParams;
this.fetchIssues();
},
},
};
......@@ -180,10 +184,10 @@ export default {
:url-params="urlParams"
label-filter-param="labels"
recent-searches-storage-key="jira_issues"
@click-tab="fetchIssuesBy('currentState', $event)"
@page-change="fetchIssuesBy('currentPage', $event)"
@sort="fetchIssuesBy('sortedBy', $event)"
@filter="handleFilterIssues"
@click-tab="onIssuableListClickTab"
@page-change="onIssuableListPageChange"
@sort="onIssuableListSort"
@filter="onIssuableListFilter"
>
<template #nav-actions>
<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 axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { ISSUES_LIST_FETCH_ERROR } from '../../constants';
const transformJiraIssueAssignees = (jiraIssue) => {
return jiraIssue.assignees.map((assignee) => ({
......@@ -78,7 +78,7 @@ export default function jiraIssuesResolver(
.catch((error) => {
return {
__typename: 'JiraIssues',
errors: error?.response?.data?.errors || [__('An error occurred while loading issues')],
errors: error?.response?.data?.errors || [ISSUES_LIST_FETCH_ERROR],
pageInfo: transformJiraIssuePageInfo(),
nodes: [],
};
......
// 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 {
"currentPage": 1,
"currentTab": "opened",
......@@ -12,14 +12,94 @@ Object {
Object {
"type": "filtered-search-term",
"value": Object {
"data": "foo",
"data": "",
},
},
],
"initialSortBy": "created_desc",
"isManualOrdering": false,
"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,
"labelFilterParam": "labels",
"namespace": "gitlab-org/gitlab-test",
......@@ -29,7 +109,7 @@ Object {
"searchInputPlaceholder": "Search Jira issues",
"searchTokens": Array [],
"showBulkEditSidebar": false,
"showPaginationControls": false,
"showPaginationControls": true,
"sortOptions": Array [
Object {
"id": 1,
......@@ -69,11 +149,11 @@ Object {
"titleTooltip": "Show all issues.",
},
],
"totalItems": 0,
"totalItems": 3,
"urlParams": Object {
"labels[]": undefined,
"page": 1,
"search": "foo",
"search": undefined,
"sort": "created_desc",
"state": "opened",
},
......
......@@ -3,6 +3,7 @@ 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 { ISSUES_LIST_FETCH_ERROR } from 'ee/integrations/jira/issues_list/constants';
import jiraIssues from 'ee/integrations/jira/issues_list/graphql/resolvers/jira_issues';
import createMockApollo from 'helpers/mock_apollo_helper';
......@@ -11,7 +12,6 @@ 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';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import httpStatus from '~/lib/utils/http_status';
import { mockProvide, mockJiraIssues } from '../mock_data';
......@@ -27,7 +27,7 @@ jest.mock('~/issuable_list/constants', () => ({
const resolvedValue = {
headers: {
'x-page': 1,
'x-total': 3,
'x-total': mockJiraIssues.length,
},
data: mockJiraIssues,
};
......@@ -40,25 +40,29 @@ const resolvers = {
},
};
function createMockApolloProvider() {
function createMockApolloProvider(mockResolvers = resolvers) {
localVue.use(VueApollo);
return createMockApollo([], resolvers);
return createMockApollo([], mockResolvers);
}
describe('JiraIssuesListRoot', () => {
let wrapper;
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, {
propsData: {
initialFilterParams,
},
provide,
localVue,
apolloProvider: createMockApolloProvider(),
apolloProvider,
});
};
......@@ -71,13 +75,11 @@ describe('JiraIssuesListRoot', () => {
mock.restore();
});
describe('on mount', () => {
describe('while loading', () => {
it('sets issuesListLoading to `true`', async () => {
jest.spyOn(axios, 'get').mockResolvedValue(new Promise(() => {}));
createComponent();
await wrapper.vm.$nextTick();
const issuableList = findIssuableList();
......@@ -86,8 +88,8 @@ describe('JiraIssuesListRoot', () => {
it('calls `axios.get` with `issuesFetchPath` and query params', async () => {
jest.spyOn(axios, 'get');
createComponent();
createComponent();
await waitForPromises();
expect(axios.get).toHaveBeenCalledWith(
......@@ -106,80 +108,41 @@ describe('JiraIssuesListRoot', () => {
});
});
describe('when request succeeds', () => {
describe('with `initialFilterParams` prop', () => {
const mockSearchTerm = 'foo';
beforeEach(async () => {
jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue);
createComponent();
createComponent({ initialFilterParams: { search: mockSearchTerm } });
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 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`', () => {
const issuableList = findIssuableList();
expect(issuableList.props('issuablesLoading')).toBe(false);
expect(issuableList.props('initialFilterValue')).toEqual([
{ type: 'filtered-search-term', value: { data: mockSearchTerm } },
]);
expect(issuableList.props('urlParams').search).toBe(mockSearchTerm);
});
});
describe('when request fails', () => {
it.each`
APIErrors | expectedRenderedErrorMessage
${['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 });
describe('when request succeeds', () => {
beforeEach(async () => {
jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue);
createComponent();
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();
expect(issuableList.exists()).toBe(true);
expect(issuableList.props()).toMatchSnapshot();
});
describe('issuable-list events', () => {
beforeEach(async () => {
jest.spyOn(axios, 'get');
createComponent();
await waitForPromises();
});
it('"click-tab" event executes GET request correctly', async () => {
const issuableList = findIssuableList();
......@@ -203,6 +166,10 @@ describe('JiraIssuesListRoot', () => {
it('"page-change" event executes GET request correctly', async () => {
const mockPage = 2;
const issuableList = findIssuableList();
jest.spyOn(axios, 'get').mockResolvedValue({
...resolvedValue,
headers: { 'x-page': mockPage, 'x-total': mockJiraIssues.length },
});
issuableList.vm.$emit('page-change', mockPage);
await waitForPromises();
......@@ -218,6 +185,8 @@ describe('JiraIssuesListRoot', () => {
with_labels_details: true,
},
});
await wrapper.vm.$nextTick();
expect(issuableList.props()).toMatchObject({
currentPage: mockPage,
previousPage: mockPage - 1,
......@@ -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', () => {
it.each`
......@@ -291,12 +307,11 @@ describe('JiraIssuesListRoot', () => {
issues,
{
'x-page': 1,
'x-total': 3,
'x-total': issues.length,
},
);
createComponent();
await waitForPromises();
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