Commit 79e09244 authored by sstern's avatar sstern Committed by Paul Slaughter

Add iterations to bulk edit on project and group

https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51657
parent d225db93
...@@ -50,6 +50,7 @@ export default { ...@@ -50,6 +50,7 @@ export default {
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
health_status: this.form.find('input[name="update[health_status]"]').val(), health_status: this.form.find('input[name="update[health_status]"]').val(),
epic_id: this.form.find('input[name="update[epic_id]"]').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: [], add_label_ids: [],
remove_label_ids: [], remove_label_ids: [],
}, },
......
...@@ -79,6 +79,16 @@ export default class IssuableBulkUpdateSidebar { ...@@ -79,6 +79,16 @@ export default class IssuableBulkUpdateSidebar {
}) })
.catch(() => {}); .catch(() => {});
} }
if (IS_EE) {
import('ee/vue_shared/components/sidebar/iterations_dropdown_bundle')
.then(({ default: iterationsDropdown }) => {
iterationsDropdown();
})
.catch((e) => {
throw e;
});
}
} }
setupBulkUpdateActions() { setupBulkUpdateActions() {
......
- type = local_assigns.fetch(:type) - type = local_assigns.fetch(:type)
- bulk_issue_health_status_flag = type == :issues && @project&.group&.feature_available?(:issuable_health_status) - 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 - 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? } } %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden .issuable-sidebar.hidden
...@@ -41,6 +42,8 @@ ...@@ -41,6 +42,8 @@
= _('Milestone') = _('Milestone')
.filter-item .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") } }) = 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 .block
.title .title
= _('Labels') = _('Labels')
......
...@@ -20,19 +20,21 @@ Only the items visible on the current page are selected for bulk editing (up to ...@@ -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 ## 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: NOTE:
You need a permission level of [Reporter or higher](../../permissions.md) to manage issues. 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: When bulk editing issues in a group, you can edit the following attributes:
- Epic ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210470) in - [Epic](../epics/index.md)
[GitLab Premium](https://about.gitlab.com/pricing/) 13.2.) **(PREMIUM)** - [Milestone](../../project/milestones/index.md)
- Milestone - [Labels](../../project/labels.md)
- Labels - [Health status](../../project/issues/index.md#health-status)
- Health status ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218395) in - [Iteration](../iterations/index.md)
[GitLab Ultimate](https://about.gitlab.com/pricing/) 13.2.) **(ULTIMATE)**
To update multiple project issues at the same time: 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 ...@@ -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 ## 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: NOTE:
You need a permission level of [Reporter or higher](../permissions.md) to manage issues. 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: ...@@ -28,13 +32,12 @@ When bulk editing issues in a project, you can edit the following attributes:
- Status (open/closed) - Status (open/closed)
- Assignee - Assignee
- Epic ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210470) in - [Epic](../group/epics/index.md)
[GitLab Premium](https://about.gitlab.com/pricing/) 13.2.) **(PREMIUM)** - [Milestone](milestones/index.md)
- Milestone - [Labels](labels.md)
- Labels - [Health status](issues/index.md#health-status)
- Health status ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218395) in - Notification subscription
[GitLab Ultimate](https://about.gitlab.com/pricing/) 13.2.) **(ULTIMATE)** - [Iteration](../group/iterations/index.md)
- Subscriptions
To update multiple project issues at the same time: 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 @@ ...@@ -2,6 +2,7 @@
- type = local_assigns.fetch(:type) - type = local_assigns.fetch(:type)
- bulk_issue_health_status_flag = type == :issues && group&.feature_available?(:issuable_health_status) - bulk_issue_health_status_flag = type == :issues && group&.feature_available?(:issuable_health_status)
- epic_bulk_edit_flag = type == :issues && group&.feature_available?(:epics) - 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? } } %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ 'aria-live' => 'polite', data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden .issuable-sidebar.hidden
...@@ -23,6 +24,8 @@ ...@@ -23,6 +24,8 @@
= _('Milestone') = _('Milestone')
.filter-item .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') } }) = 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 .block
.title .title
= _('Labels') = _('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 "" ...@@ -25996,6 +25996,9 @@ msgstr ""
msgid "Select health status" msgid "Select health status"
msgstr "" msgstr ""
msgid "Select iteration"
msgstr ""
msgid "Select label" msgid "Select label"
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