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 _ from 'underscore';
import DropLab from '~/droplab/drop_lab'; import DropLab from '~/droplab/drop_lab';
import DropdownWeight from 'ee/filtered_search/dropdown_weight';
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys'; import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils'; 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'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager { export default class FilteredSearchDropdownManager {
...@@ -51,127 +45,15 @@ export default class FilteredSearchDropdownManager { ...@@ -51,127 +45,15 @@ export default class FilteredSearchDropdownManager {
setupMapping() { setupMapping() {
const supportedTokens = this.filteredSearchTokenKeys.getKeys(); const supportedTokens = this.filteredSearchTokenKeys.getKeys();
const allowedMappings = { const availableMappings = new AvailableDropdownMappings(
hint: { this.container,
reference: null, this.baseEndpoint,
gl: DropdownHint, this.groupsOnly,
element: this.container.querySelector('#js-dropdown-hint'), this.includeAncestorGroups,
}, this.includeDescendantGroups,
}; );
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;
}
getRunnerTagsEndpoint() { this.mapping = availableMappings.getAllowedMappings(supportedTokens);
return `${this.baseEndpoint}/admin/runners/tag_list.json`;
} }
static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) { static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
......
...@@ -88,21 +88,4 @@ export default class FilteredSearchTokenKeys { ...@@ -88,21 +88,4 @@ export default class FilteredSearchTokenKeys {
this.tokenKeys.push(confidentialToken); this.tokenKeys.push(confidentialToken);
this.tokenKeysWithAlternative.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 VisualTokenValue from 'ee_else_ce/filtered_search/visual_token_value';
import AjaxCache from '~/lib/utils/ajax_cache';
import { objectToQueryString } from '~/lib/utils/common_utils'; import { objectToQueryString } from '~/lib/utils/common_utils';
import Flash from '../flash';
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
import UsersCache from '../lib/utils/users_cache';
import DropdownUtils from './dropdown_utils';
export default class FilteredSearchVisualTokens { export default class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() { static getLastVisualTokenBeforeInput() {
...@@ -20,21 +16,6 @@ export default class FilteredSearchVisualTokens { ...@@ -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() { static unselectTokens() {
const otherTokens = FilteredSearchContainer.container.querySelectorAll( const otherTokens = FilteredSearchContainer.container.querySelectorAll(
'.js-visual-token .selectable.selected', '.js-visual-token .selectable.selected',
...@@ -76,124 +57,15 @@ export default class FilteredSearchVisualTokens { ...@@ -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) { static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value'); const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue; tokenValueElement.innerText = tokenValue;
if (['none', 'any'].includes(tokenValue.toLowerCase())) { const visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
return;
}
const tokenType = tokenName.toLowerCase(); visualTokenValue.render(tokenValueContainer, tokenValueElement);
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,
);
}
} }
static addVisualTokenElement(name, value, options = {}) { static addVisualTokenElement(name, value, options = {}) {
...@@ -328,6 +200,21 @@ export default class FilteredSearchVisualTokens { ...@@ -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) { static editToken(token) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search'); 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 projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; 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 IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants'; import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
......
import projectSelect from '~/project_select'; import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; 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'; import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
......
...@@ -2,12 +2,13 @@ import IssuableIndex from '~/issuable_index'; ...@@ -2,12 +2,13 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import UsersSelect from '~/users_select'; import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; 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 IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants'; import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests(); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
......
...@@ -90,3 +90,5 @@ class MergeRequestsFinder < IssuableFinder ...@@ -90,3 +90,5 @@ class MergeRequestsFinder < IssuableFinder
.or(table[:title].matches('[WIP]%')) .or(table[:title].matches('[WIP]%'))
end end
end end
MergeRequestsFinder.prepend(EE::MergeRequestsFinder)
%ul.content-list.mr-list.issuable-list %ul.content-list.mr-list.issuable-list
- if @merge_requests.exists? - if @merge_requests.present?
= render @merge_requests = render @merge_requests
- else - else
= render 'shared/empty_states/merge_requests' = render 'shared/empty_states/merge_requests'
......
...@@ -71,6 +71,7 @@ ...@@ -71,6 +71,7 @@
= render 'shared/issuable/user_dropdown_item', = render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'), user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' } avatar: { lazy: true, url: '{{avatar_url}}' }
= render_if_exists 'shared/issuable/approver_dropdown'
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } } %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. ...@@ -290,3 +290,10 @@ request itself. It belongs to the target branch's project.
## Approver suggestions ## Approver suggestions
Approvers are suggested for merge requests based on the previous authors of the files affected by the merge request. 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 ...@@ -7,6 +7,7 @@ module EE
include ::Approvable include ::Approvable
include ::Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
include FromUnion
prepend ApprovableForRule prepend ApprovableForRule
prepended do prepended do
...@@ -33,6 +34,12 @@ module EE ...@@ -33,6 +34,12 @@ module EE
accepts_nested_attributes_for :approval_rules, allow_destroy: true accepts_nested_attributes_for :approval_rules, allow_destroy: true
end end
class_methods do
def select_from_union(relations)
from_union(relations, remove_duplicates: true)
end
end
override :mergeable? override :mergeable?
def mergeable?(skip_ci_check: false) def mergeable?(skip_ci_check: false)
return false unless approved? 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 ...@@ -14,6 +14,11 @@ module EE
optional :approvals_required, type: Integer, desc: 'Total number of approvals required' optional :approvals_required, type: Integer, desc: 'Total number of approvals required'
end end
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 end
params do params do
......
...@@ -7,6 +7,19 @@ FactoryBot.modify do ...@@ -7,6 +7,19 @@ FactoryBot.modify do
create :approver, target: merge_request create :approver, target: merge_request
end end
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
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 ...@@ -130,4 +130,57 @@ describe API::MergeRequests do
end end
end 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 end
...@@ -22,9 +22,22 @@ module API ...@@ -22,9 +22,22 @@ module API
message: "should be an integer, 'None' or 'Any'" message: "should be an integer, 'None' or 'Any'"
end end
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 end
end end
Grape::Validations.register_validator(:absence, ::API::Helpers::CustomValidators::Absence) Grape::Validations.register_validator(:absence, ::API::Helpers::CustomValidators::Absence)
Grape::Validations.register_validator(:integer_none_any, ::API::Helpers::CustomValidators::IntegerNoneAny) 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 ...@@ -12,6 +12,9 @@ module API
helpers do helpers do
params :optional_params_ee do params :optional_params_ee do
end end
params :optional_merge_requests_search_params do
end
end end
def self.update_params_at_least_one_of def self.update_params_at_least_one_of
...@@ -114,6 +117,8 @@ module API ...@@ -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 :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 :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' 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 use :pagination
end end
end end
......
This diff is collapsed.
...@@ -50,6 +50,29 @@ describe API::Helpers::CustomValidators do ...@@ -50,6 +50,29 @@ describe API::Helpers::CustomValidators do
end end
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) def expect_no_validation_error(params)
expect { validate_test_param!(params) }.not_to raise_error expect { validate_test_param!(params) }.not_to raise_error
end 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