Commit 646145d4 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'ss/add-iteration-to-bulk-edit-issues' into 'master'

Add iterations to bulk edit on project and group

See merge request gitlab-org/gitlab!51657
parents b9a474ad 79e09244
......@@ -50,6 +50,7 @@ export default {
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
health_status: this.form.find('input[name="update[health_status]"]').val(),
epic_id: this.form.find('input[name="update[epic_id]"]').val(),
sprint_id: this.form.find('input[name="update[iteration_id]"]').val(),
add_label_ids: [],
remove_label_ids: [],
},
......
......@@ -79,6 +79,16 @@ export default class IssuableBulkUpdateSidebar {
})
.catch(() => {});
}
if (IS_EE) {
import('ee/vue_shared/components/sidebar/iterations_dropdown_bundle')
.then(({ default: iterationsDropdown }) => {
iterationsDropdown();
})
.catch((e) => {
throw e;
});
}
}
setupBulkUpdateActions() {
......
- type = local_assigns.fetch(:type)
- bulk_issue_health_status_flag = type == :issues && @project&.group&.feature_available?(:issuable_health_status)
- epic_bulk_edit_flag = @project&.group&.feature_available?(:epics) && type == :issues
- bulk_iterations_flag = @project.feature_available?(:iterations) && @project&.group.present? && type == :issues
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
......@@ -41,6 +42,8 @@
= _('Milestone')
.filter-item
= dropdown_tag(_("Select milestone"), options: { title: _("Assign milestone"), toggle_class: "js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: _("Search milestones"), data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, use_id: true, default_label: _("Milestone") } })
- if bulk_iterations_flag
= render_if_exists 'shared/iterations_dropdown', path: @project.group.full_path
.block
.title
= _('Labels')
......
......@@ -20,19 +20,21 @@ Only the items visible on the current page are selected for bulk editing (up to
## Bulk edit issues at the group level
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/7249) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.1.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/7249) in GitLab 12.1.
> - Assigning epic ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210470) in GitLab 13.2.
> - Editing health status [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218395) in GitLab 13.2.
> - Editing iteration [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/196806) in GitLab 13.9.
NOTE:
You need a permission level of [Reporter or higher](../../permissions.md) to manage issues.
When bulk editing issues in a group, you can edit the following attributes:
- Epic ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210470) in
[GitLab Premium](https://about.gitlab.com/pricing/) 13.2.) **(PREMIUM)**
- Milestone
- Labels
- Health status ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218395) in
[GitLab Ultimate](https://about.gitlab.com/pricing/) 13.2.) **(ULTIMATE)**
- [Epic](../epics/index.md)
- [Milestone](../../project/milestones/index.md)
- [Labels](../../project/labels.md)
- [Health status](../../project/issues/index.md#health-status)
- [Iteration](../iterations/index.md)
To update multiple project issues at the same time:
......
......@@ -21,6 +21,10 @@ Only the items visible on the current page are selected for bulk editing (up to
## Bulk edit issues at the project level
> - Assigning epic ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210470) in GitLab 13.2.
> - Editing health status [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218395) in GitLab 13.2.
> - Editing iteration [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/196806) in GitLab 13.9.
NOTE:
You need a permission level of [Reporter or higher](../permissions.md) to manage issues.
......@@ -28,13 +32,12 @@ When bulk editing issues in a project, you can edit the following attributes:
- Status (open/closed)
- Assignee
- Epic ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210470) in
[GitLab Premium](https://about.gitlab.com/pricing/) 13.2.) **(PREMIUM)**
- Milestone
- Labels
- Health status ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218395) in
[GitLab Ultimate](https://about.gitlab.com/pricing/) 13.2.) **(ULTIMATE)**
- Subscriptions
- [Epic](../group/epics/index.md)
- [Milestone](milestones/index.md)
- [Labels](labels.md)
- [Health status](issues/index.md#health-status)
- Notification subscription
- [Iteration](../group/iterations/index.md)
To update multiple project issues at the same time:
......
<script>
import {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlDropdownSectionHeader,
GlTooltipDirective,
GlLoadingIcon,
} from '@gitlab/ui';
import groupIterationsQuery from '../queries/group_iterations.query.graphql';
import { __ } from '~/locale';
import { iterationSelectTextMap, iterationDisplayState } from '../constants';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlDropdownSectionHeader,
GlLoadingIcon,
},
apollo: {
iterations: {
query: groupIterationsQuery,
debounce: 250,
variables() {
const search = this.searchTerm ? `"${this.searchTerm}"` : '';
return {
fullPath: this.fullPath,
title: search,
state: iterationDisplayState,
};
},
update(data) {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220379
return data.group?.iterations?.nodes || [];
},
result({ data }) {
const nodes = data.group?.iterations?.nodes || [];
this.iterations = iterationSelectTextMap.noIterationItem.concat(nodes);
},
skip() {
return !this.shouldFetch;
},
},
},
props: {
fullPath: {
required: true,
type: String,
},
},
data() {
return {
searchTerm: '',
iterations: [],
currentIteration: null,
shouldFetch: false,
};
},
computed: {
title() {
return this.currentIteration?.title || __('Select iteration');
},
},
methods: {
onClick(iteration) {
if (iteration.id === this.currentIteration?.id) {
this.currentIteration = null;
} else {
this.currentIteration = iteration;
}
this.$emit('onIterationSelect', this.currentIteration);
},
isIterationChecked(id) {
return id === this.currentIteration?.id;
},
onDropdownShow() {
this.shouldFetch = true;
},
},
};
</script>
<template>
<div data-qa-selector="iteration_container">
<gl-dropdown :text="title" class="gl-w-full" @show="onDropdownShow">
<gl-dropdown-section-header class="gl-display-flex! gl-justify-content-center">{{
__('Assign Iteration')
}}</gl-dropdown-section-header>
<gl-search-box-by-type v-model="searchTerm" />
<gl-loading-icon v-if="$apollo.loading" />
<gl-dropdown-item
v-for="iterationItem in iterations"
v-else
:key="iterationItem.id"
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
@click="onClick(iterationItem)"
>{{ iterationItem.title }}</gl-dropdown-item
>
</gl-dropdown>
</div>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import IterationDropdown from 'ee/sidebar/components/iteration_dropdown.vue';
import createDefaultClient from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
Vue.use(VueApollo);
export default function () {
const el = document.querySelector('#js-iteration-dropdown');
const iterationField = document.getElementById('issue_iteration_id');
if (!el || !iterationField) {
return false;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const { fullPath } = el.dataset;
return new Vue({
el,
apolloProvider,
methods: {
getIdForIteration(iteration) {
const noChangeIterationValue = '';
const unSetIterationValue = '0';
if (iteration === null) {
return noChangeIterationValue;
} else if (iteration.id === null) {
return unSetIterationValue;
}
return getIdFromGraphQLId(iteration.id);
},
handleIterationSelect(iteration) {
iterationField.setAttribute('value', this.getIdForIteration(iteration));
},
},
render(createElement) {
return createElement(IterationDropdown, {
props: {
fullPath,
},
on: {
onIterationSelect: this.handleIterationSelect.bind(this),
},
});
},
});
}
- path = local_assigns.fetch(:path)
.block
.title
= _('Iteration')
.filter-item
#js-iteration-dropdown{ data: { full_path: path } }
%input{ id: 'issue_iteration_id', type: 'hidden', name: 'update[iteration_id]' }
......@@ -2,6 +2,7 @@
- type = local_assigns.fetch(:type)
- bulk_issue_health_status_flag = type == :issues && group&.feature_available?(:issuable_health_status)
- epic_bulk_edit_flag = type == :issues && group&.feature_available?(:epics)
- bulk_edit_iterations = group.feature_available?(:iterations) && type == :issues
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ 'aria-live' => 'polite', data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
......@@ -23,6 +24,8 @@
= _('Milestone')
.filter-item
= dropdown_tag(_('Select milestone'), options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: 'dropdown-menu-selectable dropdown-menu-milestone', placeholder: _('Search milestones'), data: { show_no: true, field_name: 'update[milestone_id]', group_id: group&.id, use_id: true, default_label: _('Milestone') } })
- if bulk_edit_iterations
= render "shared/iterations_dropdown", path: group.full_path
.block
.title
= _('Labels')
......
---
title: Add iteration to bulk issue edit sidebar
merge_request: 51657
author:
type: changed
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Issues > Iteration bulk assignment' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:project_without_group) { create(:project, :public) }
let_it_be(:issue1) { create(:issue, project: project, title: "Issue 1") }
let_it_be(:issue2) { create(:issue, project: project, title: "Issue 2") }
let_it_be(:issue3) { create(:issue, project: project_without_group, title: "Issue 3") }
let_it_be(:iteration) { create(:iteration, group: group, title: "Iteration 1") }
shared_examples 'cannot find iterations when project does not have a group' do |context|
context 'cannot find iteration when group does not belong to project', :js do
before do
project_without_group.add_maintainer(user)
enable_bulk_update(context)
end
it 'cannot find iteration dropdown' do
expect(page).not_to have_selector('[data-qa-selector="iteration_container"]')
end
end
end
shared_examples 'bulk edit iteration' do |context|
context 'iteration', :js do
before do
enable_bulk_update(context)
end
context 'to all issues' do
before do
check 'check-all-issues'
open_iteration_dropdown ['Iteration 1']
update_issues
end
it 'updates the iteration' do
aggregate_failures 'each issue in list' do
expect(issue1.reload.iteration.name).to eq 'Iteration 1'
expect(issue2.reload.iteration.name).to eq 'Iteration 1'
end
end
end
end
context 'cannot find iteration when iterations is off', :js do
before do
stub_licensed_features(iterations: false)
enable_bulk_update(context)
end
it 'cannot find iteration dropdown' do
expect(page).not_to have_selector('[data-qa-selector="iteration_container"]')
end
end
end
context 'as an allowed user', :js do
before do
group.add_maintainer(user)
sign_in user
end
context 'at group level' do
it_behaves_like 'bulk edit iteration', :group
end
context 'at project level' do
it_behaves_like 'bulk edit iteration', :project
it_behaves_like 'cannot find iterations when project does not have a group', :project_without_group
end
end
def enable_bulk_update(context)
if context == :project
visit project_issues_path(project)
elsif context == :project_without_group
visit project_issues_path(project_without_group)
else
visit issues_group_path(group)
end
click_button 'Edit issues'
end
def open_iteration_dropdown(items = [])
page.within('.issues-bulk-update') do
click_button 'Select iteration'
items.map do |item|
find('.dropdown-item', text: item).click
end
end
end
def update_issues
find('.update-selected-issues').click
wait_for_requests
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IterationDropdown default shows gl-dropdown 1`] = `
<gl-dropdown-stub
category="primary"
class="gl-w-full"
headertext=""
hideheaderborder="true"
size="medium"
text="Select iteration"
variant="default"
>
<gl-dropdown-section-header-stub
class="gl-display-flex! gl-justify-content-center"
>
Assign Iteration
</gl-dropdown-section-header-stub>
<gl-search-box-by-type-stub
clearbuttontitle="Clear"
value=""
/>
</gl-dropdown-stub>
`;
import { GlDropdownItem, GlLoadingIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import IterationDropdown from 'ee/sidebar/components/iteration_dropdown.vue';
import groupIterationsQuery from 'ee/sidebar/queries/group_iterations.query.graphql';
import { iterationSelectTextMap } from 'ee/sidebar/constants';
const localVue = createLocalVue();
localVue.use(VueApollo);
const TEST_SEARCH = 'search';
const TEST_FULL_PATH = 'gitlab-test/test';
const TEST_ITERATIONS = [
{
id: '1',
title: 'Test Title',
webUrl: '',
state: '',
},
{
id: '2',
title: 'Another Test Title',
webUrl: '',
state: '',
},
];
describe('IterationDropdown', () => {
let wrapper;
let fakeApollo;
let groupIterationsSpy;
beforeEach(() => {
groupIterationsSpy = jest.fn().mockResolvedValue({
data: {
group: {
iterations: {
nodes: TEST_ITERATIONS,
},
},
},
});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const waitForDebounce = async () => {
await wrapper.vm.$nextTick();
jest.runOnlyPendingTimers();
};
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDropdownItemWithText = (text) =>
findDropdownItems().wrappers.find((x) => x.text() === text);
const findDropdownItemsData = () =>
findDropdownItems().wrappers.map((x) => ({
isCheckItem: x.props('isCheckItem'),
isChecked: x.props('isChecked'),
text: x.text(),
}));
const selectDropdownItemAndWait = async (text) => {
const item = findDropdownItemWithText(text);
item.vm.$emit('click');
await wrapper.vm.$nextTick();
};
const findDropdown = () => wrapper.find(GlDropdown);
const showDropdownAndWait = async () => {
findDropdown().vm.$emit('show');
await waitForDebounce();
};
const isLoading = () => wrapper.find(GlLoadingIcon).exists();
const createComponent = ({ mountFn = shallowMount } = {}) => {
fakeApollo = createMockApollo([[groupIterationsQuery, groupIterationsSpy]]);
wrapper = mountFn(IterationDropdown, {
localVue,
apolloProvider: fakeApollo,
propsData: {
fullPath: TEST_FULL_PATH,
},
});
};
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('does not show loading', () => {
expect(isLoading()).toBe(false);
});
it('shows gl-dropdown', () => {
expect(wrapper.find(GlDropdown).exists()).toBe(true);
expect(wrapper.find(GlDropdown).element).toMatchSnapshot();
});
});
describe('when dropdown opens and query is loading', () => {
beforeEach(async () => {
// return promise that doesn't resolve to force loading state
groupIterationsSpy.mockReturnValue(new Promise(() => {}));
createComponent();
await showDropdownAndWait();
});
it('shows loading', () => {
expect(isLoading()).toBe(true);
});
it('calls groupIterations query', () => {
expect(groupIterationsSpy).toHaveBeenCalledTimes(1);
expect(groupIterationsSpy).toHaveBeenCalledWith({
fullPath: TEST_FULL_PATH,
state: 'opened',
title: '',
});
});
});
describe('when dropdown opens and query responds', () => {
beforeEach(async () => {
createComponent();
await showDropdownAndWait();
});
it('does not show loading', () => {
expect(isLoading()).toBe(false);
});
it('shows dropdown items', () => {
const result = iterationSelectTextMap.noIterationItem.concat(TEST_ITERATIONS);
expect(findDropdownItemsData()).toEqual(
result.map((x) => ({
isCheckItem: true,
isChecked: false,
text: x.title,
})),
);
});
it('does not re-query if opened again', async () => {
groupIterationsSpy.mockClear();
await showDropdownAndWait();
expect(groupIterationsSpy).not.toHaveBeenCalled();
});
describe.each([0, 1, 2])('when item %s is selected', (index) => {
const allIterations = iterationSelectTextMap.noIterationItem.concat(TEST_ITERATIONS);
const selected = allIterations[index];
const asNotChecked = ({ title }) => ({ isCheckItem: true, isChecked: false, text: title });
beforeEach(async () => {
await selectDropdownItemAndWait(selected.title);
});
it('shows item as checked', () => {
const prevSelected = allIterations.slice(0, index);
const afterSelected = allIterations.slice(index + 1);
expect(findDropdownItemsData()).toEqual([
...prevSelected.map(asNotChecked),
{
isCheckItem: true,
isChecked: true,
text: selected.title,
},
...afterSelected.map(asNotChecked),
]);
});
it('emits event', () => {
expect(wrapper.emitted('onIterationSelect')).toEqual([[selected]]);
});
describe('when item is clicked again', () => {
beforeEach(async () => {
await selectDropdownItemAndWait(selected.title);
});
it('shows item as unchecked', () => {
expect(findDropdownItemsData()).toEqual(allIterations.map(asNotChecked));
});
it('emits event', () => {
expect(wrapper.emitted('onIterationSelect').length).toBe(2);
expect(wrapper.emitted('onIterationSelect')[1]).toEqual([null]);
});
});
});
});
describe('when dropdown opens and search is set', () => {
beforeEach(async () => {
createComponent();
await showDropdownAndWait();
groupIterationsSpy.mockClear();
wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
await waitForDebounce();
});
it('adds the search to the query', () => {
expect(groupIterationsSpy).toHaveBeenCalledWith({
fullPath: TEST_FULL_PATH,
state: 'opened',
title: `"${TEST_SEARCH}"`,
});
});
});
});
......@@ -25996,6 +25996,9 @@ msgstr ""
msgid "Select health status"
msgstr ""
msgid "Select iteration"
msgstr ""
msgid "Select label"
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