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