Commit 6c174bee authored by Kushal Pandya's avatar Kushal Pandya Committed by Natalia Tepluhina

Add support for bulk editing epics

parent f52ac300
...@@ -13,6 +13,7 @@ export default { ...@@ -13,6 +13,7 @@ export default {
if (layoutPageEl) { if (layoutPageEl) {
layoutPageEl.classList.toggle('right-sidebar-expanded', value); layoutPageEl.classList.toggle('right-sidebar-expanded', value);
layoutPageEl.classList.toggle('right-sidebar-collapsed', !value); layoutPageEl.classList.toggle('right-sidebar-collapsed', !value);
layoutPageEl.classList.toggle('issuable-bulk-update-sidebar', !value);
} }
}, },
}, },
......
<script>
import { GlForm, GlFormGroup } from '@gitlab/ui';
import { uniqBy } from 'lodash';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import csrf from '~/lib/utils/csrf';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export default {
csrf,
DropdownVariant,
getIdFromGraphQLId,
components: {
GlForm,
GlFormGroup,
LabelsSelectWidget,
},
inject: ['labelsManagePath', 'labelsFetchPath'],
props: {
checkedEpics: {
type: Array,
required: true,
},
},
data() {
return {
selectedLabelIds: [],
removedLabelIds: [],
};
},
computed: {
/**
* This prop returns a unique list of labels
* applied on all the selected epics while
* also making sure that `id` is numeri
* instead of GraphQL ID string.
*/
existingSelectedLabels() {
if (!this.checkedEpics.length) {
return [];
}
return uniqBy(
this.checkedEpics.reduce((labels, epic) => {
if (epic.labels.nodes.length) {
const labelsForEpic = epic.labels.nodes.map((label) => ({
...label,
id: getIdFromGraphQLId(label.id),
}));
labels.push(...labelsForEpic);
}
return labels;
}, []),
'id',
);
},
},
methods: {
handleSelectedLabels(labels) {
this.selectedLabelIds = [...labels].map((label) => label.id);
this.removedLabelIds = this.existingSelectedLabels
.filter((label) => !this.selectedLabelIds.includes(label.id))
.map((label) => label.id);
},
handleFormSubmitted() {
const bulkUpdateData = {
issuable_ids: this.checkedEpics.map((epic) => getIdFromGraphQLId(epic.id)).join(','),
add_label_ids: this.selectedLabelIds,
remove_label_ids: this.removedLabelIds,
};
this.$emit('bulk-update', bulkUpdateData);
},
},
};
</script>
<template>
<gl-form id="epics-list-bulk-edit" @submit.prevent="handleFormSubmitted">
<gl-form-group :label="__('Labels')" class="block gl-p-0! gl-m-auto gl-mt-6">
<labels-select-widget
:allow-label-edit="true"
:allow-multiselect="true"
:allow-scoped-labels="true"
:selected-labels="existingSelectedLabels"
:labels-fetch-path="labelsFetchPath"
:labels-manage-path="labelsManagePath"
:variant="$options.DropdownVariant.Embedded"
@updateSelectedLabels="handleSelectedLabels"
/>
</gl-form-group>
</gl-form>
</template>
<script> <script>
import { GlButton, GlIcon } from '@gitlab/ui'; import { GlButton, GlIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import EpicsFilteredSearchMixin from 'ee/roadmap/mixins/filtered_search_mixin'; import EpicsFilteredSearchMixin from 'ee/roadmap/mixins/filtered_search_mixin';
...@@ -15,6 +16,7 @@ import { EpicsSortOptions } from '../constants'; ...@@ -15,6 +16,7 @@ import { EpicsSortOptions } from '../constants';
import groupEpics from '../queries/group_epics.query.graphql'; import groupEpics from '../queries/group_epics.query.graphql';
import EpicsListEmptyState from './epics_list_empty_state.vue'; import EpicsListEmptyState from './epics_list_empty_state.vue';
import EpicsListBulkEditSidebar from './epics_list_bulk_edit_sidebar.vue';
export default { export default {
IssuableListTabs, IssuableListTabs,
...@@ -26,6 +28,7 @@ export default { ...@@ -26,6 +28,7 @@ export default {
GlIcon, GlIcon,
IssuableList, IssuableList,
EpicsListEmptyState, EpicsListEmptyState,
EpicsListBulkEditSidebar,
}, },
mixins: [EpicsFilteredSearchMixin], mixins: [EpicsFilteredSearchMixin],
inject: [ inject: [
...@@ -39,7 +42,7 @@ export default { ...@@ -39,7 +42,7 @@ export default {
'epicsCount', 'epicsCount',
'epicNewPath', 'epicNewPath',
'groupFullPath', 'groupFullPath',
'groupLabelsPath', 'listEpicsPath',
'groupMilestonesPath', 'groupMilestonesPath',
'emptyStatePath', 'emptyStatePath',
'isSignedIn', 'isSignedIn',
...@@ -108,6 +111,8 @@ export default { ...@@ -108,6 +111,8 @@ export default {
nextPageCursor: this.next, nextPageCursor: this.next,
filterParams: this.initialFilterParams, filterParams: this.initialFilterParams,
sortedBy: this.initialSortBy, sortedBy: this.initialSortBy,
showBulkEditSidebar: false,
bulkEditInProgress: false,
epics: { epics: {
list: [], list: [],
pageInfo: {}, pageInfo: {},
...@@ -198,6 +203,26 @@ export default { ...@@ -198,6 +203,26 @@ export default {
handleFilterEpics(filters) { handleFilterEpics(filters) {
this.filterParams = this.getFilterParams(filters); this.filterParams = this.getFilterParams(filters);
}, },
/**
* Bulk editing Issuables (or Epics in this case) is not supported
* via GraphQL mutations, so we're using legacy API to do it,
* hence we're making a POST call within the component.
*/
handleEpicsBulkUpdate(update) {
this.bulkEditInProgress = true;
axios
.post(`${this.listEpicsPath}/bulk_update`, {
update,
})
.then(() => window.location.reload())
.catch((error) => {
createFlash({
message: s__('Epics|Something went wrong while updating epics.'),
captureError: true,
error,
});
});
},
}, },
}; };
</script> </script>
...@@ -215,6 +240,7 @@ export default { ...@@ -215,6 +240,7 @@ export default {
:initial-sort-by="sortedBy" :initial-sort-by="sortedBy"
:issuables="epics.list" :issuables="epics.list"
:issuables-loading="epicsListLoading" :issuables-loading="epicsListLoading"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls" :show-pagination-controls="showPaginationControls"
:show-discussions="true" :show-discussions="true"
:default-page-size="$options.defaultPageSize" :default-page-size="$options.defaultPageSize"
...@@ -230,10 +256,37 @@ export default { ...@@ -230,10 +256,37 @@ export default {
@filter="handleFilterEpics" @filter="handleFilterEpics"
> >
<template v-if="canCreateEpic || canBulkEditEpics" #nav-actions> <template v-if="canCreateEpic || canBulkEditEpics" #nav-actions>
<gl-button v-if="canCreateEpic" category="primary" variant="success" :href="epicNewPath">{{ <gl-button
v-if="canBulkEditEpics"
:disabled="showBulkEditSidebar"
@click="showBulkEditSidebar = true"
>{{ __('Edit epics') }}</gl-button
>
<gl-button v-if="canCreateEpic" category="primary" variant="confirm" :href="epicNewPath">{{
__('New epic') __('New epic')
}}</gl-button> }}</gl-button>
</template> </template>
<template #bulk-edit-actions="{ checkedIssuables }">
<gl-button
category="primary"
variant="confirm"
type="submit"
class="js-update-selected-issues"
form="epics-list-bulk-edit"
:disabled="checkedIssuables.length === 0 || bulkEditInProgress"
:loading="bulkEditInProgress"
>{{ __('Update all') }}</gl-button
>
<gl-button class="gl-float-right" @click="showBulkEditSidebar = false">{{
__('Cancel')
}}</gl-button>
</template>
<template #sidebar-items="{ checkedIssuables }">
<epics-list-bulk-edit-sidebar
:checked-epics="checkedIssuables"
@bulk-update="handleEpicsBulkUpdate"
/>
</template>
<template #reference="{ issuable }"> <template #reference="{ issuable }">
<span class="issuable-reference">{{ epicReference(issuable) }}</span> <span class="issuable-reference">{{ epicReference(issuable) }}</span>
</template> </template>
......
...@@ -35,7 +35,8 @@ export default function initEpicsList({ mountPointSelector }) { ...@@ -35,7 +35,8 @@ export default function initEpicsList({ mountPointSelector }) {
epicNewPath, epicNewPath,
listEpicsPath, listEpicsPath,
groupFullPath, groupFullPath,
groupLabelsPath, labelsManagePath,
labelsFetchPath,
groupMilestonesPath, groupMilestonesPath,
emptyStatePath, emptyStatePath,
isSignedIn, isSignedIn,
...@@ -69,10 +70,11 @@ export default function initEpicsList({ mountPointSelector }) { ...@@ -69,10 +70,11 @@ export default function initEpicsList({ mountPointSelector }) {
[IssuableStates.Closed]: parseInt(epicsCountClosed, 10), [IssuableStates.Closed]: parseInt(epicsCountClosed, 10),
[IssuableStates.All]: parseInt(epicsCountAll, 10), [IssuableStates.All]: parseInt(epicsCountAll, 10),
}, },
labelsFetchPath: `${labelsFetchPath}?only_group_labels=true`,
epicNewPath, epicNewPath,
listEpicsPath, listEpicsPath,
groupFullPath, groupFullPath,
groupLabelsPath, labelsManagePath,
groupMilestonesPath, groupMilestonesPath,
emptyStatePath, emptyStatePath,
isSignedIn: parseBoolean(isSignedIn), isSignedIn: parseBoolean(isSignedIn),
......
...@@ -17,7 +17,8 @@ ...@@ -17,7 +17,8 @@
epic_new_path: new_group_epic_url(@group), epic_new_path: new_group_epic_url(@group),
list_epics_path: group_epics_path(@group), list_epics_path: group_epics_path(@group),
group_full_path: @group.full_path, group_full_path: @group.full_path,
group_labels_path: group_labels_path(@group, format: :json), labels_manage_path: group_labels_path(@group),
labels_fetch_path: group_labels_path(@group, format: :json),
group_milestones_path: group_milestones_path(@group, format: :json), group_milestones_path: group_milestones_path(@group, format: :json),
empty_state_path: image_path('illustrations/epics/list.svg'), empty_state_path: image_path('illustrations/epics/list.svg'),
is_signed_in: is_signed_in } } is_signed_in: is_signed_in } }
......
...@@ -227,8 +227,9 @@ RSpec.describe 'epics list', :js do ...@@ -227,8 +227,9 @@ RSpec.describe 'epics list', :js do
wait_for_requests wait_for_requests
end end
it 'renders New Epic Link' do it 'renders epics list header actions', :aggregate_failures do
page.within('.issuable-list-container') do page.within('.issuable-list-container .nav-controls') do
expect(page).to have_button('Edit epics')
expect(page).to have_link('New epic') expect(page).to have_link('New epic')
end end
end end
...@@ -236,6 +237,55 @@ RSpec.describe 'epics list', :js do ...@@ -236,6 +237,55 @@ RSpec.describe 'epics list', :js do
it_behaves_like 'epic list' it_behaves_like 'epic list'
it_behaves_like 'filtered search bar', available_tokens it_behaves_like 'filtered search bar', available_tokens
it 'shows bulk editing sidebar with actions and labels select dropdown', :aggregate_failures do
click_button 'Edit epics'
page.within('.issuable-list-container aside.right-sidebar') do
expect(page).to have_button('Update all', disabled: true)
expect(page).to have_button('Cancel')
expect(page).to have_selector('form#epics-list-bulk-edit')
expect(page).to have_button('Label')
end
end
it 'shows checkboxes for selecting epics while bulk editing sidebar is visible', :aggregate_failures do
click_button 'Edit epics'
page.within('.issuable-list-container') do
expect(page).to have_selector('.vue-filtered-search-bar-container input[type="checkbox"]')
expect(page.first('.issuable-list li.issue')).to have_selector('.gl-form-checkbox input[type="checkbox"]')
end
end
it 'applies label to multiple epics from bulk editing sidebar', :aggregate_failures do
# Vertify that no labels are applied already
expect(find('.issuable-list li.issue .issuable-info', match: :first)).not_to have_selector('.gl-label')
# Bulk edit all epics to apply label
page.within('.issuable-list-container') do
click_button 'Edit epics'
page.within('.vue-filtered-search-bar-container') do
page.find('input[type="checkbox"]').click
end
page.within('aside.right-sidebar') do
click_button 'Label'
wait_for_requests
click_link bug_label.title
click_button 'Update all'
wait_for_requests
end
end
# Verify that label is applied
expect(find('.issuable-list li.issue .issuable-info', match: :first)).to have_selector('.gl-label', text: bug_label.title)
end
end end
context 'when signed out' do context 'when signed out' do
......
import { GlForm, GlFormGroup } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EpicsListBulkEditSidebar from 'ee/epics_list/components/epics_list_bulk_edit_sidebar.vue';
import { mockFormattedEpic, mockFormattedEpic2 } from 'ee_jest/roadmap/mock_data';
import {
mockLabels,
mockRegularLabel,
mockScopedLabel,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
const mockEpic1 = {
...mockFormattedEpic,
id: 'gid://gitlab/Epic/1',
labels: {
nodes: [mockRegularLabel],
},
};
const mockEpic2 = {
...mockFormattedEpic2,
id: 'gid://gitlab/Epic/2',
labels: {
nodes: [mockScopedLabel],
},
};
const labelsFetchPath = '/gitlab-org/my-project/-/labels.json';
const labelsManagePath = '/gitlab-org/my-project/-/labels';
const createComponent = ({ checkedEpics = [mockEpic1, mockEpic2] } = {}) =>
shallowMount(EpicsListBulkEditSidebar, {
propsData: {
checkedEpics,
},
provide: {
labelsFetchPath,
labelsManagePath,
},
});
describe('EpicsListBulkEditSidebar', () => {
let wrapper;
const findLabelsSelect = () => wrapper.findComponent(LabelsSelectWidget);
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders gl-form with labels-select-widget', () => {
expect(wrapper.findComponent(GlForm).attributes('id')).toBe('epics-list-bulk-edit');
expect(wrapper.findComponent(GlFormGroup).attributes('label')).toBe('Labels');
expect(findLabelsSelect().exists()).toBe(true);
expect(findLabelsSelect().props()).toMatchObject({
allowLabelEdit: true,
allowMultiselect: true,
allowScopedLabels: true,
selectedLabels: [mockRegularLabel, mockScopedLabel],
labelsFetchPath,
labelsManagePath,
variant: 'embedded',
});
});
it('emits `bulk-update` event with request payload object on component after labels are selected/unselected', async () => {
// We're slicing `mockLabels` as it already includes
// 2 labels (as last 2 elements) that epics have present.
findLabelsSelect().vm.$emit('updateSelectedLabels', mockLabels.slice(0, 2));
await wrapper.vm.$nextTick();
wrapper.findComponent(GlForm).vm.$emit('submit', {
preventDefault: jest.fn(),
});
await wrapper.vm.$nextTick();
expect(wrapper.emitted('bulk-update')).toBeDefined();
expect(wrapper.emitted('bulk-update')[0]).toEqual([
{
issuable_ids: '1,2',
add_label_ids: [29, 28],
remove_label_ids: [26, 27],
},
]);
});
});
...@@ -12832,6 +12832,9 @@ msgstr "" ...@@ -12832,6 +12832,9 @@ msgstr ""
msgid "Edit environment" msgid "Edit environment"
msgstr "" msgstr ""
msgid "Edit epics"
msgstr ""
msgid "Edit files in the editor and commit changes here" msgid "Edit files in the editor and commit changes here"
msgstr "" msgstr ""
...@@ -13773,6 +13776,9 @@ msgstr "" ...@@ -13773,6 +13776,9 @@ msgstr ""
msgid "Epics|Something went wrong while removing issue from epic." msgid "Epics|Something went wrong while removing issue from epic."
msgstr "" msgstr ""
msgid "Epics|Something went wrong while updating epics."
msgstr ""
msgid "Epics|This epic and any containing child epics are confidential and should only be visible to team members with at least Reporter access." msgid "Epics|This epic and any containing child epics are confidential and should only be visible to team members with at least Reporter access."
msgstr "" msgstr ""
......
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