Commit 8c9b0947 authored by Nathan Friend's avatar Nathan Friend

Merge branch '322755-use-graphql-in-issues-refactor' into 'master'

Convert to use GraphQL in issues page refactor [RUN ALL RSPEC]

See merge request gitlab-org/gitlab!63681
parents 66265b02 154dd2c2
......@@ -50,6 +50,9 @@ export default {
},
},
computed: {
issuableId() {
return getIdFromGraphQLId(this.issuable.id);
},
createdInPastDay() {
const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date());
return createdSecondsAgo < SECONDS_IN_DAY;
......@@ -61,7 +64,7 @@ export default {
return this.issuable.gitlabWebUrl || this.issuable.webUrl;
},
authorId() {
return getIdFromGraphQLId(`${this.author.id}`);
return getIdFromGraphQLId(this.author.id);
},
isIssuableUrlExternal() {
return isExternal(this.webUrl);
......@@ -70,10 +73,10 @@ export default {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
labelIdsString() {
return JSON.stringify(this.labels.map((label) => label.id));
return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id)));
},
assignees() {
return this.issuable.assignees || [];
return this.issuable.assignees?.nodes || this.issuable.assignees || [];
},
createdAt() {
return sprintf(__('created %{timeAgo}'), {
......@@ -157,7 +160,7 @@ export default {
<template>
<li
:id="`issuable_${issuable.id}`"
:id="`issuable_${issuableId}`"
class="issue gl-px-5!"
:class="{ closed: issuable.closedAt, today: createdInPastDay }"
:data-labels="labelIdsString"
......@@ -167,7 +170,7 @@ export default {
<gl-form-checkbox
class="gl-mr-0"
:checked="checked"
:data-id="issuable.id"
:data-id="issuableId"
@input="$emit('checked-input', $event)"
>
<span class="gl-sr-only">{{ issuable.title }}</span>
......
<script>
import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
......@@ -19,6 +19,7 @@ export default {
tag: 'ul',
},
components: {
GlKeysetPagination,
GlSkeletonLoading,
IssuableTabs,
FilteredSearchBar,
......@@ -140,6 +141,21 @@ export default {
required: false,
default: false,
},
useKeysetPagination: {
type: Boolean,
required: false,
default: false,
},
hasNextPage: {
type: Boolean,
required: false,
default: false,
},
hasPreviousPage: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -211,7 +227,7 @@ export default {
},
methods: {
issuableId(issuable) {
return issuable.id || issuable.iid || uniqueId();
return getIdFromGraphQLId(issuable.id) || issuable.iid || uniqueId();
},
issuableChecked(issuable) {
return this.checkedIssuables[this.issuableId(issuable)]?.checked;
......@@ -315,8 +331,16 @@ export default {
<slot v-else name="empty-state"></slot>
</template>
<div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
<gl-keyset-pagination
:has-next-page="hasNextPage"
:has-previous-page="hasPreviousPage"
@next="$emit('next-page')"
@prev="$emit('previous-page')"
/>
</div>
<gl-pagination
v-if="showPaginationControls"
v-else-if="showPaginationControls"
:per-page="defaultPageSize"
:total-items="totalItems"
:value="currentPage"
......
......@@ -42,6 +42,9 @@ export default {
}
return __('Milestone');
},
milestoneLink() {
return this.issue.milestone.webPath || this.issue.milestone.webUrl;
},
dueDate() {
return this.issue.dueDate && dateInWords(new Date(this.issue.dueDate), true);
},
......@@ -49,7 +52,7 @@ export default {
return isInPast(new Date(this.issue.dueDate));
},
timeEstimate() {
return this.issue.timeStats?.humanTimeEstimate;
return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate;
},
showHealthStatus() {
return this.hasIssuableHealthStatusFeature && this.issue.healthStatus;
......@@ -85,7 +88,7 @@ export default {
class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
data-testid="issuable-milestone"
>
<gl-link v-gl-tooltip :href="issue.milestone.webUrl" :title="milestoneDate">
<gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
<gl-icon name="clock" />
{{ issue.milestone.title }}
</gl-link>
......
......@@ -9,7 +9,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { toNumber } from 'lodash';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
......@@ -17,13 +17,12 @@ import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import {
API_PARAM,
apiSortParams,
CREATED_DESC,
i18n,
initialPageParams,
MAX_LIST_SIZE,
PAGE_SIZE,
PARAM_DUE_DATE,
PARAM_PAGE,
PARAM_SORT,
PARAM_STATE,
RELATIVE_POSITION_DESC,
......@@ -49,7 +48,8 @@ import {
getSortOptions,
} from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
import { getParameterByName } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import {
DEFAULT_NONE_ANY,
OPERATOR_IS_ONLY,
......@@ -107,9 +107,6 @@ export default {
emptyStateSvgPath: {
default: '',
},
endpoint: {
default: '',
},
exportCsvPath: {
default: '',
},
......@@ -173,15 +170,43 @@ export default {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens: getFilterTokens(window.location.search),
isLoading: false,
issues: [],
page: toNumber(getParameterByName(PARAM_PAGE)) || 1,
pageInfo: {},
pageParams: initialPageParams,
showBulkEditSidebar: false,
sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey,
state: state || IssuableStates.Opened,
totalIssues: 0,
};
},
apollo: {
issues: {
query: getIssuesQuery,
variables() {
return {
projectPath: this.projectPath,
search: this.searchQuery,
sort: this.sortKey,
state: this.state,
...this.pageParams,
...this.apiFilterParams,
};
},
update: ({ project }) => project.issues.nodes,
result({ data }) {
this.pageInfo = data.project.issues.pageInfo;
this.totalIssues = data.project.issues.count;
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error(error) {
createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error });
},
skip() {
return !this.hasProjectIssues;
},
debounce: 200,
},
},
computed: {
hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length;
......@@ -348,7 +373,6 @@ export default {
return {
due_date: this.dueDateFilter,
page: this.page,
search: this.searchQuery,
state: this.state,
...urlSortParams[this.sortKey],
......@@ -361,7 +385,6 @@ export default {
},
mounted() {
eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
this.fetchIssues();
},
beforeDestroy() {
eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
......@@ -406,54 +429,11 @@ export default {
fetchUsers(search) {
return axios.get(this.autocompleteUsersPath, { params: { search } });
},
fetchIssues() {
if (!this.hasProjectIssues) {
return undefined;
}
this.isLoading = true;
const filterParams = {
...this.apiFilterParams,
};
if (filterParams.epic_id) {
filterParams.epic_id = filterParams.epic_id.split('::&').pop();
} else if (filterParams['not[epic_id]']) {
filterParams['not[epic_id]'] = filterParams['not[epic_id]'].split('::&').pop();
}
return axios
.get(this.endpoint, {
params: {
due_date: this.dueDateFilter,
page: this.page,
per_page: PAGE_SIZE,
search: this.searchQuery,
state: this.state,
with_labels_details: true,
...apiSortParams[this.sortKey],
...filterParams,
},
})
.then(({ data, headers }) => {
this.page = Number(headers['x-page']);
this.totalIssues = Number(headers['x-total']);
this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
})
.catch(() => {
createFlash({ message: this.$options.i18n.errorFetchingIssues });
})
.finally(() => {
this.isLoading = false;
});
},
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
},
getStatus(issue) {
if (issue.closedAt && issue.movedToId) {
if (issue.closedAt && issue.moved) {
return this.$options.i18n.closedMoved;
}
if (issue.closedAt) {
......@@ -484,18 +464,26 @@ export default {
},
handleClickTab(state) {
if (this.state !== state) {
this.page = 1;
this.pageParams = initialPageParams;
}
this.state = state;
this.fetchIssues();
},
handleFilter(filter) {
this.filterTokens = filter;
this.fetchIssues();
},
handlePageChange(page) {
this.page = page;
this.fetchIssues();
handleNextPage() {
this.pageParams = {
afterCursor: this.pageInfo.endCursor,
firstPageSize: PAGE_SIZE,
};
scrollUp();
},
handlePreviousPage() {
this.pageParams = {
beforeCursor: this.pageInfo.startCursor,
lastPageSize: PAGE_SIZE,
};
scrollUp();
},
handleReorder({ newIndex, oldIndex }) {
const issueToMove = this.issues[oldIndex];
......@@ -530,9 +518,11 @@ export default {
createFlash({ message: this.$options.i18n.reorderError });
});
},
handleSort(value) {
this.sortKey = value;
this.fetchIssues();
handleSort(sortKey) {
if (this.sortKey !== sortKey) {
this.pageParams = initialPageParams;
}
this.sortKey = sortKey;
},
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
......@@ -556,18 +546,18 @@ export default {
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
:issuables-loading="isLoading"
:issuables-loading="$apollo.queries.issues.loading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
:total-items="totalIssues"
:current-page="page"
:previous-page="page - 1"
:next-page="page + 1"
:use-keyset-pagination="true"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
:url-params="urlParams"
@click-tab="handleClickTab"
@filter="handleFilter"
@page-change="handlePageChange"
@next-page="handleNextPage"
@previous-page="handlePreviousPage"
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
......@@ -646,7 +636,7 @@ export default {
</li>
<blocking-issues-count
class="gl-display-none gl-sm-display-block"
:blocking-issues-count="issuable.blockingIssuesCount"
:blocking-issues-count="issuable.blockedByCount"
:is-list-item="true"
/>
</template>
......
......@@ -101,10 +101,13 @@ export const i18n = {
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
export const PARAM_DUE_DATE = 'due_date';
export const PARAM_PAGE = 'page';
export const PARAM_SORT = 'sort';
export const PARAM_STATE = 'state';
export const initialPageParams = {
firstPageSize: PAGE_SIZE,
};
export const DUE_DATE_NONE = '0';
export const DUE_DATE_ANY = '';
export const DUE_DATE_OVERDUE = 'overdue';
......
......@@ -73,6 +73,13 @@ export function mountIssuesListApp() {
return false;
}
Vue.use(VueApollo);
const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
const apolloProvider = new VueApollo({
defaultClient,
});
const {
autocompleteAwardEmojisPath,
autocompleteUsersPath,
......@@ -83,7 +90,6 @@ export function mountIssuesListApp() {
email,
emailsHelpPagePath,
emptyStateSvgPath,
endpoint,
exportCsvPath,
groupEpicsPath,
hasBlockedIssuesFeature,
......@@ -113,16 +119,13 @@ export function mountIssuesListApp() {
return new Vue({
el,
// Currently does not use Vue Apollo, but need to provide {} for now until the
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {},
apolloProvider,
provide: {
autocompleteAwardEmojisPath,
autocompleteUsersPath,
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
endpoint,
groupEpicsPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./issue.fragment.graphql"
query getProjectIssues(
$projectPath: ID!
$search: String
$sort: IssueSort
$state: IssuableState
$assigneeId: String
$assigneeUsernames: [String!]
$authorUsername: String
$labelName: [String]
$milestoneTitle: [String]
$not: NegatedIssueFilterInput
$beforeCursor: String
$afterCursor: String
$firstPageSize: Int
$lastPageSize: Int
) {
project(fullPath: $projectPath) {
issues(
search: $search
sort: $sort
state: $state
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
not: $not
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
count
pageInfo {
...PageInfo
}
nodes {
...IssueFragment
}
}
}
}
fragment IssueFragment on Issue {
id
iid
closedAt
confidential
createdAt
downvotes
dueDate
humanTimeEstimate
moved
title
updatedAt
upvotes
userDiscussionsCount
webUrl
assignees {
nodes {
id
avatarUrl
name
username
webUrl
}
}
author {
id
avatarUrl
name
username
webUrl
}
labels {
nodes {
id
color
title
description
}
}
milestone {
id
dueDate
startDate
webPath
title
}
taskCompletionStatus {
completedCount
count
}
}
......@@ -190,7 +190,6 @@ module IssuesHelper
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: image_path('illustrations/issues.svg'),
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path,
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "~/issues_list/queries/issue.fragment.graphql"
query getProjectIssues(
$projectPath: ID!
$search: String
$sort: IssueSort
$state: IssuableState
$assigneeId: String
$assigneeUsernames: [String!]
$authorUsername: String
$labelName: [String]
$milestoneTitle: [String]
$epicId: String
$iterationId: [ID]
$iterationWildcardId: IterationWildcardId
$weight: String
$not: NegatedIssueFilterInput
$beforeCursor: String
$afterCursor: String
$firstPageSize: Int
$lastPageSize: Int
) {
project(fullPath: $projectPath) {
issues(
search: $search
sort: $sort
state: $state
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
epicId: $epicId
iterationId: $iterationId
iterationWildcardId: $iterationWildcardId
weight: $weight
not: $not
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
count
pageInfo {
...PageInfo
}
nodes {
...IssueFragment
blockedByCount
healthStatus
weight
}
}
}
}
......@@ -6,6 +6,8 @@ Object {
"currentTab": "opened",
"defaultPageSize": 2,
"enableLabelPermalinks": true,
"hasNextPage": false,
"hasPreviousPage": false,
"initialFilterValue": Array [
Object {
"type": "filtered-search-term",
......@@ -75,5 +77,6 @@ Object {
"sort": "created_desc",
"state": "opened",
},
"useKeysetPagination": false,
}
`;
import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueDraggable from 'vuedraggable';
......@@ -11,9 +11,12 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte
import { mockIssuableListProps, mockIssuables } from '../mock_data';
const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
const createComponent = ({ props = {}, data = {} } = {}) =>
shallowMount(IssuableListRoot, {
propsData: props,
propsData: {
...mockIssuableListProps,
...props,
},
data() {
return data;
},
......@@ -34,6 +37,7 @@ describe('IssuableListRoot', () => {
let wrapper;
const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findGlPagination = () => wrapper.findComponent(GlPagination);
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
......@@ -189,15 +193,15 @@ describe('IssuableListRoot', () => {
});
describe('template', () => {
beforeEach(() => {
it('renders component container element with class "issuable-list-container"', () => {
wrapper = createComponent();
});
it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
});
it('renders issuable-tabs component', () => {
wrapper = createComponent();
const tabsEl = findIssuableTabs();
expect(tabsEl.exists()).toBe(true);
......@@ -209,6 +213,8 @@ describe('IssuableListRoot', () => {
});
it('renders contents for slot "nav-actions" within issuable-tab component', () => {
wrapper = createComponent();
const buttonEl = findIssuableTabs().find('button.js-new-issuable');
expect(buttonEl.exists()).toBe(true);
......@@ -216,6 +222,8 @@ describe('IssuableListRoot', () => {
});
it('renders filtered-search-bar component', () => {
wrapper = createComponent();
const searchEl = findFilteredSearchBar();
const {
namespace,
......@@ -239,12 +247,8 @@ describe('IssuableListRoot', () => {
});
});
it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => {
wrapper.setProps({
issuablesLoading: true,
});
await wrapper.vm.$nextTick();
it('renders gl-loading-icon when `issuablesLoading` prop is true', () => {
wrapper = createComponent({ props: { issuablesLoading: true } });
expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength(
wrapper.vm.skeletonItemCount,
......@@ -252,6 +256,8 @@ describe('IssuableListRoot', () => {
});
it('renders issuable-item component for each item within `issuables` array', () => {
wrapper = createComponent();
const itemsEl = wrapper.findAllComponents(IssuableItem);
const mockIssuable = mockIssuableListProps.issuables[0];
......@@ -262,28 +268,23 @@ describe('IssuableListRoot', () => {
});
});
it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => {
wrapper.setProps({
issuables: [],
});
await wrapper.vm.$nextTick();
it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', () => {
wrapper = createComponent({ props: { issuables: [] } });
expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true);
expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state');
});
it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
wrapper.setProps({
showPaginationControls: true,
totalItems: 10,
it('renders only gl-pagination when `showPaginationControls` prop is true', () => {
wrapper = createComponent({
props: {
showPaginationControls: true,
totalItems: 10,
},
});
await wrapper.vm.$nextTick();
const paginationEl = findGlPagination();
expect(paginationEl.exists()).toBe(true);
expect(paginationEl.props()).toMatchObject({
expect(findGlKeysetPagination().exists()).toBe(false);
expect(findGlPagination().props()).toMatchObject({
perPage: 20,
value: 1,
prevPage: 0,
......@@ -292,32 +293,47 @@ describe('IssuableListRoot', () => {
align: 'center',
});
});
});
describe('events', () => {
beforeEach(() => {
it('renders only gl-keyset-pagination when `showPaginationControls` and `useKeysetPagination` props are true', () => {
wrapper = createComponent({
data: {
checkedIssuables: {
[mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
},
props: {
hasNextPage: true,
hasPreviousPage: true,
showPaginationControls: true,
useKeysetPagination: true,
},
});
expect(findGlPagination().exists()).toBe(false);
expect(findGlKeysetPagination().props()).toMatchObject({
hasNextPage: true,
hasPreviousPage: true,
});
});
});
describe('events', () => {
const data = {
checkedIssuables: {
[mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
},
};
it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
wrapper = createComponent({ data });
findIssuableTabs().vm.$emit('click');
expect(wrapper.emitted('click-tab')).toBeTruthy();
});
it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => {
it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', () => {
wrapper = createComponent({ data });
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('checked-input', true);
await wrapper.vm.$nextTick();
expect(searchEl.emitted('checked-input')).toBeTruthy();
expect(searchEl.emitted('checked-input').length).toBe(1);
......@@ -328,6 +344,8 @@ describe('IssuableListRoot', () => {
});
it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
wrapper = createComponent({ data });
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('onFilter');
......@@ -336,13 +354,13 @@ describe('IssuableListRoot', () => {
expect(wrapper.emitted('sort')).toBeTruthy();
});
it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => {
it('sets an issuable as checked when issuable-item component emits `checked-input` event', () => {
wrapper = createComponent({ data });
const issuableItem = wrapper.findAllComponents(IssuableItem).at(0);
issuableItem.vm.$emit('checked-input', true);
await wrapper.vm.$nextTick();
expect(issuableItem.emitted('checked-input')).toBeTruthy();
expect(issuableItem.emitted('checked-input').length).toBe(1);
......@@ -353,27 +371,45 @@ describe('IssuableListRoot', () => {
});
it('emits `update-legacy-bulk-edit` when filtered-search-bar checkbox is checked', () => {
wrapper = createComponent({ data });
findFilteredSearchBar().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
it('emits `update-legacy-bulk-edit` when issuable-item checkbox is checked', () => {
wrapper = createComponent({ data });
findIssuableItem().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
it('gl-pagination component emits `page-change` event on `input` event', async () => {
wrapper.setProps({
showPaginationControls: true,
});
await wrapper.vm.$nextTick();
it('gl-pagination component emits `page-change` event on `input` event', () => {
wrapper = createComponent({ data, props: { showPaginationControls: true } });
findGlPagination().vm.$emit('input');
expect(wrapper.emitted('page-change')).toBeTruthy();
});
it.each`
event | glKeysetPaginationEvent
${'next-page'} | ${'next'}
${'previous-page'} | ${'prev'}
`(
'emits `$event` event when gl-keyset-pagination emits `$glKeysetPaginationEvent` event',
({ event, glKeysetPaginationEvent }) => {
wrapper = createComponent({
data,
props: { showPaginationControls: true, useKeysetPagination: true },
});
findGlKeysetPagination().vm.$emit(glKeysetPaginationEvent);
expect(wrapper.emitted(event)).toEqual([[]]);
},
);
});
describe('manual sorting', () => {
......
......@@ -13,12 +13,10 @@ describe('IssuesListApp component', () => {
dueDate: '2020-12-17',
startDate: '2020-12-10',
title: 'My milestone',
webUrl: '/milestone/webUrl',
webPath: '/milestone/webPath',
},
dueDate: '2020-12-12',
timeStats: {
humanTimeEstimate: '1w',
},
humanTimeEstimate: '1w',
};
const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]');
......@@ -56,7 +54,7 @@ describe('IssuesListApp component', () => {
expect(milestone.text()).toBe(issue.milestone.title);
expect(milestone.find(GlIcon).props('name')).toBe('clock');
expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl);
expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webPath);
});
describe.each`
......@@ -102,7 +100,7 @@ describe('IssuesListApp component', () => {
const timeEstimate = wrapper.find('[data-testid="time-estimate"]');
expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate);
expect(timeEstimate.text()).toBe(issue.humanTimeEstimate);
expect(timeEstimate.attributes('title')).toBe('Estimate');
expect(timeEstimate.find(GlIcon).props('name')).toBe('timer');
});
......
import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { cloneDeep } from 'lodash';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data';
import {
getIssuesQueryResponse,
filteredTokens,
locationSearch,
urlParams,
} from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
......@@ -14,10 +24,7 @@ import {
apiSortParams,
CREATED_DESC,
DUE_DATE_OVERDUE,
PAGE_SIZE,
PAGE_SIZE_MANUAL,
PARAM_DUE_DATE,
RELATIVE_POSITION_DESC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
......@@ -32,20 +39,26 @@ import {
import eventHub from '~/issues_list/eventhub';
import { getSortOptions } from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
jest.mock('~/flash');
jest.mock('~/lib/utils/scroll_utils', () => ({
scrollUp: jest.fn().mockName('scrollUpMock'),
}));
describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
const localVue = createLocalVue();
localVue.use(VueApollo);
const defaultProvide = {
autocompleteUsersPath: 'autocomplete/users/path',
calendarPath: 'calendar/path',
canBulkUpdate: false,
emptyStateSvgPath: 'empty-state.svg',
endpoint: 'api/endpoint',
exportCsvPath: 'export/csv/path',
hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true,
......@@ -61,21 +74,13 @@ describe('IssuesListApp component', () => {
signInPath: 'sign/in/path',
};
const state = 'opened';
const xPage = 1;
const xTotal = 25;
const tabCounts = {
opened: xTotal,
closed: undefined,
all: undefined,
};
const fetchIssuesResponse = {
data: [],
headers: {
'x-page': xPage,
'x-total': xTotal,
},
};
let defaultQueryResponse = getIssuesQueryResponse;
if (IS_EE) {
defaultQueryResponse = cloneDeep(getIssuesQueryResponse);
defaultQueryResponse.data.project.issues.nodes[0].blockedByCount = 1;
defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null;
defaultQueryResponse.data.project.issues.nodes[0].weight = 5;
}
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
......@@ -86,19 +91,26 @@ describe('IssuesListApp component', () => {
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) =>
mountFn(IssuesListApp, {
const mountComponent = ({
provide = {},
response = defaultQueryResponse,
mountFn = shallowMount,
} = {}) => {
const requestHandlers = [[getIssuesQuery, jest.fn().mockResolvedValue(response)]];
const apolloProvider = createMockApollo(requestHandlers);
return mountFn(IssuesListApp, {
localVue,
apolloProvider,
provide: {
...defaultProvide,
...provide,
},
});
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock
.onGet(defaultProvide.endpoint)
.reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
});
afterEach(() => {
......@@ -108,28 +120,37 @@ describe('IssuesListApp component', () => {
});
describe('IssuableList', () => {
beforeEach(async () => {
beforeEach(() => {
wrapper = mountComponent();
await waitForPromises();
jest.runOnlyPendingTimers();
});
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: defaultProvide.projectPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search or filter results…',
searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder,
sortOptions: getSortOptions(true, true),
initialSortBy: CREATED_DESC,
issuables: getIssuesQueryResponse.data.project.issues.nodes,
tabs: IssuableListTabs,
currentTab: IssuableStates.Opened,
tabCounts,
showPaginationControls: false,
issuables: [],
totalItems: xTotal,
currentPage: xPage,
previousPage: xPage - 1,
nextPage: xPage + 1,
urlParams: { page: xPage, state },
tabCounts: {
opened: 1,
closed: undefined,
all: undefined,
},
issuablesLoading: false,
isManualOrdering: false,
showBulkEditSidebar: false,
showPaginationControls: true,
useKeysetPagination: true,
hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage,
hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage,
urlParams: {
state: IssuableStates.Opened,
...urlSortParams[CREATED_DESC],
},
});
});
});
......@@ -157,9 +178,9 @@ describe('IssuesListApp component', () => {
describe('csv import/export component', () => {
describe('when user is signed in', () => {
it('renders', async () => {
const search = '?page=1&search=refactor&state=opened&sort=created_date';
const search = '?search=refactor&state=opened&sort=created_date';
beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` });
wrapper = mountComponent({
......@@ -167,11 +188,13 @@ describe('IssuesListApp component', () => {
mountFn: mount,
});
await waitForPromises();
jest.runOnlyPendingTimers();
});
it('renders', () => {
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: `${defaultProvide.exportCsvPath}${search}`,
issuableCount: xTotal,
issuableCount: 1,
});
});
});
......@@ -238,18 +261,6 @@ describe('IssuesListApp component', () => {
});
});
describe('page', () => {
it('is set from the url params', () => {
const page = 5;
global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) });
wrapper = mountComponent();
expect(findIssuableList().props('currentPage')).toBe(page);
});
});
describe('search', () => {
it('is set from the url params', () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
......@@ -326,12 +337,10 @@ describe('IssuesListApp component', () => {
describe('empty states', () => {
describe('when there are issues', () => {
describe('when search returns no results', () => {
beforeEach(async () => {
beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` });
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
it('shows empty state', () => {
......@@ -344,10 +353,8 @@ describe('IssuesListApp component', () => {
});
describe('when "Open" tab has no issues', () => {
beforeEach(async () => {
beforeEach(() => {
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
it('shows empty state', () => {
......@@ -360,14 +367,12 @@ describe('IssuesListApp component', () => {
});
describe('when "Closed" tab has no issues', () => {
beforeEach(async () => {
beforeEach(() => {
global.jsdom.reconfigure({
url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST),
});
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
it('shows empty state', () => {
......@@ -555,98 +560,70 @@ describe('IssuesListApp component', () => {
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, {
'x-page': 2,
'x-total': xTotal,
});
wrapper = mountComponent();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
it('makes API call to filter the list by the new state and resets the page to 1', () => {
expect(axiosMock.history.get[1].params).toMatchObject({
page: 1,
state: IssuableStates.Closed,
});
it('updates to the new tab', () => {
expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
});
});
describe('when "page-change" event is emitted by IssuableList', () => {
const data = [{ id: 10, title: 'title', state }];
const page = 2;
const totalItems = 21;
beforeEach(async () => {
axiosMock.onGet(defaultProvide.endpoint).reply(200, data, {
'x-page': page,
'x-total': totalItems,
});
wrapper = mountComponent();
findIssuableList().vm.$emit('page-change', page);
await waitForPromises();
});
describe.each(['next-page', 'previous-page'])(
'when "%s" event is emitted by IssuableList',
(event) => {
beforeEach(() => {
wrapper = mountComponent();
it('fetches issues with expected params', () => {
expect(axiosMock.history.get[1].params).toMatchObject({
page,
per_page: PAGE_SIZE,
state,
with_labels_details: true,
findIssuableList().vm.$emit(event);
});
});
it('updates IssuableList with response data', () => {
expect(findIssuableList().props()).toMatchObject({
issuables: data,
totalItems,
currentPage: page,
previousPage: page - 1,
nextPage: page + 1,
urlParams: { page, state },
it('scrolls to the top', () => {
expect(scrollUp).toHaveBeenCalled();
});
});
});
},
);
describe('when "reorder" event is emitted by IssuableList', () => {
const issueOne = { id: 1, iid: 101, title: 'Issue one' };
const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
const issueThree = { id: 3, iid: 103, title: 'Issue three' };
const issueFour = { id: 4, iid: 104, title: 'Issue four' };
const issues = [issueOne, issueTwo, issueThree, issueFour];
beforeEach(async () => {
axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers);
wrapper = mountComponent();
await waitForPromises();
});
describe('when successful', () => {
describe.each`
description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
`(
'when moving issue $description',
({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
it('makes API call to reorder the issue', async () => {
findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
await waitForPromises();
expect(axiosMock.history.put[0]).toMatchObject({
url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`,
data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
});
});
const issueOne = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/1',
iid: 101,
title: 'Issue one',
};
const issueTwo = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/2',
iid: 102,
title: 'Issue two',
};
const issueThree = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/3',
iid: 103,
title: 'Issue three',
};
const issueFour = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/4',
iid: 104,
title: 'Issue four',
};
const response = {
data: {
project: {
issues: {
...defaultQueryResponse.data.project.issues,
nodes: [issueOne, issueTwo, issueThree, issueFour],
},
},
);
},
};
beforeEach(() => {
wrapper = mountComponent({ response });
jest.runOnlyPendingTimers();
});
describe('when unsuccessful', () => {
......@@ -664,21 +641,16 @@ describe('IssuesListApp component', () => {
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(apiSortParams))(
'fetches issues with correct params with payload `%s`',
'updates to the new sort when payload is `%s`',
async (sortKey) => {
wrapper = mountComponent();
findIssuableList().vm.$emit('sort', sortKey);
await waitForPromises();
jest.runOnlyPendingTimers();
await nextTick();
expect(axiosMock.history.get[1].params).toEqual({
page: xPage,
per_page: sortKey === RELATIVE_POSITION_DESC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
state,
with_labels_details: true,
...apiSortParams[sortKey],
});
expect(findIssuableList().props('urlParams')).toMatchObject(urlSortParams[sortKey]);
},
);
});
......@@ -687,13 +659,11 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.spyOn(eventHub, '$emit');
});
it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
findIssuableList().vm.$emit('update-legacy-bulk-edit');
});
await waitForPromises();
it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
......@@ -705,10 +675,6 @@ describe('IssuesListApp component', () => {
findIssuableList().vm.$emit('filter', filteredTokens);
});
it('makes an API call to search for issues with the search term', () => {
expect(axiosMock.history.get[1].params).toMatchObject(apiParams);
});
it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
});
......
......@@ -3,6 +3,73 @@ import {
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
export const getIssuesQueryResponse = {
data: {
project: {
issues: {
count: 1,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'startcursor',
endCursor: 'endcursor',
},
nodes: [
{
id: 'gid://gitlab/Issue/123456',
iid: '789',
closedAt: null,
confidential: false,
createdAt: '2021-05-22T04:08:01Z',
downvotes: 2,
dueDate: '2021-05-29',
humanTimeEstimate: null,
moved: false,
title: 'Issue title',
updatedAt: '2021-05-22T04:08:01Z',
upvotes: 3,
userDiscussionsCount: 4,
webUrl: 'project/-/issues/789',
assignees: {
nodes: [
{
id: 'gid://gitlab/User/234',
avatarUrl: 'avatar/url',
name: 'Marge Simpson',
username: 'msimpson',
webUrl: 'url/msimpson',
},
],
},
author: {
id: 'gid://gitlab/User/456',
avatarUrl: 'avatar/url',
name: 'Homer Simpson',
username: 'hsimpson',
webUrl: 'url/hsimpson',
},
labels: {
nodes: [
{
id: 'gid://gitlab/ProjectLabel/456',
color: '#333',
title: 'Label title',
description: 'Label description',
},
],
},
milestone: null,
taskCompletionStatus: {
completedCount: 1,
count: 2,
},
},
],
},
},
},
};
export const locationSearch = [
'?search=find+issues',
'author_username=homer',
......
......@@ -302,7 +302,6 @@ RSpec.describe IssuesHelper do
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: '#',
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#',
......
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