Skip to content
Snippets Groups Projects
Commit de784ac1 authored by Hiroyuki Sato's avatar Hiroyuki Sato Committed by Nick Thomas
Browse files

Filter merge requests by target branch

parent 6908c5f7
No related branches found
No related tags found
No related merge requests found
Showing
with 204 additions and 16 deletions
Loading
Loading
@@ -13,4 +13,16 @@ export default IssuableTokenKeys => {
 
IssuableTokenKeys.tokenKeys.push(wipToken);
IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
const targetBranchToken = {
key: 'target-branch',
type: 'string',
param: '',
symbol: '',
icon: 'arrow-right',
tag: 'branch',
};
IssuableTokenKeys.tokenKeys.push(targetBranchToken);
IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken);
};
Loading
Loading
@@ -5,6 +5,7 @@ import DropdownEmoji from './dropdown_emoji';
import NullDropdown from './null_dropdown';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownUtils from './dropdown_utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
 
export default class AvailableDropdownMappings {
constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) {
Loading
Loading
@@ -13,6 +14,7 @@ export default class AvailableDropdownMappings {
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
}
 
getAllowedMappings(supportedTokens) {
Loading
Loading
@@ -102,6 +104,15 @@ export default class AvailableDropdownMappings {
},
element: this.container.querySelector('#js-dropdown-runner-tag'),
},
'target-branch': {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getMergeRequestTargetBranchesEndpoint(),
symbol: '',
},
element: this.container.querySelector('#js-dropdown-target-branch'),
},
};
}
 
