Commit 106888fb authored by Nick Thomas's avatar Nick Thomas

Merge branch 'filter-merge-requests-by-target-branch' into 'master'

Filter merge requests by target branch

See merge request gitlab-org/gitlab-ce!24380
parents 6908c5f7 de784ac1
...@@ -13,4 +13,16 @@ export default IssuableTokenKeys => { ...@@ -13,4 +13,16 @@ export default IssuableTokenKeys => {
IssuableTokenKeys.tokenKeys.push(wipToken); IssuableTokenKeys.tokenKeys.push(wipToken);
IssuableTokenKeys.tokenKeysWithAlternative.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'; ...@@ -5,6 +5,7 @@ import DropdownEmoji from './dropdown_emoji';
import NullDropdown from './null_dropdown'; import NullDropdown from './null_dropdown';
import DropdownAjaxFilter from './dropdown_ajax_filter'; import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownUtils from './dropdown_utils'; import DropdownUtils from './dropdown_utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
export default class AvailableDropdownMappings { export default class AvailableDropdownMappings {
constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) { constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) {
...@@ -13,6 +14,7 @@ export default class AvailableDropdownMappings { ...@@ -13,6 +14,7 @@ export default class AvailableDropdownMappings {
this.groupsOnly = groupsOnly; this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups; this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups; this.includeDescendantGroups = includeDescendantGroups;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
} }
getAllowedMappings(supportedTokens) { getAllowedMappings(supportedTokens) {
...@@ -102,6 +104,15 @@ export default class AvailableDropdownMappings { ...@@ -102,6 +104,15 @@ export default class AvailableDropdownMappings {
}, },
element: this.container.querySelector('#js-dropdown-runner-tag'), 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 { ...@@ -130,4 +141,24 @@ export default class AvailableDropdownMappings {
getRunnerTagsEndpoint() { getRunnerTagsEndpoint() {
return `${this.baseEndpoint}/admin/runners/tag_list.json`; 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 { ...@@ -504,14 +504,7 @@ export default class FilteredSearchManager {
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
if (match) { if (match) {
// Use lastIndexOf because the token key is allowed to contain underscore const { key, symbol } = match;
// 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;
let quotationsToUse = ''; let quotationsToUse = '';
if (sanitizedValue.indexOf(' ') !== -1) { if (sanitizedValue.indexOf(' ') !== -1) {
...@@ -520,10 +513,10 @@ export default class FilteredSearchManager { ...@@ -520,10 +513,10 @@ export default class FilteredSearchManager {
} }
hasFilteredSearch = true; hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue); const canEdit = this.canEdit && this.canEdit(key, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match; const { uppercaseTokenName, capitalizeTokenValue } = match;
FilteredSearchVisualTokens.addFilterVisualToken( FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey, key,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
{ {
canEdit, canEdit,
......
...@@ -69,11 +69,21 @@ export default class FilteredSearchVisualTokens { ...@@ -69,11 +69,21 @@ export default class FilteredSearchVisualTokens {
} }
static addVisualTokenElement(name, value, options = {}) { 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'); const li = document.createElement('li');
li.classList.add('js-visual-token'); li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (!isSearchTerm) {
li.classList.add(tokenClass);
}
if (value) { if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit, canEdit,
......
...@@ -108,6 +108,8 @@ ...@@ -108,6 +108,8 @@
} }
.value-container { .value-container {
display: flex;
align-items: center;
background-color: $white-normal; background-color: $white-normal;
color: $filter-value-text-color; color: $filter-value-text-color;
border-radius: 0 2px 2px 0; border-radius: 0 2px 2px 0;
...@@ -121,7 +123,7 @@ ...@@ -121,7 +123,7 @@
.remove-token { .remove-token {
display: inline-block; display: inline-block;
padding-left: 4px; padding-left: 8px;
padding-right: 0; padding-right: 0;
.fa-close { .fa-close {
...@@ -412,3 +414,10 @@ ...@@ -412,3 +414,10 @@
padding: 8px 16px; padding: 8px 16px;
text-align: center; text-align: center;
} }
.search-token-target-branch {
.value {
font-family: $monospace-font;
font-size: 13px;
}
}
# frozen_string_literal: true # frozen_string_literal: true
class AutocompleteController < ApplicationController 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 def users
project = Autocomplete::ProjectFinder project = Autocomplete::ProjectFinder
...@@ -38,4 +38,11 @@ class AutocompleteController < ApplicationController ...@@ -38,4 +38,11 @@ class AutocompleteController < ApplicationController
def award_emojis def award_emojis
render json: AwardedEmojiFinder.new(current_user).execute render json: AwardedEmojiFinder.new(current_user).execute
end 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 end
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
# #
class MergeRequestsFinder < IssuableFinder class MergeRequestsFinder < IssuableFinder
def self.scalar_params def self.scalar_params
@scalar_params ||= super + [:wip] @scalar_params ||= super + [:wip, :target_branch]
end end
def klass def klass
......
...@@ -203,6 +203,22 @@ class MergeRequest < ActiveRecord::Base ...@@ -203,6 +203,22 @@ class MergeRequest < ActiveRecord::Base
'!' '!'
end 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? def rebase_in_progress?
strong_memoize(:rebase_in_progress) do strong_memoize(:rebase_in_progress) do
# The source project can be deleted # The source project can be deleted
......
...@@ -137,6 +137,11 @@ ...@@ -137,6 +137,11 @@
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
%button.btn.btn-link{ type: 'button' } %button.btn.btn-link{ type: 'button' }
= _('No') = _('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 = 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 ...@@ -43,6 +43,7 @@ Rails.application.routes.draw do
get '/autocomplete/users/:id' => 'autocomplete#user' get '/autocomplete/users/:id' => 'autocomplete#user'
get '/autocomplete/projects' => 'autocomplete#projects' get '/autocomplete/projects' => 'autocomplete#projects'
get '/autocomplete/award_emojis' => 'autocomplete#award_emojis' get '/autocomplete/award_emojis' => 'autocomplete#award_emojis'
get '/autocomplete/merge_request_target_branches' => 'autocomplete#merge_request_target_branches'
# Search # Search
get 'search' => 'search#show' get 'search' => 'search#show'
......
...@@ -371,5 +371,36 @@ describe AutocompleteController do ...@@ -371,5 +371,36 @@ describe AutocompleteController do
expect(json_response[3]).to match('name' => 'thumbsdown') expect(json_response[3]).to match('name' => 'thumbsdown')
end end
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
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 ...@@ -36,7 +36,7 @@ describe MergeRequestsFinder do
let(:project5) { create_project_without_n_plus_1(group: subgroup) } let(:project5) { create_project_without_n_plus_1(group: subgroup) }
let(:project6) { 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_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_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') } 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', () => { ...@@ -293,6 +293,7 @@ describe('Filtered Search Visual Tokens', () => {
subject.addVisualTokenElement('milestone'); subject.addVisualTokenElement('milestone');
const token = tokensContainer.querySelector('.js-visual-token'); 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.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('milestone'); expect(token.querySelector('.name').innerText).toEqual('milestone');
expect(token.querySelector('.value')).toEqual(null); expect(token.querySelector('.value')).toEqual(null);
...@@ -302,6 +303,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -302,6 +303,7 @@ describe('Filtered Search Visual Tokens', () => {
subject.addVisualTokenElement('label', 'Frontend'); subject.addVisualTokenElement('label', 'Frontend');
const token = tokensContainer.querySelector('.js-visual-token'); 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.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('label'); expect(token.querySelector('.name').innerText).toEqual('label');
expect(token.querySelector('.value').innerText).toEqual('Frontend'); expect(token.querySelector('.value').innerText).toEqual('Frontend');
...@@ -317,10 +319,12 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -317,10 +319,12 @@ describe('Filtered Search Visual Tokens', () => {
const labelToken = tokens[0]; const labelToken = tokens[0];
const assigneeToken = tokens[1]; const assigneeToken = tokens[1];
expect(labelToken.classList.contains('search-token-label')).toEqual(true);
expect(labelToken.classList.contains('filtered-search-token')).toEqual(true); expect(labelToken.classList.contains('filtered-search-token')).toEqual(true);
expect(labelToken.querySelector('.name').innerText).toEqual('label'); expect(labelToken.querySelector('.name').innerText).toEqual('label');
expect(labelToken.querySelector('.value').innerText).toEqual('Frontend'); 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.classList.contains('filtered-search-token')).toEqual(true);
expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee'); expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee');
expect(assigneeToken.querySelector('.value').innerText).toEqual('@root'); expect(assigneeToken.querySelector('.value').innerText).toEqual('@root');
......
...@@ -5,7 +5,7 @@ export default class FilteredSearchSpecHelper { ...@@ -5,7 +5,7 @@ export default class FilteredSearchSpecHelper {
static createFilterVisualToken(name, value, isSelected = false) { static createFilterVisualToken(name, value, isSelected = false) {
const li = document.createElement('li'); 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 = ` li.innerHTML = `
<div class="selectable ${isSelected ? 'selected' : ''}" role="button"> <div class="selectable ${isSelected ? 'selected' : ''}" role="button">
......
...@@ -270,6 +270,25 @@ describe MergeRequest do ...@@ -270,6 +270,25 @@ describe MergeRequest do
end end
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 describe '#target_branch_sha' do
let(:project) { create(:project, :repository) } 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