Commit 18bb17cf authored by Mike Greiling's avatar Mike Greiling

Merge branch 'id-1951-filter-merge-requests-by-approvers' into 'master'

Add filtering merge requests by approvers

See merge request gitlab-org/gitlab-ee!9468
parents 0b7187e0 99f13ce8
export default IssuableTokenKeys => {
const wipToken = {
key: 'wip',
type: 'string',
param: '',
symbol: '',
icon: 'admin',
tag: 'Yes or No',
lowercaseValueOnSubmit: true,
uppercaseTokenName: true,
capitalizeTokenValue: true,
};
IssuableTokenKeys.tokenKeys.push(wipToken);
IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
};
import DropdownHint from './dropdown_hint';
import DropdownUser from './dropdown_user';
import DropdownNonUser from './dropdown_non_user';
import DropdownEmoji from './dropdown_emoji';
import NullDropdown from './null_dropdown';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownUtils from './dropdown_utils';
export default class AvailableDropdownMappings {
constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) {
this.container = container;
this.baseEndpoint = baseEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
}
getAllowedMappings(supportedTokens) {
return this.buildMappings(supportedTokens, this.getMappings());
}
buildMappings(supportedTokens, availableMappings) {
const allowedMappings = {
hint: {
reference: null,
gl: DropdownHint,
element: this.container.querySelector('#js-dropdown-hint'),
},
};
supportedTokens.forEach(type => {
if (availableMappings[type]) {
allowedMappings[type] = availableMappings[type];
}
});
return allowedMappings;
}
getMappings() {
return {
author: {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getMilestoneEndpoint(),
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getLabelsEndpoint(),
symbol: '~',
preprocessing: DropdownUtils.duplicateLabelPreprocessing,
},
element: this.container.querySelector('#js-dropdown-label'),
},
'my-reaction': {
reference: null,
gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
wip: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
confidential: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-confidential'),
},
status: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-status'),
},
type: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-type'),
},
tag: {
reference: null,
gl: DropdownAjaxFilter,
extraArguments: {
endpoint: this.getRunnerTagsEndpoint(),
symbol: '~',
},
element: this.container.querySelector('#js-dropdown-runner-tag'),
},
};
}
getMilestoneEndpoint() {
return `${this.baseEndpoint}/milestones.json`;
}
getLabelsEndpoint() {
let endpoint = `${this.baseEndpoint}/labels.json?`;
if (this.groupsOnly) {
endpoint = `${endpoint}only_group_labels=true&`;
}
if (this.includeAncestorGroups) {
endpoint = `${endpoint}include_ancestor_groups=true&`;
}
if (this.includeDescendantGroups) {
endpoint = `${endpoint}include_descendant_groups=true`;
}
return endpoint;
}
getRunnerTagsEndpoint() {
return `${this.baseEndpoint}/admin/runners/tag_list.json`;
}
}
import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings';
import _ from 'underscore';
import DropLab from '~/droplab/drop_lab';
import DropdownWeight from 'ee/filtered_search/dropdown_weight';
import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils';
import DropdownHint from './dropdown_hint';
import DropdownEmoji from './dropdown_emoji';
import DropdownNonUser from './dropdown_non_user';
import DropdownUser from './dropdown_user';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import NullDropdown from './null_dropdown';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager {
......@@ -51,127 +45,15 @@ export default class FilteredSearchDropdownManager {
setupMapping() {
const supportedTokens = this.filteredSearchTokenKeys.getKeys();
const allowedMappings = {
hint: {
reference: null,
gl: DropdownHint,
element: this.container.querySelector('#js-dropdown-hint'),
},
};
const availableMappings = {
author: {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-author'),
},
assignee: {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
milestone: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getMilestoneEndpoint(),
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
},
label: {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getLabelsEndpoint(),
symbol: '~',
preprocessing: DropdownUtils.duplicateLabelPreprocessing,
},
element: this.container.querySelector('#js-dropdown-label'),
},
'my-reaction': {
reference: null,
gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
wip: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
confidential: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-confidential'),
},
status: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-status'),
},
type: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-type'),
},
tag: {
reference: null,
gl: DropdownAjaxFilter,
extraArguments: {
endpoint: this.getRunnerTagsEndpoint(),
symbol: '~',
},
element: this.container.querySelector('#js-dropdown-runner-tag'),
},
// EE-only start
weight: {
reference: null,
gl: DropdownWeight,
element: this.container.querySelector('#js-dropdown-weight'),
},
// EE-only end
};
supportedTokens.forEach(type => {
if (availableMappings[type]) {
allowedMappings[type] = availableMappings[type];
}
});
this.mapping = allowedMappings;
}
getMilestoneEndpoint() {
let endpoint = `${this.baseEndpoint}/milestones.json`;
// EE-only
if (this.groupsOnly) {
endpoint = `${endpoint}?only_group_milestones=true`;
}
return endpoint;
}
getLabelsEndpoint() {
let endpoint = `${this.baseEndpoint}/labels.json?`;
if (this.groupsOnly) {
endpoint = `${endpoint}only_group_labels=true&`;
}
if (this.includeAncestorGroups) {
endpoint = `${endpoint}include_ancestor_groups=true&`;
}
if (this.includeDescendantGroups) {
endpoint = `${endpoint}include_descendant_groups=true`;
}
return endpoint;
}
const availableMappings = new AvailableDropdownMappings(
this.container,
this.baseEndpoint,
this.groupsOnly,
this.includeAncestorGroups,
this.includeDescendantGroups,
);
getRunnerTagsEndpoint() {
return `${this.baseEndpoint}/admin/runners/tag_list.json`;
this.mapping = availableMappings.getAllowedMappings(supportedTokens);
}
static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
......
......@@ -88,21 +88,4 @@ export default class FilteredSearchTokenKeys {
this.tokenKeys.push(confidentialToken);
this.tokenKeysWithAlternative.push(confidentialToken);
}
addExtraTokensForMergeRequests() {
const wipToken = {
key: 'wip',
type: 'string',
param: '',
symbol: '',
icon: 'admin',
tag: 'Yes or No',
lowercaseValueOnSubmit: true,
uppercaseTokenName: true,
capitalizeTokenValue: true,
};
this.tokenKeys.push(wipToken);
this.tokenKeysWithAlternative.push(wipToken);
}
}
import _ from 'underscore';
import AjaxCache from '~/lib/utils/ajax_cache';
import VisualTokenValue from 'ee_else_ce/filtered_search/visual_token_value';
import { objectToQueryString } from '~/lib/utils/common_utils';
import Flash from '../flash';
import FilteredSearchContainer from './container';
import UsersCache from '../lib/utils/users_cache';
import DropdownUtils from './dropdown_utils';
export default class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
......@@ -20,21 +16,6 @@ export default class FilteredSearchVisualTokens {
};
}
/**
* Returns a computed API endpoint
* and query string composed of values from endpointQueryParams
* @param {String} endpoint
* @param {String} endpointQueryParams
*/
static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
if (!endpointQueryParams) {
return endpoint;
}
const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
return `${endpoint}?${queryString}`;
}
static unselectTokens() {
const otherTokens = FilteredSearchContainer.container.querySelectorAll(
'.js-visual-token .selectable.selected',
......@@ -76,124 +57,15 @@ export default class FilteredSearchVisualTokens {
`;
}
static setTokenStyle(tokenContainer, backgroundColor, textColor) {
const token = tokenContainer;
token.style.backgroundColor = backgroundColor;
token.style.color = textColor;
if (textColor === '#FFFFFF') {
const removeToken = token.querySelector('.remove-token');
removeToken.classList.add('inverted');
}
return token;
}
static updateLabelTokenColor(tokenValueContainer, tokenValue) {
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const { baseEndpoint } = filteredSearchInput.dataset;
const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
`${baseEndpoint}/labels.json`,
filteredSearchInput.dataset.endpointQueryParams,
);
return AjaxCache.retrieve(labelsEndpoint)
.then(labels => {
const matchingLabel = (labels || []).find(
label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
);
if (!matchingLabel) {
return;
}
FilteredSearchVisualTokens.setTokenStyle(
tokenValueContainer,
matchingLabel.color,
matchingLabel.text_color,
);
})
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
const username = tokenValue.replace(/^@/, '');
return (
UsersCache.retrieve(username)
.then(user => {
if (!user) {
return;
}
/* eslint-disable no-param-reassign */
tokenValueContainer.dataset.originalValue = tokenValue;
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="">
${_.escape(user.name)}
`;
/* eslint-enable no-param-reassign */
})
// ignore error and leave username in the search bar
.catch(() => {})
);
}
static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
const container = tokenValueContainer;
const element = tokenValueElement;
const value = tokenValue;
return (
import(/* webpackChunkName: 'emoji' */ '../emoji')
.then(Emoji => {
Emoji.initEmojiMap()
.then(() => {
if (!Emoji.isEmojiNameValid(value)) {
return;
}
container.dataset.originalValue = value;
element.innerHTML = Emoji.glEmojiTag(value);
})
// ignore error and leave emoji name in the search bar
.catch(err => {
throw err;
});
})
// ignore error and leave emoji name in the search bar
.catch(importError => {
throw importError;
})
);
}
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue;
if (['none', 'any'].includes(tokenValue.toLowerCase())) {
return;
}
const visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
const tokenType = tokenName.toLowerCase();
if (tokenType === 'label') {
FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
} else if (tokenType === 'author' || tokenType === 'assignee') {
FilteredSearchVisualTokens.updateUserTokenAppearance(
tokenValueContainer,
tokenValueElement,
tokenValue,
);
} else if (tokenType === 'my-reaction') {
FilteredSearchVisualTokens.updateEmojiTokenAppearance(
tokenValueContainer,
tokenValueElement,
tokenValue,
);
}
visualTokenValue.render(tokenValueContainer, tokenValueElement);
}
static addVisualTokenElement(name, value, options = {}) {
......@@ -328,6 +200,21 @@ export default class FilteredSearchVisualTokens {
}
}
/**
* Returns a computed API endpoint
* and query string composed of values from endpointQueryParams
* @param {String} endpoint
* @param {String} endpointQueryParams
*/
static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
if (!endpointQueryParams) {
return endpoint;
}
const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
return `${endpoint}?${queryString}`;
}
static editToken(token) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
......
import _ from 'underscore';
import FilteredSearchContainer from '~/filtered_search/container';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
import AjaxCache from '~/lib/utils/ajax_cache';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import Flash from '~/flash';
import UsersCache from '~/lib/utils/users_cache';
export default class VisualTokenValue {
constructor(tokenValue, tokenType) {
this.tokenValue = tokenValue;
this.tokenType = tokenType;
}
render(tokenValueContainer, tokenValueElement) {
const { tokenType } = this;
if (['none', 'any'].includes(tokenType)) {
return;
}
if (tokenType === 'label') {
this.updateLabelTokenColor(tokenValueContainer);
} else if (tokenType === 'author' || tokenType === 'assignee') {
this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement);
} else if (tokenType === 'my-reaction') {
this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement);
}
}
updateUserTokenAppearance(tokenValueContainer, tokenValueElement) {
const { tokenValue } = this;
const username = this.tokenValue.replace(/^@/, '');
return (
UsersCache.retrieve(username)
.then(user => {
if (!user) {
return;
}
/* eslint-disable no-param-reassign */
tokenValueContainer.dataset.originalValue = tokenValue;
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="">
${_.escape(user.name)}
`;
/* eslint-enable no-param-reassign */
})
// ignore error and leave username in the search bar
.catch(() => {})
);
}
updateLabelTokenColor(tokenValueContainer) {
const { tokenValue } = this;
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const { baseEndpoint } = filteredSearchInput.dataset;
const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
`${baseEndpoint}/labels.json`,
filteredSearchInput.dataset.endpointQueryParams,
);
return AjaxCache.retrieve(labelsEndpoint)
.then(labels => {
const matchingLabel = (labels || []).find(
label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
);
if (!matchingLabel) {
return;
}
VisualTokenValue.setTokenStyle(
tokenValueContainer,
matchingLabel.color,
matchingLabel.text_color,
);
})
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
static setTokenStyle(tokenValueContainer, backgroundColor, textColor) {
const token = tokenValueContainer;
token.style.backgroundColor = backgroundColor;
token.style.color = textColor;
if (textColor === '#FFFFFF') {
const removeToken = token.querySelector('.remove-token');
removeToken.classList.add('inverted');
}
return token;
}
updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement) {
const container = tokenValueContainer;
const element = tokenValueElement;
const value = this.tokenValue;
return (
import(/* webpackChunkName: 'emoji' */ '../emoji')
.then(Emoji => {
Emoji.initEmojiMap()
.then(() => {
if (!Emoji.isEmojiNameValid(value)) {
return;
}
container.dataset.originalValue = value;
element.innerHTML = Emoji.glEmojiTag(value);
})
// ignore error and leave emoji name in the search bar
.catch(err => {
throw err;
});
})
// ignore error and leave emoji name in the search bar
.catch(importError => {
throw importError;
})
);
}
}
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
......
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
......
......@@ -2,12 +2,13 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
......
......@@ -90,3 +90,5 @@ class MergeRequestsFinder < IssuableFinder
.or(table[:title].matches('[WIP]%'))
end
end
MergeRequestsFinder.prepend(EE::MergeRequestsFinder)
%ul.content-list.mr-list.issuable-list
- if @merge_requests.exists?
- if @merge_requests.present?
= render @merge_requests
- else
= render 'shared/empty_states/merge_requests'
......
......@@ -71,6 +71,7 @@
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
= render_if_exists 'shared/issuable/approver_dropdown'
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
......
This diff is collapsed.
......@@ -290,3 +290,10 @@ request itself. It belongs to the target branch's project.
## Approver suggestions
Approvers are suggested for merge requests based on the previous authors of the files affected by the merge request.
## Filtering merge requests by approvers
To filter merge requests by an individual approver, you can type (or select from
the dropdown) `approver` and select the user.
![Filter MRs by an approver](img/filter_approver_merge_requests.png)
import addExtraTokensForMergeRequests from '~/filtered_search/add_extra_tokens_for_merge_requests';
export default IssuableTokenKeys => {
addExtraTokensForMergeRequests(IssuableTokenKeys);
const approversConditions = [
{
url: 'approver_usernames[]=None',
tokenKey: 'approver',
value: 'None',
},
{
url: 'approver_usernames[]=Any',
tokenKey: 'approver',
value: 'Any',
},
];
const approversToken = {
key: 'approver',
type: 'array',
param: 'usernames[]',
symbol: '@',
icon: 'approval',
tag: '@approver',
};
const approversTokenPosition = 2;
IssuableTokenKeys.tokenKeys.splice(approversTokenPosition, 0, approversToken);
IssuableTokenKeys.tokenKeysWithAlternative.splice(approversTokenPosition, 0, approversToken);
IssuableTokenKeys.conditions.push(...approversConditions);
};
import DropdownUser from '~/filtered_search/dropdown_user';
import DropdownNonUser from '~/filtered_search/dropdown_non_user';
import DropdownWeight from './dropdown_weight';
import AvailableDropdownMappingsCE from '~/filtered_search/available_dropdown_mappings';
export default class AvailableDropdownMappings {
constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) {
this.container = container;
this.baseEndpoint = baseEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
this.ceAvailableMappings = new AvailableDropdownMappingsCE(
container,
baseEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
);
}
getAllowedMappings(supportedTokens) {
const ceMappings = this.ceAvailableMappings.getMappings();
ceMappings.milestone = {
reference: null,
gl: DropdownNonUser,
extraArguments: {
endpoint: this.getMilestoneEndpoint(),
symbol: '%',
},
element: this.container.querySelector('#js-dropdown-milestone'),
};
ceMappings.approver = {
reference: null,
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-approver'),
};
ceMappings.weight = {
reference: null,
gl: DropdownWeight,
element: this.container.querySelector('#js-dropdown-weight'),
};
return this.ceAvailableMappings.buildMappings(supportedTokens, ceMappings);
}
getMilestoneEndpoint() {
let endpoint = `${this.baseEndpoint}/milestones.json`;
if (this.groupsOnly) {
endpoint = `${endpoint}?only_group_milestones=true`;
}
return endpoint;
}
}
import VisualTokenValueCE from '~/filtered_search/visual_token_value';
export default class VisualTokenValue {
constructor(tokenValue, tokenType) {
this.tokenValue = tokenValue;
this.tokenType = tokenType;
this.visualTokenValueCE = new VisualTokenValueCE(tokenValue, tokenType);
}
render(tokenValueContainer, tokenValueElement) {
if (this.tokenType === 'approver') {
this.visualTokenValueCE.updateUserTokenAppearance(tokenValueContainer, tokenValueElement);
} else {
this.visualTokenValueCE.render(tokenValueContainer, tokenValueElement);
}
}
}
# frozen_string_literal: true
module EE
module MergeRequestsFinder
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
override :filter_items
def filter_items(items)
items = super(items)
by_approvers(items)
end
def by_approvers(items)
::MergeRequests::ByApproversFinder
.new(params[:approver_usernames], params[:approver_ids])
.execute(items)
end
class_methods do
extend ::Gitlab::Utils::Override
override :scalar_params
def scalar_params
@scalar_params ||= super + [:approver_ids]
end
override :array_params
def array_params
@array_params ||= super.merge(approver_usernames: [])
end
end
end
end
# frozen_string_literal: true
# MergeRequests::ByApprovers class
#
# Used to filter MergeRequests collections by approvers
module MergeRequests
class ByApproversFinder
attr_reader :usernames, :ids
def initialize(usernames, ids)
@usernames = usernames.to_a.map(&:to_s)
@ids = ids
end
def execute(items)
if by_no_approvers?
without_approvers(items)
elsif by_any_approvers?
with_any_approvers(items)
elsif ids.present?
find_approvers_by_ids(items)
elsif usernames.present?
find_approvers_by_names(items)
else
items
end
end
private
def by_no_approvers?
includes_custom_label?(IssuableFinder::FILTER_NONE)
end
def by_any_approvers?
includes_custom_label?(IssuableFinder::FILTER_ANY)
end
def includes_custom_label?(label)
ids.to_s.downcase == label || usernames.map(&:downcase).include?(label)
end
# rubocop: disable CodeReuse/ActiveRecord
def without_approvers(items)
items
.left_outer_joins(:approval_rules)
.joins('LEFT OUTER JOIN approval_project_rules ON approval_project_rules.project_id = merge_requests.target_project_id')
.where(approval_merge_request_rules: { id: nil })
.where(approval_project_rules: { id: nil })
end
def with_any_approvers(items)
items.select_from_union([
items.joins(:approval_rules),
items.joins('INNER JOIN approval_project_rules ON approval_project_rules.project_id = merge_requests.target_project_id')
])
end
def find_approvers_by_names(items)
with_users_filtered_by_criteria(items) do |items_with_users|
find_approvers_by_query(items_with_users, :username, usernames)
end
end
def find_approvers_by_ids(items)
with_users_filtered_by_criteria(items) do |items_with_users|
find_approvers_by_query(items_with_users, :id, ids)
end
end
def find_approvers_by_query(items, field, values)
items
.where(users: { field => values })
.group('merge_requests.id')
.having("COUNT(users.id) = ?", values.size)
end
def with_users_filtered_by_criteria(items)
users_mrs = yield(items.joins(approval_rules: :users))
group_users_mrs = yield(items.joins(approval_rules: { groups: :users }))
mrs_without_overriden_rules = items.left_outer_joins(:approval_rules).where(approval_merge_request_rules: { id: nil })
project_users_mrs = yield(mrs_without_overriden_rules.joins(target_project: { approval_rules: :users }))
project_group_users_mrs = yield(mrs_without_overriden_rules.joins(target_project: { approval_rules: { groups: :users } }))
items.select_from_union([users_mrs, group_users_mrs, project_users_mrs, project_group_users_mrs])
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
......@@ -7,6 +7,7 @@ module EE
include ::Approvable
include ::Gitlab::Utils::StrongMemoize
include FromUnion
prepend ApprovableForRule
prepended do
......@@ -33,6 +34,12 @@ module EE
accepts_nested_attributes_for :approval_rules, allow_destroy: true
end
class_methods do
def select_from_union(relations)
from_union(relations, remove_duplicates: true)
end
end
override :mergeable?
def mergeable?(skip_ci_check: false)
return false unless approved?
......
#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{ type: 'button' }
= _('None')
%li.filter-dropdown-item{ data: { value: 'Any' } }
%button.btn.btn-link{ type: 'button' }
= _('Any')
%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}}' }
---
title: Add filtering merge requests by approvers
merge_request: 9468
author:
type: added
......@@ -14,6 +14,11 @@ module EE
optional :approvals_required, type: Integer, desc: 'Total number of approvals required'
end
end
params :optional_merge_requests_search_params do
optional :approver_ids, types: [String, Array], array_none_any: true,
desc: 'Return merge requests which have specified the users with the given IDs as an individual approver'
end
end
params do
......
......@@ -7,6 +7,19 @@ FactoryBot.modify do
create :approver, target: merge_request
end
end
transient do
approval_groups []
approval_users []
end
after :create do |merge_request, evaluator|
next if evaluator.approval_users.blank? && evaluator.approval_groups.blank?
rule = merge_request.approval_rules.first_or_create(attributes_for(:approval_merge_request_rule))
rule.users = evaluator.approval_users if evaluator.approval_users.present?
rule.groups = evaluator.approval_groups if evaluator.approval_groups.present?
end
end
end
......
# frozen_string_literal: true
require 'rails_helper'
describe 'Merge Requests > User filters by approvers', :js do
include FilteredSearchHelpers
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
let(:group_user) { create(:user) }
let(:first_user) { create(:user) }
let!(:merge_request_with_approver) do
create(:merge_request, approval_users: [first_user], title: 'Bugfix1', source_project: project, source_branch: 'bugfix1')
end
let!(:merge_request_with_two_approvers) do
create(:merge_request, title: 'Bugfix2', approval_users: [user, first_user], source_project: project, source_branch: 'bugfix2')
end
let!(:merge_request) { create(:merge_request, title: 'Bugfix3', source_project: project, source_branch: 'bugfix3') }
let!(:merge_request_with_group_approver) do
group = create(:group)
group.add_developer(group_user)
create(:merge_request, approval_groups: [group], title: 'Bugfix4', source_project: project, source_branch: 'bugfix4')
end
before do
sign_in(user)
visit project_merge_requests_path(project)
end
context 'filtering by approver:none' do
it 'applies the filter' do
input_filtered_search('approver:none')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).not_to have_content 'Bugfix1'
expect(page).not_to have_content 'Bugfix2'
expect(page).not_to have_content 'Bugfix4'
expect(page).to have_content 'Bugfix3'
end
end
context 'filtering by approver:any' do
it 'applies the filter' do
input_filtered_search('approver:any')
expect(page).to have_issuable_counts(open: 3, closed: 0, all: 3)
expect(page).to have_content 'Bugfix1'
expect(page).to have_content 'Bugfix2'
expect(page).to have_content 'Bugfix4'
expect(page).not_to have_content 'Bugfix3'
end
end
context 'filtering by approver:@username' do
it 'applies the filter' do
input_filtered_search("approver:@#{first_user.username}")
expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
expect(page).to have_content 'Bugfix1'
expect(page).to have_content 'Bugfix2'
expect(page).not_to have_content 'Bugfix3'
expect(page).not_to have_content 'Bugfix4'
end
end
context 'filtering by multiple approvers' do
it 'applies the filter' do
input_filtered_search("approver:@#{first_user.username} approver:@#{user.username}")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2'
expect(page).not_to have_content 'Bugfix1'
expect(page).not_to have_content 'Bugfix3'
expect(page).not_to have_content 'Bugfix4'
end
end
context 'filtering by an approver from a group' do
it 'applies the filter' do
input_filtered_search("approver:@#{group_user.username}")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix4'
expect(page).not_to have_content 'Bugfix1'
expect(page).not_to have_content 'Bugfix2'
expect(page).not_to have_content 'Bugfix3'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe MergeRequests::ByApproversFinder do
let(:group_user) { create(:user) }
let(:second_group_user) { create(:user) }
let(:group) do
create(:group).tap do |group|
group.add_developer(group_user)
group.add_developer(second_group_user)
end
end
let!(:merge_request) { create(:merge_request) }
let!(:merge_request_with_approver) { create(:merge_request_with_approver) }
let(:project_user) { create(:user) }
let(:first_user) { merge_request_with_approver.approvers.first.user }
let(:second_user) { create(:user) }
let(:second_project_user) { create(:user) }
let!(:merge_request_with_project_approver) do
rule = create(:approval_project_rule, users: [project_user, second_project_user])
create(:merge_request, source_project: create(:project, approval_rules: [rule]))
end
let!(:merge_request_with_two_approvers) { create(:merge_request, approval_users: [first_user, second_user]) }
let!(:merge_request_with_group_approver) do
create(:merge_request).tap do |merge_request|
rule = create(:approval_merge_request_rule, merge_request: merge_request, groups: [group])
merge_request.approval_rules << rule
end
end
let!(:merge_request_with_project_group_approver) do
rule = create(:approval_project_rule, groups: [group])
create(:merge_request, source_project: create(:project, approval_rules: [rule]))
end
def merge_requests(ids: nil, names: [])
described_class.new(names, ids).execute(MergeRequest.all)
end
context 'filter by no approvers' do
it 'returns merge requests without approvers' do
expect(merge_requests(ids: 'None')).to eq([merge_request])
expect(merge_requests(names: ['None'])).to eq([merge_request])
end
end
context 'filter by any approver' do
it 'returns only merge requests with approvers' do
expect(merge_requests(ids: 'Any')).to match_array([
merge_request_with_approver, merge_request_with_two_approvers,
merge_request_with_group_approver, merge_request_with_project_approver,
merge_request_with_project_group_approver
])
expect(merge_requests(names: ['Any'])).to match_array([
merge_request_with_approver, merge_request_with_two_approvers,
merge_request_with_group_approver, merge_request_with_project_approver,
merge_request_with_project_group_approver
])
end
end
context 'filter by second approver' do
it 'returns only merge requests with the second approver' do
expect(merge_requests(ids: [second_user.id])).to eq(
[merge_request_with_two_approvers]
)
expect(merge_requests(names: [second_user.username])).to eq(
[merge_request_with_two_approvers]
)
end
end
context 'filter by both approvers' do
it 'returns only merge requests with both approvers' do
expect(merge_requests(ids: [first_user.id, second_user.id])).to eq(
[merge_request_with_two_approvers]
)
expect(merge_requests(names: [first_user.username, second_user.username])).to eq(
[merge_request_with_two_approvers]
)
end
end
context 'pass empty params' do
it 'returns all merge requests' do
expect(merge_requests(ids: [])).to match_array([
merge_request, merge_request_with_approver,
merge_request_with_two_approvers, merge_request_with_group_approver,
merge_request_with_project_approver, merge_request_with_project_group_approver
])
expect(merge_requests(names: [])).to match_array([
merge_request, merge_request_with_approver,
merge_request_with_two_approvers, merge_request_with_group_approver,
merge_request_with_project_approver, merge_request_with_project_group_approver
])
end
end
context 'filter by an approver from group' do
it 'returns only merge requests with the approver from group' do
expect(merge_requests(ids: [group_user.id])).to match_array(
[merge_request_with_project_group_approver, merge_request_with_group_approver]
)
expect(merge_requests(names: [group_user.username])).to match_array(
[merge_request_with_project_group_approver, merge_request_with_group_approver]
)
expect(merge_requests(names: [first_user.username, group_user.username])).to match_array([])
expect(merge_requests(names: [group_user.username, second_group_user.username])).to match_array(
[merge_request_with_project_group_approver, merge_request_with_group_approver]
)
end
end
context 'filter by an overridden approver from project' do
it 'returns only merge requests with the project approver' do
expect(merge_requests(ids: [project_user.id])).to eq(
[merge_request_with_project_approver]
)
expect(merge_requests(ids: [first_user.id, project_user.id])).to eq([])
expect(merge_requests(ids: [project_user.id, second_project_user.id])).to eq(
[merge_request_with_project_approver]
)
expect(merge_requests(names: [project_user.username])).to eq(
[merge_request_with_project_approver]
)
expect(merge_requests(names: [first_user.username, project_user.username])).to eq([])
expect(merge_requests(names: [project_user.username, second_project_user.username])).to eq(
[merge_request_with_project_approver]
)
end
end
end
......@@ -130,4 +130,57 @@ describe API::MergeRequests do
end
end
end
context 'when authenticated' do
def expect_response_contain_exactly(*items)
expect(response).to have_gitlab_http_status(200)
expect(json_response.length).to eq(items.size)
expect(json_response.map { |element| element['id'] }).to contain_exactly(*items.map(&:id))
end
let!(:merge_request_with_approver) do
create(:merge_request_with_approver, :simple, author: user, source_project: project, target_project: project, source_branch: 'other-branch')
end
let(:another_user) {}
context 'request merge requests' do
before do
get api('/merge_requests', user), params: { approver_ids: approvers_param, scope: :all }
end
context 'with specified approver id' do
let(:approvers_param) { [merge_request_with_approver.approvers.first.user_id] }
it 'returns an array of merge requests which have specified the user as an approver' do
expect_response_contain_exactly(merge_request_with_approver)
end
end
context 'with specified None as a param' do
let(:approvers_param) { 'None' }
it 'returns an array of merge requests with no approvers' do
expect_response_contain_exactly(merge_request)
end
end
context 'with specified Any as a param' do
let(:approvers_param) { 'Any' }
it 'returns an array of merge requests with any approver' do
expect_response_contain_exactly(merge_request_with_approver)
end
end
context 'with any other string as a param' do
let(:approvers_param) { 'any-other-string' }
it 'returns a validation error' do
expect(response).to have_gitlab_http_status(400)
expect(json_response['error']).to eq("approver_ids should be an array, 'None' or 'Any'")
end
end
end
end
end
......@@ -22,9 +22,22 @@ module API
message: "should be an integer, 'None' or 'Any'"
end
end
class ArrayNoneAny < Grape::Validations::Base
def validate_param!(attr_name, params)
value = params[attr_name]
return if value.is_a?(Array) ||
[IssuableFinder::FILTER_NONE, IssuableFinder::FILTER_ANY].include?(value.to_s.downcase)
raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)],
message: "should be an array, 'None' or 'Any'"
end
end
end
end
end
Grape::Validations.register_validator(:absence, ::API::Helpers::CustomValidators::Absence)
Grape::Validations.register_validator(:integer_none_any, ::API::Helpers::CustomValidators::IntegerNoneAny)
Grape::Validations.register_validator(:array_none_any, ::API::Helpers::CustomValidators::ArrayNoneAny)
......@@ -12,6 +12,9 @@ module API
helpers do
params :optional_params_ee do
end
params :optional_merge_requests_search_params do
end
end
def self.update_params_at_least_one_of
......@@ -114,6 +117,8 @@ module API
optional :search, type: String, desc: 'Search merge requests for text present in the title, description, or any combination of these'
optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma'
optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title'
use :optional_merge_requests_search_params
use :pagination
end
end
......
This diff is collapsed.
......@@ -50,6 +50,29 @@ describe API::Helpers::CustomValidators do
end
end
describe API::Helpers::CustomValidators::ArrayNoneAny do
subject do
described_class.new(['test'], {}, false, scope.new)
end
context 'valid parameters' do
it 'does not raise a validation error' do
expect_no_validation_error({ 'test' => [] })
expect_no_validation_error({ 'test' => [1, 2, 3] })
expect_no_validation_error({ 'test' => 'None' })
expect_no_validation_error({ 'test' => 'Any' })
expect_no_validation_error({ 'test' => 'none' })
expect_no_validation_error({ 'test' => 'any' })
end
end
context 'invalid parameters' do
it 'should raise a validation error' do
expect_validation_error({ 'test' => 'some_other_string' })
end
end
end
def expect_no_validation_error(params)
expect { validate_test_param!(params) }.not_to raise_error
end
......
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