Commit 4bd48589 authored by Scott Hampton's avatar Scott Hampton

Merge branch '322755-add-sorting-to-vue-issues-list-page' into 'master'

Add sorting functionality to Vue issues list page

See merge request gitlab-org/gitlab!56754
parents 222add97 e05cbda5
......@@ -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 ""
......@@ -17727,6 +17730,9 @@ msgstr ""
msgid "Label lists show all issues with the selected label."
msgstr ""
msgid "Label priority"
msgstr ""
msgid "Label was created"
msgstr ""
......@@ -18616,6 +18622,9 @@ msgstr ""
msgid "Manifest import"
msgstr ""
msgid "Manual"
msgstr ""
msgid "ManualOrdering|Couldn't save the order of the issues"
msgstr ""
......@@ -19682,6 +19691,9 @@ msgid_plural "Milestones"
msgstr[0] ""
msgstr[1] ""
msgid "Milestone due date"
msgstr ""
msgid "Milestone lists not available with your current license"
msgstr ""
......@@ -22936,6 +22948,9 @@ msgstr ""
msgid "Policy project doesn't exists"
msgstr ""
msgid "Popularity"
msgstr ""
msgid "Postman collection"
msgstr ""
......@@ -23125,6 +23140,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