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 {
if (layoutPageEl) {
layoutPageEl.classList.toggle('right-sidebar-expanded', 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>
import { GlButton, GlIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import EpicsFilteredSearchMixin from 'ee/roadmap/mixins/filtered_search_mixin';
......@@ -15,6 +16,7 @@ import { EpicsSortOptions } from '../constants';
import groupEpics from '../queries/group_epics.query.graphql';
import EpicsListEmptyState from './epics_list_empty_state.vue';
import EpicsListBulkEditSidebar from './epics_list_bulk_edit_sidebar.vue';
export default {
IssuableListTabs,
......@@ -26,6 +28,7 @@ export default {
GlIcon,
IssuableList,
EpicsListEmptyState,
EpicsListBulkEditSidebar,
},
mixins: [EpicsFilteredSearchMixin],
inject: [
......@@ -39,7 +42,7 @@ export default {
'epicsCount',
'epicNewPath',
'groupFullPath',
'groupLabelsPath',
'listEpicsPath',
'groupMilestonesPath',
'emptyStatePath',
'isSignedIn',
......@@ -108,6 +111,8 @@ export default {
nextPageCursor: this.next,
filterParams: this.initialFilterParams,
sortedBy: this.initialSortBy,
showBulkEditSidebar: false,
bulkEditInProgress: false,
epics: {
list: [],
pageInfo: {},
......@@ -198,6 +203,26 @@ export default {
handleFilterEpics(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>
......@@ -215,6 +240,7 @@ export default {
:initial-sort-by="sortedBy"
:issuables="epics.list"
:issuables-loading="epicsListLoading"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
:show-discussions="true"
:default-page-size="$options.defaultPageSize"
......@@ -230,10 +256,37 @@ export default {
@filter="handleFilterEpics"
>
<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')
}}</gl-button>
</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 }">
<span class="issuable-reference">{{ epicReference(issuable) }}</span>
</template>
......
......@@ -35,7 +35,8 @@ export default function initEpicsList({ mountPointSelector }) {
epicNewPath,
listEpicsPath,
groupFullPath,
groupLabelsPath,
labelsManagePath,
labelsFetchPath,
groupMilestonesPath,
emptyStatePath,
isSignedIn,
......@@ -69,10 +70,11 @@ export default function initEpicsList({ mountPointSelector }) {
[IssuableStates.Closed]: parseInt(epicsCountClosed, 10),
[IssuableStates.All]: parseInt(epicsCountAll, 10),
},
labelsFetchPath: `${labelsFetchPath}?only_group_labels=true`,
epicNewPath,
listEpicsPath,
groupFullPath,
groupLabelsPath,
labelsManagePath,
groupMilestonesPath,
emptyStatePath,
isSignedIn: parseBoolean(isSignedIn),
......
......@@ -17,7 +17,8 @@
epic_new_path: new_group_epic_url(@group),
list_epics_path: group_epics_path(@group),
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),
empty_state_path: image_path('illustrations/epics/list.svg'),
is_signed_in: is_signed_in } }
......
......@@ -227,8 +227,9 @@ RSpec.describe 'epics list', :js do
wait_for_requests
end
it 'renders New Epic Link' do
page.within('.issuable-list-container') do
it 'renders epics list header actions', :aggregate_failures do
page.within('.issuable-list-container .nav-controls') do
expect(page).to have_button('Edit epics')
expect(page).to have_link('New epic')
end
end
......@@ -236,6 +237,55 @@ RSpec.describe 'epics list', :js do
it_behaves_like 'epic list'
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
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 ""
msgid "Edit environment"
msgstr ""
msgid "Edit epics"
msgstr ""
msgid "Edit files in the editor and commit changes here"
msgstr ""
......@@ -13773,6 +13776,9 @@ msgstr ""
msgid "Epics|Something went wrong while removing issue from epic."
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."
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