Commit 9ea6affb authored by Coung Ngo's avatar Coung Ngo Committed by Kushal Pandya

Add bulk edit to issues list page refactor

This commit adds bulk edit to the Vue issues list page,
which is under the feature flag `vue_issues_list` defaulted to off.
parent 82da9ec1
...@@ -87,7 +87,7 @@ export default { ...@@ -87,7 +87,7 @@ export default {
// From issuable's initial bulk selection // From issuable's initial bulk selection
getOriginalCommonIds() { getOriginalCommonIds() {
const labelIds = []; const labelIds = [];
this.getElement('.selected-issuable:checked').each((i, el) => { this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
}); });
return intersection.apply(this, labelIds); return intersection.apply(this, labelIds);
...@@ -100,7 +100,7 @@ export default { ...@@ -100,7 +100,7 @@ export default {
let issuableLabels = []; let issuableLabels = [];
// Collect unique label IDs for all checked issues // Collect unique label IDs for all checked issues
this.getElement('.selected-issuable:checked').each((i, el) => { this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => {
issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
issuableLabels.forEach((labelId) => { issuableLabels.forEach((labelId) => {
// Store unique IDs // Store unique IDs
......
...@@ -34,7 +34,7 @@ export default class IssuableBulkUpdateSidebar { ...@@ -34,7 +34,7 @@ export default class IssuableBulkUpdateSidebar {
this.$otherFilters = $('.issues-other-filters'); this.$otherFilters = $('.issues-other-filters');
this.$checkAllContainer = $('.check-all-holder'); this.$checkAllContainer = $('.check-all-holder');
this.$issueChecks = $('.issue-check'); this.$issueChecks = $('.issue-check');
this.$issuesList = $('.selected-issuable'); this.$issuesList = $('.issuable-list input[type="checkbox"]');
this.$issuableIdsInput = $('#update_issuable_ids'); this.$issuableIdsInput = $('#update_issuable_ids');
} }
...@@ -46,16 +46,14 @@ export default class IssuableBulkUpdateSidebar { ...@@ -46,16 +46,14 @@ export default class IssuableBulkUpdateSidebar {
this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
this.$checkAllContainer.on('click', () => this.updateFormState()); this.$checkAllContainer.on('click', () => this.updateFormState());
if (this.vueIssuablesListFeature) { issueableEventHub.$on('issuables:updateBulkEdit', () => {
issueableEventHub.$on('issuables:updateBulkEdit', () => { // Danger! Strong coupling ahead!
// Danger! Strong coupling ahead! // The bulk update sidebar and its dropdowns look for checkboxes, and get data on which issue
// The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue // is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties
// is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties // explicitly, but this component is used in too many places right now to refactor straight away.
// explicitly, but this component is used in too many places right now to refactor straight away.
this.updateFormState(); this.updateFormState();
}); });
}
} }
initDropdowns() { initDropdowns() {
...@@ -96,7 +94,7 @@ export default class IssuableBulkUpdateSidebar { ...@@ -96,7 +94,7 @@ export default class IssuableBulkUpdateSidebar {
} }
updateFormState() { updateFormState() {
const noCheckedIssues = !$('.selected-issuable:checked').length; const noCheckedIssues = !$('.issuable-list input[type="checkbox"]:checked').length;
this.toggleSubmitButtonDisabled(noCheckedIssues); this.toggleSubmitButtonDisabled(noCheckedIssues);
this.updateSelectedIssuableIds(); this.updateSelectedIssuableIds();
...@@ -166,7 +164,7 @@ export default class IssuableBulkUpdateSidebar { ...@@ -166,7 +164,7 @@ export default class IssuableBulkUpdateSidebar {
} }
static getCheckedIssueIds() { static getCheckedIssueIds() {
const $checkedIssues = $('.selected-issuable:checked'); const $checkedIssues = $('.issuable-list input[type="checkbox"]:checked');
if ($checkedIssues.length > 0) { if ($checkedIssues.length > 0) {
return $.map($checkedIssues, (value) => $(value).data('id')); return $.map($checkedIssues, (value) => $(value).data('id'));
......
import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar'; import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar';
export default class IssuableIndex { export default class IssuableIndex {
constructor(pagePrefix) { constructor(pagePrefix = 'issuable_') {
issuableInitBulkUpdateSidebar.init(pagePrefix); issuableInitBulkUpdateSidebar.init(pagePrefix);
} }
} }
...@@ -65,6 +65,9 @@ export default { ...@@ -65,6 +65,9 @@ export default {
labels() { labels() {
return this.issuable.labels?.nodes || this.issuable.labels || []; return this.issuable.labels?.nodes || this.issuable.labels || [];
}, },
labelIdsString() {
return JSON.stringify(this.labels.map((label) => label.id));
},
assignees() { assignees() {
return this.issuable.assignees || []; return this.issuable.assignees || [];
}, },
...@@ -149,12 +152,13 @@ export default { ...@@ -149,12 +152,13 @@ export default {
</script> </script>
<template> <template>
<li class="issue gl-px-5!"> <li :id="`issuable_${issuable.id}`" class="issue gl-px-5!" :data-labels="labelIdsString">
<div class="issuable-info-container"> <div class="issuable-info-container">
<div v-if="showCheckbox" class="issue-check"> <div v-if="showCheckbox" class="issue-check">
<gl-form-checkbox <gl-form-checkbox
class="gl-mr-0" class="gl-mr-0"
:checked="checked" :checked="checked"
:data-id="issuable.id"
@input="$emit('checked-input', $event)" @input="$emit('checked-input', $event)"
/> />
</div> </div>
......
...@@ -218,11 +218,13 @@ export default { ...@@ -218,11 +218,13 @@ export default {
}, },
handleIssuableCheckedInput(issuable, value) { handleIssuableCheckedInput(issuable, value) {
this.checkedIssuables[this.issuableId(issuable)].checked = value; this.checkedIssuables[this.issuableId(issuable)].checked = value;
this.$emit('update-legacy-bulk-edit');
}, },
handleAllIssuablesCheckedInput(value) { handleAllIssuablesCheckedInput(value) {
Object.keys(this.checkedIssuables).forEach((issuableId) => { Object.keys(this.checkedIssuables).forEach((issuableId) => {
this.checkedIssuables[issuableId].checked = value; this.checkedIssuables[issuableId].checked = value;
}); });
this.$emit('update-legacy-bulk-edit');
}, },
handleVueDraggableUpdate({ newIndex, oldIndex }) { handleVueDraggableUpdate({ newIndex, oldIndex }) {
this.$emit('reorder', { newIndex, oldIndex }); this.$emit('reorder', { newIndex, oldIndex });
......
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
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 eventHub from '../eventhub';
import IssueCardTimeInfo from './issue_card_time_info.vue'; import IssueCardTimeInfo from './issue_card_time_info.vue';
export default { export default {
...@@ -56,6 +57,7 @@ export default { ...@@ -56,6 +57,7 @@ export default {
filters: sortParams[sortKey] || {}, filters: sortParams[sortKey] || {},
isLoading: false, isLoading: false,
issues: [], issues: [],
showBulkEditSidebar: false,
sortKey: sortKey || CREATED_DESC, sortKey: sortKey || CREATED_DESC,
totalIssues: 0, totalIssues: 0,
}; };
...@@ -73,8 +75,15 @@ export default { ...@@ -73,8 +75,15 @@ export default {
}, },
}, },
mounted() { mounted() {
eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => {
this.showBulkEditSidebar = showBulkEditSidebar;
});
this.fetchIssues(); this.fetchIssues();
}, },
beforeDestroy() {
// eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('issuables:toggleBulkEdit');
},
methods: { methods: {
fetchIssues(pageToFetch) { fetchIssues(pageToFetch) {
this.isLoading = true; this.isLoading = true;
...@@ -101,6 +110,13 @@ export default { ...@@ -101,6 +110,13 @@ export default {
this.isLoading = false; this.isLoading = false;
}); });
}, },
handleUpdateLegacyBulkEdit() {
// If "select all" checkbox was checked, wait for all checkboxes
// to be checked before updating IssuableBulkUpdateSidebar class
this.$nextTick(() => {
eventHub.$emit('issuables:updateBulkEdit');
});
},
handlePageChange(page) { handlePageChange(page) {
this.fetchIssues(page); this.fetchIssues(page);
}, },
...@@ -159,6 +175,7 @@ export default { ...@@ -159,6 +175,7 @@ export default {
current-tab="" current-tab=""
:issuables-loading="isLoading" :issuables-loading="isLoading"
:is-manual-ordering="isManualOrdering" :is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="true" :show-pagination-controls="true"
:total-items="totalIssues" :total-items="totalIssues"
:current-page="currentPage" :current-page="currentPage"
...@@ -168,6 +185,7 @@ export default { ...@@ -168,6 +185,7 @@ export default {
@page-change="handlePageChange" @page-change="handlePageChange"
@reorder="handleReorder" @reorder="handleReorder"
@sort="handleSort" @sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
> >
<template #timeframe="{ issuable = {} }"> <template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" /> <issue-card-time-info :issue="issuable" />
......
...@@ -526,11 +526,15 @@ export default class LabelsSelect { ...@@ -526,11 +526,15 @@ export default class LabelsSelect {
} }
bindEvents() { bindEvents() {
return $('body').on('change', '.selected-issuable', this.onSelectCheckboxIssue); return $('body').on(
'change',
'.issuable-list input[type="checkbox"]',
this.onSelectCheckboxIssue,
);
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
onSelectCheckboxIssue() { onSelectCheckboxIssue() {
if ($('.selected-issuable:checked').length) { if ($('.issuable-list input[type="checkbox"]:checked').length) {
return; return;
} }
return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label')); return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label'));
......
...@@ -20,7 +20,12 @@ initFilteredSearch({ ...@@ -20,7 +20,12 @@ initFilteredSearch({
useDefaultState: true, useDefaultState: true,
}); });
new IssuableIndex(ISSUABLE_INDEX.ISSUE); if (gon.features?.vueIssuesList) {
new IssuableIndex();
} else {
new IssuableIndex(ISSUABLE_INDEX.ISSUE);
}
new ShortcutsNavigation(); new ShortcutsNavigation();
new UsersSelect(); new UsersSelect();
......
...@@ -45,6 +45,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -45,6 +45,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:vue_issuables_list, project) push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true) push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project)
end end
before_action only: :show do before_action only: :show do
......
...@@ -26,6 +26,8 @@ ...@@ -26,6 +26,8 @@
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) } } issues_path: project_issues_path(@project) } }
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
- else - else
= render 'shared/issuable/search_bar', type: :issues = render 'shared/issuable/search_bar', type: :issues
......
...@@ -35,6 +35,7 @@ describe('IssuableListRoot', () => { ...@@ -35,6 +35,7 @@ describe('IssuableListRoot', () => {
const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar); const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
const findGlPagination = () => wrapper.findComponent(GlPagination); const findGlPagination = () => wrapper.findComponent(GlPagination);
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs); const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
const findVueDraggable = () => wrapper.findComponent(VueDraggable); const findVueDraggable = () => wrapper.findComponent(VueDraggable);
...@@ -351,6 +352,18 @@ describe('IssuableListRoot', () => { ...@@ -351,6 +352,18 @@ describe('IssuableListRoot', () => {
}); });
}); });
it('emits `update-legacy-bulk-edit` when filtered-search-bar checkbox is checked', () => {
findFilteredSearchBar().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
it('emits `update-legacy-bulk-edit` when issuable-item checkbox is checked', () => {
findIssuableItem().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
it('gl-pagination component emits `page-change` event on `input` event', async () => { it('gl-pagination component emits `page-change` event on `input` event', async () => {
wrapper.setProps({ wrapper.setProps({
showPaginationControls: true, showPaginationControls: true,
...@@ -379,7 +392,7 @@ describe('IssuableListRoot', () => { ...@@ -379,7 +392,7 @@ describe('IssuableListRoot', () => {
}); });
it('IssuableItem has grab cursor', () => { it('IssuableItem has grab cursor', () => {
expect(wrapper.findComponent(IssuableItem).classes()).toContain('gl-cursor-grab'); expect(findIssuableItem().classes()).toContain('gl-cursor-grab');
}); });
it('emits a "reorder" event when user updates the issue order', () => { it('emits a "reorder" event when user updates the issue order', () => {
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
sortOptions, sortOptions,
sortParams, sortParams,
} from '~/issues_list/constants'; } from '~/issues_list/constants';
import eventHub from '~/issues_list/eventhub';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { setUrlParams } from '~/lib/utils/url_utility'; import { setUrlParams } from '~/lib/utils/url_utility';
...@@ -101,6 +102,23 @@ describe('IssuesListApp component', () => { ...@@ -101,6 +102,23 @@ describe('IssuesListApp component', () => {
}); });
}); });
describe('bulk edit', () => {
describe.each([true, false])(
'when "issuables:toggleBulkEdit" event is received with payload `%s`',
(isBulkEdit) => {
beforeEach(() => {
wrapper = mountComponent();
eventHub.$emit('issuables:toggleBulkEdit', isBulkEdit);
});
it(`${isBulkEdit ? 'enables' : 'disables'} bulk edit`, () => {
expect(findIssuableList().props('showBulkEditSidebar')).toBe(isBulkEdit);
});
},
);
});
describe('when "page-change" event is emitted by IssuableList', () => { 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;
...@@ -119,7 +137,7 @@ describe('IssuesListApp component', () => { ...@@ -119,7 +137,7 @@ describe('IssuesListApp component', () => {
await waitForPromises(); await waitForPromises();
}); });
it('fetches issues with expected params', async () => { it('fetches issues with expected params', () => {
expect(axiosMock.history.get[1].params).toEqual({ expect(axiosMock.history.get[1].params).toEqual({
page, page,
per_page: PAGE_SIZE, per_page: PAGE_SIZE,
...@@ -192,7 +210,7 @@ describe('IssuesListApp component', () => { ...@@ -192,7 +210,7 @@ describe('IssuesListApp component', () => {
describe('when "sort" event is emitted by IssuableList', () => { describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(sortParams))( it.each(Object.keys(sortParams))(
'fetches issues with correct params for "sort" payload %s', 'fetches issues with correct params for "sort" payload `%s`',
async (sortKey) => { async (sortKey) => {
wrapper = mountComponent(); wrapper = mountComponent();
...@@ -210,4 +228,19 @@ describe('IssuesListApp component', () => { ...@@ -210,4 +228,19 @@ describe('IssuesListApp component', () => {
}, },
); );
}); });
describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.spyOn(eventHub, '$emit');
});
it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
findIssuableList().vm.$emit('update-legacy-bulk-edit');
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
}); });
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