Commit e05cbda5 authored by Coung Ngo's avatar Coung Ngo Committed by Scott Hampton

Add sorting functionality to Vue issues list page

This MR adds sorting functionality to the Vue issues list page,
which is under the feature flag `vue_issues_list` defaulted to off.
parent 8474a0ba
......@@ -10,7 +10,14 @@ import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import IssuableItem from './issuable_item.vue';
import IssuableTabs from './issuable_tabs.vue';
const VueDraggable = () => import('vuedraggable');
export default {
vueDraggableAttributes: {
animation: 200,
ghostClass: 'gl-visibility-hidden',
tag: 'ul',
},
components: {
GlSkeletonLoading,
IssuableTabs,
......@@ -18,6 +25,7 @@ export default {
IssuableItem,
IssuableBulkEditSidebar,
GlPagination,
VueDraggable,
},
props: {
namespace: {
......@@ -127,6 +135,11 @@ export default {
required: false,
default: null,
},
isManualOrdering: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -159,6 +172,9 @@ export default {
return acc;
}, []);
},
issuablesWrapper() {
return this.isManualOrdering ? VueDraggable : 'ul';
},
},
watch: {
issuables(list) {
......@@ -208,6 +224,9 @@ export default {
this.checkedIssuables[issuableId].checked = value;
});
},
handleVueDraggableUpdate({ newIndex, oldIndex }) {
this.$emit('reorder', { newIndex, oldIndex });
},
},
};
</script>
......@@ -253,13 +272,18 @@ export default {
<gl-skeleton-loading />
</li>
</ul>
<ul
<component
:is="issuablesWrapper"
v-if="!issuablesLoading && issuables.length"
class="content-list issuable-list issues-list"
:class="{ 'manual-ordering': isManualOrdering }"
v-bind="$options.vueDraggableAttributes"
@update="handleVueDraggableUpdate"
>
<issuable-item
v-for="issuable in issuables"
:key="issuableId(issuable)"
:class="{ 'gl-cursor-grab': isManualOrdering }"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
:enable-label-permalinks="enableLabelPermalinks"
......@@ -284,7 +308,7 @@ export default {
<slot name="statistics" :issuable="issuable"></slot>
</template>
</issuable-item>
</ul>
</component>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
<gl-pagination
v-if="showPaginationControls"
......
......@@ -30,6 +30,9 @@ import issueableEventHub from '../eventhub';
import { emptyStateHelper } from '../service_desk_helper';
import Issuable from './issuable.vue';
/**
* @deprecated Use app/assets/javascripts/issuable_list/components/issuable_list_root.vue instead
*/
export default {
LOADING_LIST_ITEMS_LENGTH,
directives: {
......
......@@ -4,14 +4,26 @@ import { toNumber } from 'lodash';
import createFlash from '~/flash';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableStatus } from '~/issue_show/constants';
import { PAGE_SIZE } from '~/issues_list/constants';
import {
CREATED_DESC,
PAGE_SIZE,
RELATIVE_POSITION_ASC,
sortOptions,
sortParams,
} from '~/issues_list/constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import IssueCardTimeInfo from './issue_card_time_info.vue';
export default {
CREATED_DESC,
PAGE_SIZE,
sortOptions,
sortParams,
i18n: {
reorderError: __('An error occurred while reordering issues.'),
},
components: {
GlIcon,
IssuableList,
......@@ -28,12 +40,23 @@ export default {
fullPath: {
default: '',
},
issuesPath: {
default: '',
},
},
data() {
const orderBy = getParameterByName('order_by');
const sort = getParameterByName('sort');
const sortKey = Object.keys(sortParams).find(
(key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort,
);
return {
currentPage: toNumber(getParameterByName('page')) || 1,
filters: sortParams[sortKey] || {},
isLoading: false,
issues: [],
sortKey: sortKey || CREATED_DESC,
totalIssues: 0,
};
},
......@@ -42,8 +65,12 @@ export default {
return {
page: this.currentPage,
state: IssuableStatus.Open,
...this.filters,
};
},
isManualOrdering() {
return this.sortKey === RELATIVE_POSITION_ASC;
},
},
mounted() {
this.fetchIssues();
......@@ -59,6 +86,7 @@ export default {
per_page: this.$options.PAGE_SIZE,
state: IssuableStatus.Open,
with_labels_details: true,
...this.filters,
},
})
.then(({ data, headers }) => {
......@@ -76,6 +104,44 @@ export default {
handlePageChange(page) {
this.fetchIssues(page);
},
handleReorder({ newIndex, oldIndex }) {
const issueToMove = this.issues[oldIndex];
const isDragDropDownwards = newIndex > oldIndex;
const isMovingToBeginning = newIndex === 0;
const isMovingToEnd = newIndex === this.issues.length - 1;
let moveBeforeId;
let moveAfterId;
if (isDragDropDownwards) {
const afterIndex = isMovingToEnd ? newIndex : newIndex + 1;
moveBeforeId = this.issues[newIndex].id;
moveAfterId = this.issues[afterIndex].id;
} else {
const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1;
moveBeforeId = this.issues[beforeIndex].id;
moveAfterId = this.issues[newIndex].id;
}
return axios
.put(`${this.issuesPath}/${issueToMove.iid}/reorder`, {
move_before_id: isMovingToBeginning ? null : moveBeforeId,
move_after_id: isMovingToEnd ? null : moveAfterId,
})
.then(() => {
// Move issue to new position in list
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issueToMove);
})
.catch(() => {
createFlash({ message: this.$options.i18n.reorderError });
});
},
handleSort(value) {
this.sortKey = value;
this.filters = sortParams[value];
this.fetchIssues();
},
},
};
</script>
......@@ -86,11 +152,13 @@ export default {
recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')"
:search-tokens="[]"
:sort-options="[]"
:sort-options="$options.sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
:tabs="[]"
current-tab=""
:issuables-loading="isLoading"
:is-manual-ordering="isManualOrdering"
:show-pagination-controls="true"
:total-items="totalIssues"
:current-page="currentPage"
......@@ -98,6 +166,8 @@ export default {
:next-page="currentPage + 1"
:url-params="urlParams"
@page-change="handlePageChange"
@reorder="handleReorder"
@sort="handleSort"
>
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
......
......@@ -54,3 +54,191 @@ export const availableSortOptionsJira = [
];
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
export const CREATED_ASC = 'CREATED_ASC';
export const CREATED_DESC = 'CREATED_DESC';
export const DUE_DATE_ASC = 'DUE_DATE_ASC';
export const DUE_DATE_DESC = 'DUE_DATE_DESC';
export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC';
export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC';
export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC';
export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC';
export const POPULARITY_ASC = 'POPULARITY_ASC';
export const POPULARITY_DESC = 'POPULARITY_DESC';
export const PRIORITY_ASC = 'PRIORITY_ASC';
export const PRIORITY_DESC = 'PRIORITY_DESC';
export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC';
export const UPDATED_ASC = 'UPDATED_ASC';
export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
export const WEIGHT_DESC = 'WEIGHT_DESC';
const SORT_ASC = 'asc';
const SORT_DESC = 'desc';
const BLOCKING_ISSUES = 'blocking_issues';
export const sortParams = {
[PRIORITY_ASC]: {
order_by: PRIORITY,
sort: SORT_ASC,
},
[PRIORITY_DESC]: {
order_by: PRIORITY,
sort: SORT_DESC,
},
[CREATED_ASC]: {
order_by: CREATED_AT,
sort: SORT_ASC,
},
[CREATED_DESC]: {
order_by: CREATED_AT,
sort: SORT_DESC,
},
[UPDATED_ASC]: {
order_by: UPDATED_AT,
sort: SORT_ASC,
},
[UPDATED_DESC]: {
order_by: UPDATED_AT,
sort: SORT_DESC,
},
[MILESTONE_DUE_ASC]: {
order_by: MILESTONE_DUE,
sort: SORT_ASC,
},
[MILESTONE_DUE_DESC]: {
order_by: MILESTONE_DUE,
sort: SORT_DESC,
},
[DUE_DATE_ASC]: {
order_by: DUE_DATE,
sort: SORT_ASC,
},
[DUE_DATE_DESC]: {
order_by: DUE_DATE,
sort: SORT_DESC,
},
[POPULARITY_ASC]: {
order_by: POPULARITY,
sort: SORT_ASC,
},
[POPULARITY_DESC]: {
order_by: POPULARITY,
sort: SORT_DESC,
},
[LABEL_PRIORITY_ASC]: {
order_by: LABEL_PRIORITY,
sort: SORT_ASC,
},
[LABEL_PRIORITY_DESC]: {
order_by: LABEL_PRIORITY,
sort: SORT_DESC,
},
[RELATIVE_POSITION_ASC]: {
order_by: RELATIVE_POSITION,
per_page: 100,
sort: SORT_ASC,
},
[WEIGHT_ASC]: {
order_by: WEIGHT,
sort: SORT_ASC,
},
[WEIGHT_DESC]: {
order_by: WEIGHT,
sort: SORT_DESC,
},
[BLOCKING_ISSUES_ASC]: {
order_by: BLOCKING_ISSUES,
sort: SORT_ASC,
},
[BLOCKING_ISSUES_DESC]: {
order_by: BLOCKING_ISSUES,
sort: SORT_DESC,
},
};
export const sortOptions = [
{
id: 1,
title: __('Priority'),
sortDirection: {
ascending: PRIORITY_ASC,
descending: PRIORITY_DESC,
},
},
{
id: 2,
title: __('Created date'),
sortDirection: {
ascending: CREATED_ASC,
descending: CREATED_DESC,
},
},
{
id: 3,
title: __('Last updated'),
sortDirection: {
ascending: UPDATED_ASC,
descending: UPDATED_DESC,
},
},
{
id: 4,
title: __('Milestone due date'),
sortDirection: {
ascending: MILESTONE_DUE_ASC,
descending: MILESTONE_DUE_DESC,
},
},
{
id: 5,
title: __('Due date'),
sortDirection: {
ascending: DUE_DATE_ASC,
descending: DUE_DATE_DESC,
},
},
{
id: 6,
title: __('Popularity'),
sortDirection: {
ascending: POPULARITY_ASC,
descending: POPULARITY_DESC,
},
},
{
id: 7,
title: __('Label priority'),
sortDirection: {
ascending: LABEL_PRIORITY_ASC,
descending: LABEL_PRIORITY_DESC,
},
},
{
id: 8,
title: __('Manual'),
sortDirection: {
ascending: RELATIVE_POSITION_ASC,
descending: RELATIVE_POSITION_ASC,
},
},
{
id: 9,
title: __('Weight'),
sortDirection: {
ascending: WEIGHT_ASC,
descending: WEIGHT_DESC,
},
},
{
id: 10,
title: __('Blocking'),
sortDirection: {
ascending: BLOCKING_ISSUES_ASC,
descending: BLOCKING_ISSUES_DESC,
},
},
];
......@@ -78,6 +78,7 @@ export function initIssuesListApp() {
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
issuesPath,
} = el.dataset;
return new Vue({
......@@ -91,6 +92,7 @@ export function initIssuesListApp() {
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
issuesPath,
},
render: (createComponent) => createComponent(IssuesListApp),
});
......
......@@ -58,7 +58,7 @@ export default {
type: String,
required: false,
default: '',
validator: (value) => value === '' || /(_desc)|(_asc)/g.test(value),
validator: (value) => value === '' || /(_desc)|(_asc)/gi.test(value),
},
showCheckbox: {
type: Boolean,
......
......@@ -24,7 +24,8 @@
full_path: @project.full_path,
has_blocked_issues_feature: Gitlab.ee? && @project.feature_available?(:blocked_issues).to_s,
has_issuable_health_status_feature: Gitlab.ee? && @project.feature_available?(:issuable_health_status).to_s,
has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s } }
has_issue_weights_feature: Gitlab.ee? && @project.feature_available?(:issue_weights).to_s,
issues_path: project_issues_path(@project) } }
- else
= render 'shared/issuable/search_bar', type: :issues
......
......@@ -4856,6 +4856,9 @@ msgstr[1] ""
msgid "Blocked issue"
msgstr ""
msgid "Blocking"
msgstr ""
msgid "Blocking issues"
msgstr ""
......@@ -17730,6 +17733,9 @@ msgstr ""
msgid "Label lists show all issues with the selected label."
msgstr ""
msgid "Label priority"
msgstr ""
msgid "Label was created"
msgstr ""
......@@ -18619,6 +18625,9 @@ msgstr ""
msgid "Manifest import"
msgstr ""
msgid "Manual"
msgstr ""
msgid "ManualOrdering|Couldn't save the order of the issues"
msgstr ""
......@@ -19685,6 +19694,9 @@ msgid_plural "Milestones"
msgstr[0] ""
msgstr[1] ""
msgid "Milestone due date"
msgstr ""
msgid "Milestone lists not available with your current license"
msgstr ""
......@@ -22939,6 +22951,9 @@ msgstr ""
msgid "Policy project doesn't exists"
msgstr ""
msgid "Popularity"
msgstr ""
msgid "Postman collection"
msgstr ""
......@@ -23128,6 +23143,9 @@ msgstr ""
msgid "Prioritized label"
msgstr ""
msgid "Priority"
msgstr ""
msgid "Private"
msgstr ""
......
import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import VueDraggable from 'vuedraggable';
import { TEST_HOST } from 'helpers/test_constants';
......@@ -11,7 +12,7 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte
import { mockIssuableListProps, mockIssuables } from '../mock_data';
const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
mount(IssuableListRoot, {
shallowMount(IssuableListRoot, {
propsData: props,
data() {
return data;
......@@ -24,20 +25,28 @@ const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
<p class="js-issuable-empty-state">Issuable empty state</p>
`,
},
stubs: {
IssuableTabs,
},
});
describe('IssuableListRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
const findGlPagination = () => wrapper.findComponent(GlPagination);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
const findVueDraggable = () => wrapper.findComponent(VueDraggable);
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
beforeEach(() => {
wrapper = createComponent();
});
const mockCheckedIssuables = {
[mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
[mockIssuables[1].iid]: { checked: true, issuable: mockIssuables[1] },
......@@ -108,6 +117,10 @@ describe('IssuableListRoot', () => {
});
describe('watch', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('issuables', () => {
it('populates `checkedIssuables` prop with all issuables', async () => {
wrapper.setProps({
......@@ -147,6 +160,10 @@ describe('IssuableListRoot', () => {
});
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('issuableId', () => {
it('returns id value from provided issuable object', () => {
expect(wrapper.vm.issuableId({ id: 1 })).toBe(1);
......@@ -171,12 +188,16 @@ describe('IssuableListRoot', () => {
});
describe('template', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
});
it('renders issuable-tabs component', () => {
const tabsEl = wrapper.find(IssuableTabs);
const tabsEl = findIssuableTabs();
expect(tabsEl.exists()).toBe(true);
expect(tabsEl.props()).toMatchObject({
......@@ -187,14 +208,14 @@ describe('IssuableListRoot', () => {
});
it('renders contents for slot "nav-actions" within issuable-tab component', () => {
const buttonEl = wrapper.find(IssuableTabs).find('button.js-new-issuable');
const buttonEl = findIssuableTabs().find('button.js-new-issuable');
expect(buttonEl.exists()).toBe(true);
expect(buttonEl.text()).toBe('New issuable');
});
it('renders filtered-search-bar component', () => {
const searchEl = wrapper.find(FilteredSearchBar);
const searchEl = findFilteredSearchBar();
const {
namespace,
recentSearchesStorageKey,
......@@ -224,11 +245,13 @@ describe('IssuableListRoot', () => {
await wrapper.vm.$nextTick();
expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(wrapper.vm.skeletonItemCount);
expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength(
wrapper.vm.skeletonItemCount,
);
});
it('renders issuable-item component for each item within `issuables` array', () => {
const itemsEl = wrapper.findAll(IssuableItem);
const itemsEl = wrapper.findAllComponents(IssuableItem);
const mockIssuable = mockIssuableListProps.issuables[0];
expect(itemsEl).toHaveLength(mockIssuableListProps.issuables.length);
......@@ -257,7 +280,7 @@ describe('IssuableListRoot', () => {
await wrapper.vm.$nextTick();
const paginationEl = wrapper.find(GlPagination);
const paginationEl = findGlPagination();
expect(paginationEl.exists()).toBe(true);
expect(paginationEl.props()).toMatchObject({
perPage: 20,
......@@ -271,10 +294,8 @@ describe('IssuableListRoot', () => {
});
describe('events', () => {
let wrapperChecked;
beforeEach(() => {
wrapperChecked = createComponent({
wrapper = createComponent({
data: {
checkedIssuables: {
[mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
......@@ -283,34 +304,30 @@ describe('IssuableListRoot', () => {
});
});
afterEach(() => {
wrapperChecked.destroy();
});
it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
wrapper.find(IssuableTabs).vm.$emit('click');
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 () => {
const searchEl = wrapperChecked.find(FilteredSearchBar);
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('checked-input', true);
await wrapperChecked.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(searchEl.emitted('checked-input')).toBeTruthy();
expect(searchEl.emitted('checked-input').length).toBe(1);
expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
checked: true,
issuable: mockIssuables[0],
});
});
it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
const searchEl = wrapper.find(FilteredSearchBar);
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('onFilter');
expect(wrapper.emitted('filter')).toBeTruthy();
......@@ -319,16 +336,16 @@ describe('IssuableListRoot', () => {
});
it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => {
const issuableItem = wrapperChecked.findAll(IssuableItem).at(0);
const issuableItem = wrapper.findAllComponents(IssuableItem).at(0);
issuableItem.vm.$emit('checked-input', true);
await wrapperChecked.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(issuableItem.emitted('checked-input')).toBeTruthy();
expect(issuableItem.emitted('checked-input').length).toBe(1);
expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
checked: true,
issuable: mockIssuables[0],
});
......@@ -341,8 +358,48 @@ describe('IssuableListRoot', () => {
await wrapper.vm.$nextTick();
wrapper.find(GlPagination).vm.$emit('input');
findGlPagination().vm.$emit('input');
expect(wrapper.emitted('page-change')).toBeTruthy();
});
});
describe('manual sorting', () => {
describe('when enabled', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
...mockIssuableListProps,
isManualOrdering: true,
},
});
});
it('renders VueDraggable component', () => {
expect(findVueDraggable().exists()).toBe(true);
});
it('IssuableItem has grab cursor', () => {
expect(wrapper.findComponent(IssuableItem).classes()).toContain('gl-cursor-grab');
});
it('emits a "reorder" event when user updates the issue order', () => {
const oldIndex = 4;
const newIndex = 6;
findVueDraggable().vm.$emit('update', { oldIndex, newIndex });
expect(wrapper.emitted('reorder')).toEqual([[{ oldIndex, newIndex }]]);
});
});
describe('when disabled', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('does not render VueDraggable component', () => {
expect(findVueDraggable().exists()).toBe(false);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import {
CREATED_DESC,
PAGE_SIZE,
PAGE_SIZE_MANUAL,
RELATIVE_POSITION_ASC,
sortOptions,
sortParams,
} from '~/issues_list/constants';
import axios from '~/lib/utils/axios_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
jest.mock('~/flash');
describe('IssuesListApp component', () => {
const originalWindowLocation = window.location;
let axiosMock;
let wrapper;
const fullPath = 'path/to/project';
const endpoint = 'api/endpoint';
const issuesPath = `${fullPath}/-/issues`;
const state = 'opened';
const xPage = 1;
const xTotal = 25;
......@@ -29,37 +44,64 @@ describe('IssuesListApp component', () => {
provide: {
endpoint,
fullPath,
issuesPath,
},
});
beforeEach(async () => {
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
wrapper = mountComponent();
await waitForPromises();
});
afterEach(() => {
window.location = originalWindowLocation;
axiosMock.reset();
wrapper.destroy();
});
it('renders IssuableList', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: fullPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search or filter results…',
showPaginationControls: true,
issuables: [],
totalItems: xTotal,
currentPage: xPage,
previousPage: xPage - 1,
nextPage: xPage + 1,
urlParams: { page: xPage, state },
describe('IssuableList', () => {
beforeEach(async () => {
wrapper = mountComponent();
await waitForPromises();
});
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: fullPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search or filter results…',
sortOptions,
initialSortBy: CREATED_DESC,
showPaginationControls: true,
issuables: [],
totalItems: xTotal,
currentPage: xPage,
previousPage: xPage - 1,
nextPage: xPage + 1,
urlParams: { page: xPage, state },
});
});
});
describe('when "page-change" event is emitted', () => {
describe('initial sort', () => {
it.each(Object.keys(sortParams))('is set as %s when the url query matches', (sortKey) => {
Object.defineProperty(window, 'location', {
writable: true,
value: {
href: setUrlParams(sortParams[sortKey], TEST_HOST),
},
});
wrapper = mountComponent();
expect(findIssuableList().props()).toMatchObject({
initialSortBy: sortKey,
urlParams: sortParams[sortKey],
});
});
});
describe('when "page-change" event is emitted by IssuableList', () => {
const data = [{ id: 10, title: 'title', state }];
const page = 2;
const totalItems = 21;
......@@ -70,6 +112,8 @@ describe('IssuesListApp component', () => {
'x-total': totalItems,
});
wrapper = mountComponent();
findIssuableList().vm.$emit('page-change', page);
await waitForPromises();
......@@ -78,7 +122,7 @@ describe('IssuesListApp component', () => {
it('fetches issues with expected params', async () => {
expect(axiosMock.history.get[1].params).toEqual({
page,
per_page: 20,
per_page: PAGE_SIZE,
state,
with_labels_details: true,
});
......@@ -95,4 +139,75 @@ describe('IssuesListApp component', () => {
});
});
});
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(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: `${issuesPath}/${issueToMove.iid}/reorder`,
data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
});
});
},
);
});
describe('when unsuccessful', () => {
it('displays an error message', async () => {
axiosMock.onPut(`${issuesPath}/${issueOne.iid}/reorder`).reply(500);
findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError });
});
});
});
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(sortParams))(
'fetches issues with correct params for "sort" payload %s',
async (sortKey) => {
wrapper = mountComponent();
findIssuableList().vm.$emit('sort', sortKey);
await waitForPromises();
expect(axiosMock.history.get[1].params).toEqual({
page: xPage,
per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
state,
with_labels_details: true,
...sortParams[sortKey],
});
},
);
});
});
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