diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 1ae4bbf538a1514b677c0d5bdb0458d205fcfb97..2c8182ae3967585747825bb5e4c30a571c03601f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -67,6 +67,11 @@ export default class FilteredSearchDropdownManager { gl: DropdownUser, element: this.container.querySelector('#js-dropdown-assignee'), }, + approver: { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-approver'), + }, milestone: { reference: null, gl: DropdownNonUser, diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 087ef5cd6f2236ec431d667980f61045a298174b..1b76a81ee3a525807d62d6b86a61b8b99c2483ef 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -12,6 +12,13 @@ const tokenKeys = [{ symbol: '@', icon: 'user', tag: '@assignee', +}, { + key: 'approver', + type: 'string', + param: 'username', + symbol: '@', + icon: 'check', + tag: '@approver', }, { key: 'milestone', type: 'string', @@ -53,6 +60,10 @@ const conditions = [{ url: 'assignee_id=0', tokenKey: 'assignee', value: 'none', +}, { + url: 'approver_id=0', + tokenKey: 'approver', + value: 'none', }, { url: 'milestone_title=No+Milestone', tokenKey: 'milestone', diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 56fe1ab4e9038e27216910eeb8cd7a3161f375a7..b0843c341f5083eea9806d3b0bbecc5be840dedb 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -171,7 +171,7 @@ export default class FilteredSearchVisualTokens { const tokenType = tokenName.toLowerCase(); if (tokenType === 'label') { FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); - } else if ((tokenType === 'author') || (tokenType === 'assignee')) { + } else if ((tokenType === 'author') || (tokenType === 'assignee') || (tokenType === 'approver')) { FilteredSearchVisualTokens.updateUserTokenAppearance( tokenValueContainer, tokenValueElement, tokenValue, ); diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 6541434a3f0880ec1a755350f54c8f5c81f05ff7..5f92f06fc51ab513e511ee6edeb6031305be8fe1 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -5,6 +5,7 @@ class DashboardController < Dashboard::ApplicationController FILTER_PARAMS = [ :author_id, :assignee_id, + :approver_id, :milestone_title, :weight, :label_name diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 372e2a96c2c9457f038cd58980a987a3c6551961..5570f20291ca7b7309403aff931f68abd0840597 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -40,6 +40,8 @@ def self.scalar_params @scalar_params ||= %i[ assignee_id assignee_username + approver_id + approver_username author_id author_username authorized_only diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 40089c082c1240025fea253d724ccb0546d87359..e2c7f760ee52bb40a6f180d757015cd6f8abfdfb 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -12,6 +12,7 @@ # milestone_title: string # author_id: integer # assignee_id: integer +# approver_id: integer # search: string # label_name: string # sort: string @@ -31,12 +32,55 @@ def klass def filter_items(_items) items = by_source_branch(super) + items = by_approver(items) by_target_branch(items) end private + def by_approver(items) + if approvers.any? + approvers.each do |approver| + items = items.can_approve(approver) + end + + items + elsif no_approver? + items.without_approvers + elsif approver_id? || approver_username? # approver not found + items.none + else + items + end + end + + def approvers + return @approvers if defined?(@approvers) + + @approvers = + if params[:approver_ids] + User.where(id: params[:approver_ids]) + elsif params[:approver_username] + User.where(username: params[:approver_username]) + else + [] + end + end + + def approver_id? + params[:approver_id].present? && params[:approver_id] != NONE + end + + def approver_username? + params[:approver_username].present? && params[:approver_username] != NONE + end + + def no_approver? + # Approver_id takes precedence over approver_username + params[:approver_id] == NONE || params[:approver_username] == NONE + end + def source_branch @source_branch ||= params[:source_branch].presence end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index cd11e134b9b5473afcadfcbfd4167bbae9852960..0401d571965f270c26f8c400f8f2eb5ed6b3f28c 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -171,6 +171,9 @@ def check_state?(merge_status) scope :unassigned, -> { where("assignee_id IS NULL") } scope :assigned_to, ->(u) { where(assignee_id: u.id)} + scope :without_approvers, -> { includes(:approvers).where({ approvers: { user_id: nil } }) } + scope :can_approve, ->(u) { includes(:approvers).where({ approvers: { user_id: u.id } }) } + participant :assignee after_save :keep_around_commit diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 1cd8ce0826c8f698c8e448b19d48b0bfebd885b6..1ecd71a4d1ec5c74b4b1596a00383ba0b5a1b60e 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -2,7 +2,7 @@ .issues-filters .issues-details-filters.row-content-block.second-block - = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do + = form_tag page_filter_path(without: [:assignee_id, :approver_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do - if params[:search].present? = hidden_field_tag :search, params[:search] .issues-other-filters @@ -18,6 +18,13 @@ = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) + - if controller.controller_name == 'merge_requests' || controller.action_name == 'merge_requests' + .filter-item.inline + - if params[:approver_id].present? + = hidden_field_tag(:approver_id, params[:approver_id]) + = dropdown_tag(user_dropdown_label(params[:approver_id], "Approver"), options: { toggle_class: "js-user-search js-filter-submit js-approvers-search", title: "Filter by approvers", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-approver js-filter-submit", + placeholder: "Search approver", data: { any_user: "Any Approver", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:approver_id], field_name: "approver_id", default_label: "Approver" } }) + .filter-item.inline.milestone-filter = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 9ce7f6fe2699efed1a2b1a99471b809fa28ec846..145d5c9617d070f7cc6b4b66a447d382fe4d8585 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -69,6 +69,19 @@ = render 'shared/issuable/user_dropdown_item', user: User.new(username: '{{username}}', name: '{{name}}'), avatar: { lazy: true, url: '{{avatar_url}}' } + #js-dropdown-approver.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + No Approver + %li.divider.droplab-item-ignore + - if current_user + = render 'shared/issuable/user_dropdown_item', + user: current_user + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } diff --git a/ee/changelogs/unreleased/1951-filter-merge-requests-by-approver.yml b/ee/changelogs/unreleased/1951-filter-merge-requests-by-approver.yml new file mode 100644 index 0000000000000000000000000000000000000000..8d977c847880140fcc8380f3476308477f58b67e --- /dev/null +++ b/ee/changelogs/unreleased/1951-filter-merge-requests-by-approver.yml @@ -0,0 +1,5 @@ +--- +title: Add filter for merge requests by individual approver +merge_request: 6696 +author: Glavin Wiechert +type: added diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb index b99c5a7f4e33490453c1f4803a1c9e41e71470e4..9d61290200e699ab4c2f34c147d978c2074e67c3 100644 --- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb @@ -65,7 +65,7 @@ def click_hint(text) it 'filters with text' do filtered_search.set('a') - expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 4) + expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5) end end diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb index c44689228832761f88c11dffc66ce5c28b7a517a..3489f0ec9b683a1ce4545dea0e24348bd47e863c 100644 --- a/spec/features/issues/filtered_search/search_bar_spec.rb +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -100,7 +100,7 @@ def get_left_style(style) find('.filtered-search-box .clear-search').click filtered_search.click - expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6) + expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 7) expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset) end end