Commit ce5c8c67 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch 'bulk-edit-health-status' into 'master'

Bulk edit health status

See merge request gitlab-org/gitlab!33065
parents e99e3f2d 6143f05f
......@@ -86,6 +86,7 @@ export default {
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
health_status: this.form.find('input[name="update[health_status]"]').val(),
add_label_ids: [],
remove_label_ids: [],
},
......
......@@ -7,6 +7,8 @@ import MilestoneSelect from './milestone_select';
import issueStatusSelect from './issue_status_select';
import subscriptionSelect from './subscription_select';
import LabelsSelect from './labels_select';
import HealthStatusSelect from 'ee_else_ce/vue_shared/components/sidebar/health_status_select/health_status_bundle';
import issueableEventHub from './issuables_list/eventhub';
const HIDDEN_CLASS = 'hidden';
......@@ -63,6 +65,10 @@ export default class IssuableBulkUpdateSidebar {
new MilestoneSelect();
issueStatusSelect();
subscriptionSelect();
if (HealthStatusSelect) {
HealthStatusSelect();
}
}
setupBulkUpdateActions() {
......
......@@ -110,9 +110,13 @@ module IssuableActions
def bulk_update
result = Issuable::BulkUpdateService.new(parent, current_user, bulk_update_params).execute(resource_name)
quantity = result[:count]
if result.success?
quantity = result.payload[:count]
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
elsif result.error?
render json: { errors: result.message }, status: result.http_status
end
end
# rubocop:disable CodeReuse/ActiveRecord
......
......@@ -11,40 +11,29 @@ module Issuable
end
def execute(type)
model_class = type.classify.constantize
update_class = type.classify.pluralize.constantize::UpdateService
ids = params.delete(:issuable_ids).split(",")
items = find_issuables(parent, model_class, ids)
set_update_params(type)
items = update_issuables(type, ids)
response_success(payload: { count: items.count })
rescue ArgumentError => e
response_error(e.message, 422)
end
private
def set_update_params(type)
params.slice!(*permitted_attrs(type))
params.delete_if { |k, v| v.blank? }
if params[:assignee_ids] == [IssuableFinder::Params::NONE.to_s]
params[:assignee_ids] = []
end
items.each do |issuable|
next unless can?(current_user, :"update_#{type}", issuable)
update_class.new(issuable.issuing_parent, current_user, params).execute(issuable)
end
{
count: items.count,
success: !items.count.zero?
}
end
private
def permitted_attrs(type)
attrs = %i(state_event milestone_id add_label_ids remove_label_ids subscription_event)
issuable_specific_attrs(type, attrs)
end
def issuable_specific_attrs(type, attrs)
if type == 'issue' || type == 'merge_request'
attrs.push(:assignee_ids)
else
......@@ -52,6 +41,20 @@ module Issuable
end
end
def update_issuables(type, ids)
model_class = type.classify.constantize
update_class = type.classify.pluralize.constantize::UpdateService
items = find_issuables(parent, model_class, ids)
items.each do |issuable|
next unless can?(current_user, :"update_#{type}", issuable)
update_class.new(issuable.issuing_parent, current_user, params).execute(issuable)
end
items
end
def find_issuables(parent, model_class, ids)
if parent.is_a?(Project)
model_class.id_in(ids).of_projects(parent)
......@@ -59,6 +62,14 @@ module Issuable
model_class.id_in(ids).of_projects(parent.all_projects)
end
end
def response_success(message: nil, payload: nil)
ServiceResponse.success(message: message, payload: payload)
end
def response_error(message, http_status)
ServiceResponse.error(message: message, http_status: http_status)
end
end
end
......
- type = local_assigns.fetch(:type)
- bulk_issue_health_status_flag = Feature.enabled?(:bulk_update_health_status, @project&.group) && type == :issues && @project&.group&.feature_available?(:issuable_health_status)
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
......@@ -36,6 +37,13 @@
= _('Labels')
.filter-item.labels-filter
= render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _("Apply a label"), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: _("Labels") }, label_name: _("Select labels"), no_default_styles: true
- if bulk_issue_health_status_flag
.block
.title
= _('Health status')
.filter-item.health-status.health-status-filter
#js-bulk-update-health-status-root
%input{ id: 'issue_health_status_value', type: 'hidden', name: 'update[health_status]' }
.block
.title
= _('Subscriptions')
......
<script>
import { GlButton, GlDropdownItem, GlDropdown, GlDropdownDivider } from '@gitlab/ui';
import { s__ } from '~/locale';
import { healthStatusTextMap } from '../../constants';
export default {
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
},
props: {
isEditable: {
type: Boolean,
required: false,
default: false,
},
isFetching: {
type: Boolean,
required: false,
default: false,
},
status: {
type: String,
required: false,
default: '',
},
},
data() {
return {
isDropdownShowing: false,
selectedStatus: this.status,
statusOptions: Object.keys(healthStatusTextMap).map(key => ({
key,
value: healthStatusTextMap[key],
})),
};
},
computed: {
statusText() {
return this.status ? healthStatusTextMap[this.status] : s__('Sidebar|None');
},
dropdownText() {
if (this.status === null) {
return s__('No status');
}
return this.status ? healthStatusTextMap[this.status] : s__('Select health status');
},
tooltipText() {
let tooltipText = s__('Sidebar|Health status');
if (this.status) {
tooltipText += `: ${this.statusText}`;
}
return tooltipText;
},
},
watch: {
status(status) {
this.selectedStatus = status;
},
},
methods: {
handleDropdownClick(status) {
this.selectedStatus = status;
this.$emit('onDropdownClick', status);
this.hideDropdown();
},
hideDropdown() {
this.isDropdownShowing = false;
},
isSelected(status) {
return this.status === status;
},
},
};
</script>
<template>
<div class="dropdown dropdown-menu-selectable">
<gl-dropdown
ref="dropdown"
class="w-100"
:text="dropdownText"
@keydown.esc.native="hideDropdown"
@hide="hideDropdown"
>
<div class="dropdown-title">
<span class="health-title">{{ s__('Sidebar|Assign health status') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
class="dropdown-title-button dropdown-menu-close"
icon="close"
@click="hideDropdown"
/>
</div>
<div class="dropdown-content dropdown-body">
<gl-dropdown-item @click="handleDropdownClick(null)">
<gl-button
variant="link"
class="dropdown-item health-dropdown-item"
:class="{ 'is-active': isSelected(null) }"
>
{{ s__('Sidebar|No status') }}
</gl-button>
</gl-dropdown-item>
<gl-dropdown-divider class="divider health-divider" />
<gl-dropdown-item
v-for="option in statusOptions"
:key="option.key"
@click="handleDropdownClick(option.key)"
>
<gl-button
variant="link"
class="dropdown-item health-dropdown-item"
:class="{ 'is-active': isSelected(option.key) }"
>
{{ option.value }}
</gl-button>
</gl-dropdown-item>
</div>
</gl-dropdown>
</div>
</template>
......@@ -18,3 +18,10 @@ export const iterationSelectTextMap = {
noIterationItem: [{ title: __('No iteration'), id: null }],
iterationSelectFail: __('Failed to set iteration on this issue. Please try again.'),
};
export const healthStatusForRestApi = {
NO_STATUS: '0',
[healthStatus.ON_TRACK]: 'on_track',
[healthStatus.NEEDS_ATTENTION]: 'needs_attention',
[healthStatus.AT_RISK]: 'at_risk',
};
import Vue from 'vue';
import HealthStatusSelect from 'ee/sidebar/components/status/health_status_dropdown.vue';
import { healthStatusForRestApi } from 'ee/sidebar/constants';
export default () => {
const el = document.getElementById('js-bulk-update-health-status-root');
const healthStatusFormFieldEl = document.getElementById('issue_health_status_value');
if (!el && !healthStatusFormFieldEl) {
return false;
}
return new Vue({
el,
components: {
HealthStatusSelect,
},
data() {
return {
selectedStatus: undefined,
};
},
methods: {
handleHealthStatusSelect(selectedStatus) {
this.selectedStatus = selectedStatus;
healthStatusFormFieldEl.setAttribute(
'value',
healthStatusForRestApi[selectedStatus || 'NO_STATUS'],
);
},
},
render(createElement) {
return createElement('health-status-select', {
props: {
isFetching: false,
isEditable: true,
showDropdown: true,
status: this.selectedStatus,
},
on: {
onDropdownClick: this.handleHealthStatusSelect.bind(this),
},
});
},
});
};
......@@ -7,6 +7,7 @@ module EE
EE_PERMITTED_KEYS = %w[
weight
health_status
].freeze
private
......
......@@ -14,11 +14,24 @@ module EE
super
end
override :issuable_specific_attrs
def issuable_specific_attrs(type, attrs)
override :permitted_attrs
def permitted_attrs(type)
return super unless type == 'issue'
super.push(:health_status, :epic)
super.push(:health_status, :epic_id)
end
override :set_update_params
def set_update_params(type)
super
set_health_status
end
def set_health_status
return unless params[:health_status].present?
params[:health_status] = nil if params[:health_status] == IssuableFinder::Params::NONE.to_s
end
end
end
......
- group = local_assigns.fetch(:group)
- type = local_assigns.fetch(:type)
- bulk_issue_health_status_flag = type == :issues && Feature.enabled?(:bulk_update_health_status, group) && group&.feature_available?(:issuable_health_status)
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ 'aria-live' => 'polite', data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
......@@ -19,6 +20,13 @@
= _('Labels')
.filter-item.labels-filter
= render "shared/issuable/label_dropdown", classes: ["js-filter-bulk-update", "js-multiselect"], dropdown_title: _('Apply a label'), show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true, default_label: "Labels" }, label_name: _('Select labels'), no_default_styles: true, edit_context: group
- if bulk_issue_health_status_flag
.block
.title
= _('Health status')
.filter-item.health-status.health-status-filter
#js-bulk-update-health-status-root
%input{ id: 'issue_health_status_value', type: 'hidden', name: 'update[health_status]' }
= hidden_field_tag 'update[issuable_ids]', []
= hidden_field_tag :state_event, params[:state_event]
---
title: Bulk edit health status
merge_request: 33065
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Issues > Health status 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(:issue1) { create(:issue, project: project, title: "Issue 1") }
let_it_be(:issue2) { create(:issue, project: project, title: "Issue 2") }
shared_examples 'bulk edit option in sidebar' do |context|
it 'is present when bulk edit is enabled' do
enable_bulk_update(context)
expect(page).to have_css('.issuable-sidebar')
end
it 'is not present when bulk edit is disabled' do
expect(page).not_to have_css('.issuable-sidebar')
end
end
shared_examples 'bulk edit health status' do |context|
before do
enable_bulk_update(context)
end
context 'health_status', :js do
context 'to all issues' do
before do
check 'check-all-issues'
open_health_status_dropdown ['On track']
update_issues
end
it 'updates the health statuses' do
expect(issue1.reload.health_status).to eq 'on_track'
expect(issue2.reload.health_status).to eq 'on_track'
end
end
context 'to an issue' do
before do
check "selected_issue_#{issue1.id}"
open_health_status_dropdown ['At risk']
update_issues
end
it 'updates the checked issue\'s status' do
expect(issue1.reload.health_status).to eq 'at_risk'
expect(issue2.reload.health_status).to eq nil
end
end
end
end
shared_examples 'bulk edit health_status with insufficient permissions' do
it 'cannot bulk assign health_status' do
expect(page).not_to have_button 'Edit issues'
expect(page).not_to have_css '.check-all-issues'
expect(page).not_to have_css '.issue-check'
end
end
before do
stub_feature_flags(vue_issuables_list: false)
end
context 'as an allowed user', :js do
before do
allow(group).to receive(:feature_enabled?).and_return(true)
stub_licensed_features(group_bulk_edit: true, issuable_health_status: true)
group.add_maintainer(user)
sign_in user
end
context 'at group level' do
it_behaves_like 'bulk edit option in sidebar', :group
it_behaves_like 'bulk edit health status', :group
end
context 'at project level' do
it_behaves_like 'bulk edit option in sidebar', :project
it_behaves_like 'bulk edit health status', :project
end
end
context 'as a guest', :js do
before do
sign_in user
allow(group).to receive(:feature_enabled?).and_return(true)
stub_licensed_features(issuable_health_status: true)
end
context 'at group level' do
before do
visit issues_group_path(group)
end
it_behaves_like 'bulk edit health_status with insufficient permissions'
end
context 'at project level' do
before do
visit project_issues_path(project)
end
it_behaves_like 'bulk edit health_status with insufficient permissions'
end
end
def open_health_status_dropdown(items = [])
page.within('.issues-bulk-update') do
click_button 'Select health status'
items.map do |item|
find('.gl-button-text', { text: item }).click
end
end
end
def toggle_issue(issue, uncheck = false)
page.within('.issues-list') do
if uncheck
uncheck "selected_issue_#{issue.id}"
else
check "selected_issue_#{issue.id}"
end
end
end
def uncheck_issue(issue)
toggle_issue(issue, uncheck: true)
end
def update_issues
find('.update-selected-issues').click
wait_for_requests
end
def enable_bulk_update(context)
if context == :project
visit project_issues_path(project)
else
visit issues_group_path(group)
end
click_button 'Edit issues'
end
end
......@@ -12,8 +12,8 @@ RSpec.describe Issuable::BulkUpdateService do
shared_examples 'updates issuables attribute' do |attribute|
it 'succeeds and returns the correct number of issuables updated' do
expect(subject[:success]).to be_truthy
expect(subject[:count]).to eq(issuables.count)
expect(subject.success?).to be_truthy
expect(subject.payload[:count]).to eq(issuables.count)
issuables.each do |issuable|
expect(issuable.reload.send(attribute)).to eq(new_value)
end
......@@ -31,48 +31,55 @@ RSpec.describe Issuable::BulkUpdateService do
context 'with issues' do
let_it_be(:type) { 'issue' }
let_it_be(:parent) { group }
let(:issue1) { create(:issue, project: project1, health_status: :at_risk, epic: epic) }
let(:issue2) { create(:issue, project: project2, health_status: :at_risk, epic: epic) }
let(:epic) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group) }
let(:issue1) { create(:issue, project: project1, health_status: :at_risk) }
let(:issue2) { create(:issue, project: project2, health_status: :at_risk) }
let(:issuables) { [issue1, issue2] }
before do
group.add_reporter(user)
end
context 'updating health status and epic' do
context 'updating health status' do
let(:params) do
{
issuable_ids: issuables.map(&:id),
health_status: :on_track,
epic: epic2
health_status: :on_track
}
end
context 'when features are enabled' do
before do
stub_licensed_features(epics: true, issuable_health_status: true)
stub_licensed_features(issuable_health_status: true)
end
it 'succeeds and returns the correct number of issuables updated' do
expect(subject[:success]).to be_truthy
expect(subject[:count]).to eq(issuables.count)
expect(subject.success?).to be_truthy
expect(subject.payload[:count]).to eq(issuables.count)
issuables.each do |issuable|
expect(issuable.reload.health_status).to eq('on_track')
end
end
context "when params value is '0'" do
let(:params) { { issuable_ids: issuables.map(&:id), health_status: '0' } }
it 'succeeds and remove values' do
expect(subject.success?).to be_truthy
expect(subject.payload[:count]).to eq(issuables.count)
issuables.each do |issuable|
issuable.reload
expect(issuable.epic).to eq(epic2)
expect(issuable.health_status).to eq('on_track')
expect(issuable.health_status).to be_nil
end
end
end
end
context 'when features are disabled' do
context 'when feature issuable_health_status is disabled' do
before do
stub_licensed_features(epics: false, issuable_health_status: false)
stub_licensed_features(issuable_health_status: false)
end
it_behaves_like 'does not update issuables attribute', :health_status
it_behaves_like 'does not update issuables attribute', :epic
end
context 'when user can not update issues' do
......@@ -81,14 +88,6 @@ RSpec.describe Issuable::BulkUpdateService do
end
it_behaves_like 'does not update issuables attribute', :health_status
it_behaves_like 'does not update issuables attribute', :epic
end
context 'when user can not admin epic' do
let(:epic3) { create(:epic, group: create(:group)) }
let(:params) { { issuable_ids: issuables.map(&:id), epic: epic3 } }
it_behaves_like 'does not update issuables attribute', :epic
end
end
end
......@@ -140,8 +139,8 @@ RSpec.describe Issuable::BulkUpdateService do
let(:params) { { issuable_ids: [epic1.id, epic3.id, outer_epic.id], add_label_ids: [label3.id] } }
it 'updates epics that belong to the parent group or descendants' do
expect(subject[:success]).to be_truthy
expect(subject[:count]).to eq(2)
expect(subject.success?).to be_truthy
expect(subject.payload[:count]).to eq(2)
expect(epic1.reload.labels).to eq([label1, label3])
expect(epic3.reload.labels).to eq([label1, label3])
......
......@@ -11749,6 +11749,9 @@ msgstr ""
msgid "Health information can be retrieved from the following endpoints. More information is available"
msgstr ""
msgid "Health status"
msgstr ""
msgid "HealthCheck|Access token is"
msgstr ""
......@@ -15391,6 +15394,9 @@ msgstr ""
msgid "No start date"
msgstr ""
msgid "No status"
msgstr ""
msgid "No template"
msgstr ""
......
......@@ -18,8 +18,8 @@ RSpec.describe Issuable::BulkUpdateService do
it 'succeeds' do
result = bulk_update(issuables, milestone_id: milestone.id)
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(issuables.count)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(issuables.count)
end
it 'updates the issuables milestone' do
......@@ -121,8 +121,8 @@ RSpec.describe Issuable::BulkUpdateService do
it 'succeeds and returns the correct number of issues updated' do
result = bulk_update(issues, state_event: 'close')
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(issues.count)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(issues.count)
end
it 'closes all the issues passed' do
......@@ -139,8 +139,8 @@ RSpec.describe Issuable::BulkUpdateService do
it 'succeeds and returns the correct number of issues updated' do
result = bulk_update(issues, state_event: 'reopen')
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(issues.count)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(issues.count)
end
it 'reopens all the issues passed' do
......@@ -161,8 +161,8 @@ RSpec.describe Issuable::BulkUpdateService do
result = bulk_update(merge_request, assignee_ids: [user.id, new_assignee.id])
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(1)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(1)
end
it 'updates the assignee to the user ID passed' do
......@@ -199,8 +199,8 @@ RSpec.describe Issuable::BulkUpdateService do
result = bulk_update(issue, assignee_ids: [new_assignee.id])
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(1)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(1)
end
it 'updates the assignee to the user ID passed' do
......@@ -273,8 +273,8 @@ RSpec.describe Issuable::BulkUpdateService do
issue2 = create(:issue, project: create(:project))
result = bulk_update([issue1, issue2], assignee_ids: [user.id])
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(1)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(1)
expect(issue1.reload.assignees).to eq([user])
expect(issue2.reload.assignees).to be_empty
......@@ -332,8 +332,8 @@ RSpec.describe Issuable::BulkUpdateService do
milestone = create(:milestone, group: group)
result = bulk_update([issue1, issue2, issue3], milestone_id: milestone.id)
expect(result[:success]).to be_truthy
expect(result[:count]).to eq(2)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(2)
expect(issue1.reload.milestone).to eq(milestone)
expect(issue2.reload.milestone).to be_nil
......
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