Merge branch 'filter-merge-requests-by-target-branch' into 'master'
Filter merge requests by target branch See merge request gitlab-org/gitlab-ce!24380
This commit is contained in:
commit
106888fb51
17 changed files with 204 additions and 16 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# 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
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
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
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
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) }
|
||||
|
||||
|
|
Loading…
Reference in a new issue