diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index 40b0fcbb8c6eec02b3a6a87d1ed06efbacc0b1e6..1b54ba766ff3f1877a99b10bb430c637df7a0c2a 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -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" diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue index 0b413ce0b06b9779f3a0cdbe5d994709f85fbd70..51cad662ebfd1517729cbe45ba2ccce93cceaab7 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -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: { diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index c57fa5a82fa421a665598ec60c95c1463f6567ae..e4bb3ecabd01eacd5659ed24460ea62444e187c1 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -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" /> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index f008ba1bf4adb396b06c41cebeb33f05cc882d2c..f6f23af80ba40ceb80baa7b5ccf0a49037b7b6ec 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -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, + }, + }, +]; diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index a283cbdc86bd3f40adaf8c4372024854a5564477..b55ebf8dbdbb89af3e387550d20b3613869581e8 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -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), }); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 97a8f681fafacc6759c533f70e83498d5f5f9ad1..6952c5def8ccef7da9422d7999981dca47fbb10d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -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, diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 1d300c42768213cb8fd53c1309d54588d8042356..9c54b117f4c0ccdc122da650b9de64e079f9904b 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 22d167c205af57f4032f9e3b7b02fa531eb732eb..89511d3ea7c2fdf039795b4be0b519ecf70f5759 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js index 9c57233548c670b5a79a73634062ec57febd4945..53f5d7874e11e94f4236fc4f4ebd6b05c8b0041a 100644 --- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js +++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js @@ -1,5 +1,6 @@ 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); + }); + }); + }); }); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 1053e8934c97e59ed087a796a39ce2f0421266d5..0a445973c14a10e23475f322c21ff84d7a52bca1 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -1,16 +1,31 @@ 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], + }); + }, + ); + }); });