Commit de784ac1 authored by Hiroyuki Sato's avatar Hiroyuki Sato 🇯🇵 Committed by Nick Thomas

Filter merge requests by target branch

parent 6908c5f7
......@@ -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);
};
......@@ -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) {
......@@ -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) {
......@@ -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'),
},
};
}
......@@ -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') || '';
}
}
......@@ -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) {
......@@ -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,
......
......@@ -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,
......
......@@ -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;
......@@ -121,7 +123,7 @@
.remove-token {
display: inline-block;
padding-left: 4px;
padding-left: 8px;
padding-right: 0;
.fa-close {
......@@ -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
......@@ -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
......@@ -29,7 +29,7 @@
#
class MergeRequestsFinder < IssuableFinder
def self.scalar_params
@scalar_params ||= super + [:wip]
@scalar_params ||= super + [:wip, :target_branch]
end
def klass
......
......@@ -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
......
......@@ -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
......
---
title: Add target branch filter to merge requests search bar
merge_request: 24380
author: Hiroyuki Sato
type: added
......@@ -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'
......
......@@ -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
......@@ -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') }
......
......@@ -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);
......@@ -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');
......@@ -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');
......
......@@ -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">
......
......@@ -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) }
......
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