Commit d81fd9e9 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '237921-side-widget-add-reviewers' into 'master'

Part 1: Add reviewers to side widget (Setup HAML + JS)

Closes #237921

See merge request gitlab-org/gitlab!40424
parents f1c813d4 84f4c0fd
...@@ -6,6 +6,7 @@ import UsersSelect from './users_select'; ...@@ -6,6 +6,7 @@ import UsersSelect from './users_select';
export default class IssuableContext { export default class IssuableContext {
constructor(currentUser) { constructor(currentUser) {
this.userSelect = new UsersSelect(currentUser); this.userSelect = new UsersSelect(currentUser);
this.reviewersSelect = new UsersSelect(currentUser, '.js-reviewer-search');
import(/* webpackChunkName: 'select2' */ 'select2/select2') import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => { .then(() => {
......
...@@ -40,6 +40,17 @@ export default class SidebarMediator { ...@@ -40,6 +40,17 @@ export default class SidebarMediator {
return this.service.update(field, data); return this.service.update(field, data);
} }
saveReviewers(field) {
const selected = this.store.reviewers.map(u => u.id);
// If there are no ids, that means we have to unassign (which is id = 0)
// And it only accepts an array, hence [0]
const reviewers = selected.length === 0 ? [0] : selected;
const data = { reviewer_ids: reviewers };
return this.service.update(field, data);
}
setMoveToProjectId(projectId) { setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId); this.store.setMoveToProjectId(projectId);
} }
...@@ -55,6 +66,7 @@ export default class SidebarMediator { ...@@ -55,6 +66,7 @@ export default class SidebarMediator {
processFetchedData(data) { processFetchedData(data) {
this.store.setAssigneeData(data); this.store.setAssigneeData(data);
this.store.setReviewerData(data);
this.store.setTimeTrackingData(data); this.store.setTimeTrackingData(data);
this.store.setParticipantsData(data); this.store.setParticipantsData(data);
this.store.setSubscriptionsData(data); this.store.setSubscriptionsData(data);
......
...@@ -18,8 +18,10 @@ export default class SidebarStore { ...@@ -18,8 +18,10 @@ export default class SidebarStore {
this.humanTimeSpent = ''; this.humanTimeSpent = '';
this.timeTrackingLimitToHours = timeTrackingLimitToHours; this.timeTrackingLimitToHours = timeTrackingLimitToHours;
this.assignees = []; this.assignees = [];
this.reviewers = [];
this.isFetching = { this.isFetching = {
assignees: true, assignees: true,
reviewers: true,
participants: true, participants: true,
subscriptions: true, subscriptions: true,
}; };
...@@ -42,6 +44,13 @@ export default class SidebarStore { ...@@ -42,6 +44,13 @@ export default class SidebarStore {
} }
} }
setReviewerData(data) {
this.isFetching.reviewers = false;
if (data.reviewers) {
this.reviewers = data.reviewers;
}
}
setTimeTrackingData(data) { setTimeTrackingData(data) {
this.timeEstimate = data.time_estimate; this.timeEstimate = data.time_estimate;
this.totalTimeSpent = data.total_time_spent; this.totalTimeSpent = data.total_time_spent;
...@@ -75,20 +84,40 @@ export default class SidebarStore { ...@@ -75,20 +84,40 @@ export default class SidebarStore {
} }
} }
addReviewer(reviewer) {
if (!this.findReviewer(reviewer)) {
this.reviewers.push(reviewer);
}
}
findAssignee(findAssignee) { findAssignee(findAssignee) {
return this.assignees.find(assignee => assignee.id === findAssignee.id); return this.assignees.find(assignee => assignee.id === findAssignee.id);
} }
findReviewer(findReviewer) {
return this.reviewers.find(reviewer => reviewer.id === findReviewer.id);
}
removeAssignee(removeAssignee) { removeAssignee(removeAssignee) {
if (removeAssignee) { if (removeAssignee) {
this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
} }
} }
removeReviewer(removeReviewer) {
if (removeReviewer) {
this.reviewers = this.reviewers.filter(reviewer => reviewer.id !== removeReviewer.id);
}
}
removeAllAssignees() { removeAllAssignees() {
this.assignees = []; this.assignees = [];
} }
removeAllReviewers() {
this.reviewers = [];
}
setAssigneesFromRealtime(data) { setAssigneesFromRealtime(data) {
this.assignees = data; this.assignees = data;
} }
......
...@@ -19,6 +19,7 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; ...@@ -19,6 +19,7 @@ import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
window.emitSidebarEvent = window.emitSidebarEvent || $.noop; window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
function UsersSelect(currentUser, els, options = {}) { function UsersSelect(currentUser, els, options = {}) {
const elsClassName = els?.toString().match('.(.+$)')[1];
const $els = $(els || '.js-user-search'); const $els = $(els || '.js-user-search');
this.users = this.users.bind(this); this.users = this.users.bind(this);
this.user = this.user.bind(this); this.user = this.user.bind(this);
...@@ -127,11 +128,18 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -127,11 +128,18 @@ function UsersSelect(currentUser, els, options = {}) {
.find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`);
firstSelected.remove(); firstSelected.remove();
if ($dropdown.hasClass(elsClassName)) {
emitSidebarEvent('sidebar.removeReviewer', {
id: firstSelectedId,
});
} else {
emitSidebarEvent('sidebar.removeAssignee', { emitSidebarEvent('sidebar.removeAssignee', {
id: firstSelectedId, id: firstSelectedId,
}); });
} }
} }
}
}; };
const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { const getMultiSelectDropdownTitle = function(selectedUser, isSelected) {
...@@ -392,8 +400,12 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -392,8 +400,12 @@ function UsersSelect(currentUser, els, options = {}) {
defaultLabel, defaultLabel,
hidden() { hidden() {
if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass('js-multiselect')) {
if ($dropdown.hasClass(elsClassName)) {
emitSidebarEvent('sidebar.saveReviewers');
} else {
emitSidebarEvent('sidebar.saveAssignees'); emitSidebarEvent('sidebar.saveAssignees');
} }
}
if (!$dropdown.data('alwaysShowSelectbox')) { if (!$dropdown.data('alwaysShowSelectbox')) {
$selectbox.hide(); $selectbox.hide();
...@@ -428,10 +440,18 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -428,10 +440,18 @@ function UsersSelect(currentUser, els, options = {}) {
previouslySelected.each((index, element) => { previouslySelected.each((index, element) => {
element.remove(); element.remove();
}); });
if ($dropdown.hasClass(elsClassName)) {
emitSidebarEvent('sidebar.removeAllReviewers');
} else {
emitSidebarEvent('sidebar.removeAllAssignees'); emitSidebarEvent('sidebar.removeAllAssignees');
}
} else if (isActive) { } else if (isActive) {
// user selected // user selected
if ($dropdown.hasClass(elsClassName)) {
emitSidebarEvent('sidebar.addReviewer', user);
} else {
emitSidebarEvent('sidebar.addAssignee', user); emitSidebarEvent('sidebar.addAssignee', user);
}
// Remove unassigned selection (if it was previously selected) // Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown const unassignedSelected = $dropdown
...@@ -448,8 +468,12 @@ function UsersSelect(currentUser, els, options = {}) { ...@@ -448,8 +468,12 @@ function UsersSelect(currentUser, els, options = {}) {
} }
// User unselected // User unselected
if ($dropdown.hasClass(elsClassName)) {
emitSidebarEvent('sidebar.removeReviewer', user);
} else {
emitSidebarEvent('sidebar.removeAssignee', user); emitSidebarEvent('sidebar.removeAssignee', user);
} }
}
if (getSelected().find(u => u === gon.current_user_id)) { if (getSelected().find(u => u === gon.current_user_id)) {
$assignToMeLink.hide(); $assignToMeLink.hide();
......
...@@ -386,6 +386,12 @@ module IssuablesHelper ...@@ -386,6 +386,12 @@ module IssuablesHelper
end end
end end
def reviewer_sidebar_data(reviewer, merge_request: nil)
{ avatar_url: reviewer.avatar_url, name: reviewer.name, username: reviewer.username }.tap do |data|
data[:can_merge] = merge_request.can_be_merged_by?(reviewer) if merge_request
end
end
def issuable_squash_option?(issuable, project) def issuable_squash_option?(issuable, project)
if issuable.persisted? if issuable.persisted?
issuable.squash issuable.squash
......
...@@ -92,7 +92,7 @@ ...@@ -92,7 +92,7 @@
.loading.hide .loading.hide
.spinner.spinner-md .spinner.spinner-md
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, source_branch: @merge_request.source_branch = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch
- if @merge_request.can_be_reverted?(current_user) - if @merge_request.can_be_reverted?(current_user)
= render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
- signed_in = !!issuable_sidebar.dig(:current_user, :id) - signed_in = !!issuable_sidebar.dig(:current_user, :id)
- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit) - can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit)
- add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras" - add_page_startup_api_call "#{issuable_sidebar[:issuable_json_path]}?serializer=sidebar_extras"
- reviewers = local_assigns.fetch(:reviewers, nil)
- if Feature.enabled?(:vue_issuable_sidebar, @project.group) - if Feature.enabled?(:vue_issuable_sidebar, @project.group)
%aside#js-vue-issuable-sidebar{ data: { signed_in: signed_in, %aside#js-vue-issuable-sidebar{ data: { signed_in: signed_in,
...@@ -28,6 +29,10 @@ ...@@ -28,6 +29,10 @@
.block.assignee.qa-assignee-block .block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees
- if reviewers
.block.reviewer.qa-reviewer-block
= render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
- if issuable_sidebar[:supports_milestone] - if issuable_sidebar[:supports_milestone]
......
- issuable_type = issuable_sidebar[:type]
- signed_in = !!issuable_sidebar.dig(:current_user, :id)
#js-vue-sidebar-reviewers{ data: { field: issuable_type, signed_in: signed_in } }
.title.hide-collapsed
= _('Reviewer')
= loading_icon(css_class: 'gl-vertical-align-text-bottom')
.selectbox.hide-collapsed
- if reviewers.none?
= hidden_field_tag "#{issuable_type}[reviewer_ids][]", 0, id: nil
- else
- reviewers.each do |reviewer|
= hidden_field_tag "#{issuable_type}[reviewer_ids][]", reviewer.id, id: nil, data: reviewer_sidebar_data(reviewer, merge_request: @merge_request)
- options = { toggle_class: 'js-reviewer-search js-author-search',
title: _('Request review from'),
filter: true,
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author',
placeholder: _('Search users'),
data: { first_user: issuable_sidebar.dig(:current_user, :username),
current_user: true,
iid: issuable_sidebar[:iid],
issuable_type: issuable_type,
project_id: issuable_sidebar[:project_id],
author_id: issuable_sidebar[:author_id],
field_name: "#{issuable_type}[reviewer_ids][]",
issue_update: issuable_sidebar[:issuable_json_path],
ability_name: issuable_type,
null_user: true,
display: 'static' } }
- dropdown_options = reviewers_dropdown_options(issuable_type)
- title = dropdown_options[:title]
- options[:toggle_class] += ' js-multiselect js-save-user-data'
- data = { field_name: "#{issuable_type}[reviewer_ids][]" }
- data[:multi_select] = true
- data['dropdown-title'] = title
- data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
- data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
- options[:data].merge!(data)
- if experiment_enabled?(:invite_members_version_a) && can_import_members?
- options[:dropdown_class] += ' dropdown-extended-height'
- options[:footer_content] = true
- options[:wrapper_class] = 'js-sidebar-reviewer-dropdown'
= dropdown_tag(title, options: options) do
%ul.dropdown-footer-list
%li
= link_to _('Invite Members'),
project_project_members_path(@project),
title: _('Invite Members'),
data: { 'is-link': true, 'track-event': 'click_invite_members', 'track-label': 'edit_reviewer' }
- else
= dropdown_tag(title, options: options)
...@@ -21465,6 +21465,9 @@ msgstr "" ...@@ -21465,6 +21465,9 @@ msgstr ""
msgid "Request parameter %{param} is missing." msgid "Request parameter %{param} is missing."
msgstr "" msgstr ""
msgid "Request review from"
msgstr ""
msgid "Request to link SAML account must be authorized" msgid "Request to link SAML account must be authorized"
msgstr "" msgstr ""
...@@ -21735,6 +21738,9 @@ msgstr "" ...@@ -21735,6 +21738,9 @@ msgstr ""
msgid "ReviewApp|Enable Review App" msgid "ReviewApp|Enable Review App"
msgstr "" msgstr ""
msgid "Reviewer"
msgstr ""
msgid "Reviewing" msgid "Reviewing"
msgstr "" msgstr ""
......
...@@ -306,6 +306,38 @@ RSpec.describe IssuablesHelper do ...@@ -306,6 +306,38 @@ RSpec.describe IssuablesHelper do
end end
end end
describe '#reviewer_sidebar_data' do
let(:user) { create(:user) }
subject { helper.reviewer_sidebar_data(user, merge_request: merge_request) }
context 'without merge_request' do
let(:merge_request) { nil }
it 'returns hash of reviewer data' do
is_expected.to eql({
avatar_url: user.avatar_url,
name: user.name,
username: user.username
})
end
end
context 'with merge_request' do
let(:merge_request) { build(:merge_request) }
where(can_merge: [true, false])
with_them do
before do
allow(merge_request).to receive(:can_be_merged_by?).and_return(can_merge)
end
it { is_expected.to include({ can_merge: can_merge })}
end
end
end
describe '#issuable_squash_option?' do describe '#issuable_squash_option?' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
......
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