Loading
Loading
@@ -130,4 +141,24 @@ export default class AvailableDropdownMappings {
getRunnerTagsEndpoint() {
return `${this.baseEndpoint}/admin/runners/tag_list.json`;
}
getMergeRequestTargetBranchesEndpoint() {
const endpoint = `${gon.relative_url_root ||
''}/autocomplete/merge_request_target_branches.json`;
const params = {
group_id: this.getGroupId(),
project_id: this.getProjectId(),
};
return mergeUrlParams(params, endpoint);
}
getGroupId() {
return this.filteredSearchInput.getAttribute('data-group-id') || '';
}
getProjectId() {
return this.filteredSearchInput.getAttribute('data-project-id') || '';
}
}
Loading
Loading
@@ -504,14 +504,7 @@ export default class FilteredSearchManager {
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
 
if (match) {
// Use lastIndexOf because the token key is allowed to contain underscore
// e.g. 'my_reaction' is the token key of 'my_reaction_emoji'
const lastIndexOf = keyParam.lastIndexOf('_');
let sanitizedKey = lastIndexOf !== -1 ? keyParam.slice(0, lastIndexOf) : keyParam;
// Replace underscore with hyphen in the sanitizedkey.
// e.g. 'my_reaction' => 'my-reaction'
sanitizedKey = sanitizedKey.replace('_', '-');
const { symbol } = match;
const { key, symbol } = match;
let quotationsToUse = '';
 
if (sanitizedValue.indexOf(' ') !== -1) {
Loading
Loading
@@ -520,10 +513,10 @@ export default class FilteredSearchManager {
}
 
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
const canEdit = this.canEdit && this.canEdit(key, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey,
key,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
{
canEdit,
Loading
Loading
Loading
Loading
@@ -69,11 +69,21 @@ export default class FilteredSearchVisualTokens {
}
 
static addVisualTokenElement(name, value, options = {}) {
const { isSearchTerm = false, canEdit, uppercaseTokenName, capitalizeTokenValue } = options;
const {
isSearchTerm = false,
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
tokenClass = `search-token-${name.toLowerCase()}`,
} = options;
const li = document.createElement('li');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
 
if (!isSearchTerm) {
li.classList.add(tokenClass);
}
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
Loading
Loading
Loading
Loading
@@ -108,6 +108,8 @@
}
 
.value-container {
display: flex;
align-items: center;
background-color: $white-normal;
color: $filter-value-text-color;
border-radius: 0 2px 2px 0;
Loading
Loading
@@ -121,7 +123,7 @@
 
.remove-token {
display: inline-block;
padding-left: 4px;
padding-left: 8px;
padding-right: 0;
 
.fa-close {
Loading
Loading
@@ -412,3 +414,10 @@
padding: 8px 16px;
text-align: center;
}
.search-token-target-branch {
.value {
font-family: $monospace-font;
font-size: 13px;
}
}
# frozen_string_literal: true
 
class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users, :award_emojis]
skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches]
 
def users
project = Autocomplete::ProjectFinder
Loading
Loading
@@ -38,4 +38,11 @@ class AutocompleteController < ApplicationController
def award_emojis
render json: AwardedEmojiFinder.new(current_user).execute
end
def merge_request_target_branches
merge_requests = MergeRequestsFinder.new(current_user, params).execute
target_branches = merge_requests.recent_target_branches
render json: target_branches.map { |target_branch| { title: target_branch } }
end
end
Loading
Loading
@@ -29,7 +29,7 @@
#
class MergeRequestsFinder < IssuableFinder
def self.scalar_params
@scalar_params ||= super + [:wip]
@scalar_params ||= super + [:wip, :target_branch]
end
 
def klass
Loading
Loading
Loading
Loading
@@ -203,6 +203,22 @@ class MergeRequest < ActiveRecord::Base
'!'
end
 
# Returns the top 100 target branches
#
# The returned value is a Array containing branch names
# sort by updated_at of merge request:
#
# ['master', 'develop', 'production']
#
# limit - The maximum number of target branch to return.
def self.recent_target_branches(limit: 100)
group(:target_branch)
.select(:target_branch)
.reorder('MAX(merge_requests.updated_at) DESC')
.limit(limit)
.pluck(:target_branch)
end
def rebase_in_progress?
strong_memoize(:rebase_in_progress) do
# The source project can be deleted
Loading
Loading
Loading
Loading
@@ -137,6 +137,11 @@
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
%button.btn.btn-link{ type: 'button' }
= _('No')
#js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value.monospace
{{title}}
 
= render_if_exists 'shared/issuable/filter_weight', type: type
 
Loading
Loading
---
title: Add target branch filter to merge requests search bar
merge_request: 24380
author: Hiroyuki Sato
type: added
Loading
Loading
@@ -43,6 +43,7 @@ Rails.application.routes.draw do
get '/autocomplete/users/:id' => 'autocomplete#user'
get '/autocomplete/projects' => 'autocomplete#projects'
get '/autocomplete/award_emojis' => 'autocomplete#award_emojis'
get '/autocomplete/merge_request_target_branches' => 'autocomplete#merge_request_target_branches'
 
# Search
get 'search' => 'search#show'
Loading
Loading
Loading
Loading
@@ -371,5 +371,36 @@ describe AutocompleteController do
expect(json_response[3]).to match('name' => 'thumbsdown')
end
end
context 'Get merge_request_target_branches' do
let(:user2) { create(:user) }
let!(:merge_request1) { create(:merge_request, source_project: project, target_branch: 'feature') }
context 'unauthorized user' do
it 'returns empty json' do
get :merge_request_target_branches
expect(json_response).to be_empty
end
end
context 'sign in as user without any accesible merge requests' do
it 'returns empty json' do
sign_in(user2)
get :merge_request_target_branches
expect(json_response).to be_empty
end
end
context 'sign in as user with a accesible merge request' do
it 'returns json' do
sign_in(user)
get :merge_request_target_branches
expect(json_response).to contain_exactly({ 'title' => 'feature' })
end
end
end
end
end
require 'rails_helper'
describe 'Merge Requests > User filters by target branch', :js do
include FilteredSearchHelpers
let!(:project) { create(:project, :public, :repository) }
let!(:user) { project.creator }
let!(:mr1) { create(:merge_request, source_project: project, target_project: project, source_branch: 'feature', target_branch: 'master') }
let!(:mr2) { create(:merge_request, source_project: project, target_project: project, source_branch: 'feature', target_branch: 'merged-target') }
before do
sign_in(user)
visit project_merge_requests_path(project)
end
context 'filtering by target-branch:master' do
it 'applies the filter' do
input_filtered_search('target-branch:master')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content mr1.title
expect(page).not_to have_content mr2.title
end
end
context 'filtering by target-branch:merged-target' do
it 'applies the filter' do
input_filtered_search('target-branch:merged-target')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).not_to have_content mr1.title
expect(page).to have_content mr2.title
end
end
context 'filtering by target-branch:feature' do
it 'applies the filter' do
input_filtered_search('target-branch:feature')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).not_to have_content mr1.title
expect(page).not_to have_content mr2.title
end
end
end
Loading
Loading
@@ -36,7 +36,7 @@ describe MergeRequestsFinder do
let(:project5) { create_project_without_n_plus_1(group: subgroup) }
let(:project6) { create_project_without_n_plus_1(group: subgroup) }
 
let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) }
let!(:merge_request1) { create(:merge_request, author: user, source_project: project2, target_project: project1, target_branch: 'merged-target') }
let!(:merge_request2) { create(:merge_request, :conflict, author: user, source_project: project2, target_project: project1, state: 'closed') }
let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2, state: 'locked', title: 'thing WIP thing') }
let!(:merge_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3, title: 'WIP thing') }
Loading
Loading
Loading
Loading
@@ -293,6 +293,7 @@ describe('Filtered Search Visual Tokens', () => {
subject.addVisualTokenElement('milestone');
const token = tokensContainer.querySelector('.js-visual-token');
 
expect(token.classList.contains('search-token-milestone')).toEqual(true);
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('milestone');
expect(token.querySelector('.value')).toEqual(null);
Loading
Loading
@@ -302,6 +303,7 @@ describe('Filtered Search Visual Tokens', () => {
subject.addVisualTokenElement('label', 'Frontend');
const token = tokensContainer.querySelector('.js-visual-token');
 
expect(token.classList.contains('search-token-label')).toEqual(true);
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('label');
expect(token.querySelector('.value').innerText).toEqual('Frontend');
Loading
Loading
@@ -317,10 +319,12 @@ describe('Filtered Search Visual Tokens', () => {
const labelToken = tokens[0];
const assigneeToken = tokens[1];
 
expect(labelToken.classList.contains('search-token-label')).toEqual(true);
expect(labelToken.classList.contains('filtered-search-token')).toEqual(true);
expect(labelToken.querySelector('.name').innerText).toEqual('label');
expect(labelToken.querySelector('.value').innerText).toEqual('Frontend');
 
expect(assigneeToken.classList.contains('search-token-assignee')).toEqual(true);
expect(assigneeToken.classList.contains('filtered-search-token')).toEqual(true);
expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee');
expect(assigneeToken.querySelector('.value').innerText).toEqual('@root');
Loading
Loading
Loading
Loading
@@ -5,7 +5,7 @@ export default class FilteredSearchSpecHelper {
 
static createFilterVisualToken(name, value, isSelected = false) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-token');
li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
 
li.innerHTML = `
<div class="selectable ${isSelected ? 'selected' : ''}" role="button">
Loading
Loading
Loading
Loading
@@ -270,6 +270,25 @@ describe MergeRequest do
end
end
 
describe '.recent_target_branches' do
let(:project) { create(:project) }
let!(:merge_request1) { create(:merge_request, :opened, source_project: project, target_branch: 'feature') }
let!(:merge_request2) { create(:merge_request, :closed, source_project: project, target_branch: 'merge-test') }
let!(:merge_request3) { create(:merge_request, :opened, source_project: project, target_branch: 'fix') }
let!(:merge_request4) { create(:merge_request, :closed, source_project: project, target_branch: 'feature') }
before do
merge_request1.update_columns(updated_at: 1.day.since)
merge_request2.update_columns(updated_at: 2.days.since)
merge_request3.update_columns(updated_at: 3.days.since)
merge_request4.update_columns(updated_at: 4.days.since)
end
it 'returns target branches sort by updated at desc' do
expect(described_class.recent_target_branches).to match_array(['feature', 'merge-test', 'fix'])
end
end
describe '#target_branch_sha' do
let(:project) { create(:project, :repository) }
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment