Commit 09f38f8d authored by Tim Zallmann's avatar Tim Zallmann

Merge branch 'ccr/wip_filter' into 'master'

#13650 added wip search functionality and tests

See merge request gitlab-org/gitlab-ce!18119
parents 82ece8ad 38f3d59f
...@@ -51,7 +51,11 @@ export default class DropdownHint extends FilteredSearchDropdown { ...@@ -51,7 +51,11 @@ export default class DropdownHint extends FilteredSearchDropdown {
FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' ')); FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
} }
FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container); const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
FilteredSearchDropdownManager.addWordToInput(key, '', false, {
uppercaseTokenName,
});
} }
this.dismissDropdown(); this.dismissDropdown();
this.dispatchInputEvent(); this.dispatchInputEvent();
......
...@@ -143,7 +143,9 @@ export default class DropdownUtils { ...@@ -143,7 +143,9 @@ export default class DropdownUtils {
const dataValue = selected.getAttribute('data-value'); const dataValue = selected.getAttribute('data-value');
if (dataValue) { if (dataValue) {
FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true); FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
});
} }
// Return boolean based on whether it was set // Return boolean based on whether it was set
......
...@@ -91,6 +91,11 @@ export default class FilteredSearchDropdownManager { ...@@ -91,6 +91,11 @@ export default class FilteredSearchDropdownManager {
gl: DropdownEmoji, gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'), element: this.container.querySelector('#js-dropdown-my-reaction'),
}, },
wip: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
status: { status: {
reference: null, reference: null,
gl: NullDropdown, gl: NullDropdown,
...@@ -136,10 +141,16 @@ export default class FilteredSearchDropdownManager { ...@@ -136,10 +141,16 @@ export default class FilteredSearchDropdownManager {
return endpoint; return endpoint;
} }
static addWordToInput(tokenName, tokenValue = '', clicked = false) { static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
const {
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = options;
const input = FilteredSearchContainer.container.querySelector('.filtered-search'); const input = FilteredSearchContainer.container.querySelector('.filtered-search');
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, {
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue); uppercaseTokenName,
capitalizeTokenValue,
});
input.value = ''; input.value = '';
if (clicked) { if (clicked) {
......
...@@ -405,7 +405,10 @@ export default class FilteredSearchManager { ...@@ -405,7 +405,10 @@ export default class FilteredSearchManager {
if (isLastVisualTokenValid) { if (isLastVisualTokenValid) {
tokens.forEach((t) => { tokens.forEach((t) => {
input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, ''); input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`); FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
});
}); });
const fragments = searchToken.split(':'); const fragments = searchToken.split(':');
...@@ -421,7 +424,10 @@ export default class FilteredSearchManager { ...@@ -421,7 +424,10 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addSearchVisualToken(searchTerms); FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
} }
FilteredSearchVisualTokens.addFilterVisualToken(tokenKey); FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
input.value = input.value.replace(`${tokenKey}:`, ''); input.value = input.value.replace(`${tokenKey}:`, '');
} }
} else { } else {
...@@ -429,7 +435,10 @@ export default class FilteredSearchManager { ...@@ -429,7 +435,10 @@ export default class FilteredSearchManager {
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g; const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') { if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
FilteredSearchVisualTokens.addFilterVisualToken(searchToken); const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, {
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
// Trim the last space as seen in the if statement above // Trim the last space as seen in the if statement above
input.value = input.value.replace(searchToken, '').trim(); input.value = input.value.replace(searchToken, '').trim();
...@@ -480,7 +489,7 @@ export default class FilteredSearchManager { ...@@ -480,7 +489,7 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addFilterVisualToken( FilteredSearchVisualTokens.addFilterVisualToken(
condition.tokenKey, condition.tokenKey,
condition.value, condition.value,
canEdit, { canEdit },
); );
} else { } else {
// Sanitize value since URL converts spaces into + // Sanitize value since URL converts spaces into +
...@@ -506,10 +515,15 @@ export default class FilteredSearchManager { ...@@ -506,10 +515,15 @@ export default class FilteredSearchManager {
hasFilteredSearch = true; hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue); const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
FilteredSearchVisualTokens.addFilterVisualToken( FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey, sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
canEdit, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
); );
} else if (!match && keyParam === 'assignee_id') { } else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10); const id = parseInt(value, 10);
...@@ -517,7 +531,7 @@ export default class FilteredSearchManager { ...@@ -517,7 +531,7 @@ export default class FilteredSearchManager {
hasFilteredSearch = true; hasFilteredSearch = true;
const tokenName = 'assignee'; const tokenName = 'assignee';
const canEdit = this.canEdit && this.canEdit(tokenName); const canEdit = this.canEdit && this.canEdit(tokenName);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { canEdit });
} }
} else if (!match && keyParam === 'author_id') { } else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10); const id = parseInt(value, 10);
...@@ -525,7 +539,7 @@ export default class FilteredSearchManager { ...@@ -525,7 +539,7 @@ export default class FilteredSearchManager {
hasFilteredSearch = true; hasFilteredSearch = true;
const tokenName = 'author'; const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName); const canEdit = this.canEdit && this.canEdit(tokenName);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { canEdit });
} }
} else if (!match && keyParam === 'search') { } else if (!match && keyParam === 'search') {
hasFilteredSearch = true; hasFilteredSearch = true;
...@@ -561,15 +575,17 @@ export default class FilteredSearchManager { ...@@ -561,15 +575,17 @@ export default class FilteredSearchManager {
this.saveCurrentSearchQuery(); this.saveCurrentSearchQuery();
const { tokens, searchToken } const tokenKeys = this.filteredSearchTokenKeys.getKeys();
= this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys()); const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys);
const currentState = state || getParameterByName('state') || 'opened'; const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
tokens.forEach((token) => { tokens.forEach((token) => {
const condition = this.filteredSearchTokenKeys const condition = this.filteredSearchTokenKeys
.searchByConditionKeyValue(token.key, token.value.toLowerCase()); .searchByConditionKeyValue(token.key, token.value.toLowerCase());
const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
const { param } = tokenConfig;
// Replace hyphen with underscore to use as request parameter // Replace hyphen with underscore to use as request parameter
// e.g. 'my-reaction' => 'my_reaction' // e.g. 'my-reaction' => 'my_reaction'
const underscoredKey = token.key.replace('-', '_'); const underscoredKey = token.key.replace('-', '_');
...@@ -581,6 +597,10 @@ export default class FilteredSearchManager { ...@@ -581,6 +597,10 @@ export default class FilteredSearchManager {
} else { } else {
let tokenValue = token.value; let tokenValue = token.value;
if (tokenConfig.lowercaseValueOnSubmit) {
tokenValue = tokenValue.toLowerCase();
}
if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') || if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') ||
(tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) { (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) {
tokenValue = tokenValue.slice(1, tokenValue.length - 1); tokenValue = tokenValue.slice(1, tokenValue.length - 1);
......
...@@ -23,6 +23,16 @@ export default class FilteredSearchTokenKeys { ...@@ -23,6 +23,16 @@ export default class FilteredSearchTokenKeys {
return this.conditions; return this.conditions;
} }
shouldUppercaseTokenName(tokenKey) {
const token = this.searchByKey(tokenKey.toLowerCase());
return token && token.uppercaseTokenName;
}
shouldCapitalizeTokenValue(tokenKey) {
const token = this.searchByKey(tokenKey.toLowerCase());
return token && token.capitalizeTokenValue;
}
searchByKey(key) { searchByKey(key) {
return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null; return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null;
} }
...@@ -55,4 +65,21 @@ export default class FilteredSearchTokenKeys { ...@@ -55,4 +65,21 @@ export default class FilteredSearchTokenKeys {
return this.conditions return this.conditions
.find(condition => condition.tokenKey === key && condition.value === value) || null; .find(condition => condition.tokenKey === key && condition.value === value) || null;
} }
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);
}
} }
...@@ -55,12 +55,18 @@ export default class FilteredSearchVisualTokens { ...@@ -55,12 +55,18 @@ export default class FilteredSearchVisualTokens {
} }
} }
static createVisualTokenElementHTML(canEdit = true) { static createVisualTokenElementHTML(options = {}) {
const {
canEdit = true,
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = options;
return ` return `
<div class="${canEdit ? 'selectable' : 'hidden'}" role="button"> <div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="name"></div> <div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>
<div class="value-container"> <div class="value-container">
<div class="value"></div> <div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div>
<div class="remove-token" role="button"> <div class="remove-token" role="button">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</div> </div>
...@@ -182,16 +188,26 @@ export default class FilteredSearchVisualTokens { ...@@ -182,16 +188,26 @@ export default class FilteredSearchVisualTokens {
} }
} }
static addVisualTokenElement(name, value, isSearchTerm, canEdit) { static addVisualTokenElement(name, value, options = {}) {
const {
isSearchTerm = false,
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
} = options;
const li = document.createElement('li'); const li = document.createElement('li');
li.classList.add('js-visual-token'); li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) { if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit); li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else { } else {
li.innerHTML = '<div class="name"></div>'; li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
} }
li.querySelector('.name').innerText = name; li.querySelector('.name').innerText = name;
...@@ -212,20 +228,32 @@ export default class FilteredSearchVisualTokens { ...@@ -212,20 +228,32 @@ export default class FilteredSearchVisualTokens {
} }
} }
static addFilterVisualToken(tokenName, tokenValue, canEdit) { static addFilterVisualToken(tokenName, tokenValue, {
canEdit,
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = {}) {
const { lastVisualToken, isLastVisualTokenValid } const { lastVisualToken, isLastVisualTokenValid }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const { addVisualTokenElement } = FilteredSearchVisualTokens; const { addVisualTokenElement } = FilteredSearchVisualTokens;
if (isLastVisualTokenValid) { if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue, false, canEdit); addVisualTokenElement(tokenName, tokenValue, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
} else { } else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText; const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken); tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName; const value = tokenValue || tokenName;
addVisualTokenElement(previousTokenName, value, false, canEdit); addVisualTokenElement(previousTokenName, value, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
} }
} }
...@@ -235,7 +263,9 @@ export default class FilteredSearchVisualTokens { ...@@ -235,7 +263,9 @@ export default class FilteredSearchVisualTokens {
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`; lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
} else { } else {
FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, true); FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, {
isSearchTerm: true,
});
} }
} }
...@@ -306,7 +336,9 @@ export default class FilteredSearchVisualTokens { ...@@ -306,7 +336,9 @@ export default class FilteredSearchVisualTokens {
let value; let value;
if (token.classList.contains('filtered-search-token')) { if (token.classList.contains('filtered-search-token')) {
FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText); FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, {
uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
});
const valueContainerElement = token.querySelector('.value-container'); const valueContainerElement = token.querySelector('.value-container');
value = valueContainerElement.dataset.originalValue; value = valueContainerElement.dataset.originalValue;
......
...@@ -4,6 +4,8 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered ...@@ -4,6 +4,8 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
import { FILTERED_SEARCH } from '~/pages/constants'; import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
isGroupDecendent: true, isGroupDecendent: true,
......
...@@ -7,10 +7,13 @@ import { FILTERED_SEARCH } from '~/pages/constants'; ...@@ -7,10 +7,13 @@ 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();
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
}); });
new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new
......
...@@ -27,13 +27,17 @@ ...@@ -27,13 +27,17 @@
# updated_before: datetime # updated_before: datetime
# #
class MergeRequestsFinder < IssuableFinder class MergeRequestsFinder < IssuableFinder
def self.scalar_params
@scalar_params ||= super + [:wip]
end
def klass def klass
MergeRequest MergeRequest
end end
def filter_items(_items) def filter_items(_items)
items = by_source_branch(super) items = by_source_branch(super)
items = by_wip(items)
by_target_branch(items) by_target_branch(items)
end end
...@@ -61,5 +65,24 @@ class MergeRequestsFinder < IssuableFinder ...@@ -61,5 +65,24 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch) items.where(target_branch: target_branch)
end end
# rubocop: enable CodeReuse/ActiveRecord
def item_project_ids(items)
items&.reorder(nil)&.select(:target_project_id)
end
def by_wip(items)
if params[:wip] == 'yes'
items.where(wip_match(items.arel_table))
elsif params[:wip] == 'no'
items.where.not(wip_match(items.arel_table))
else
items
end
end
def wip_match(table)
table[:title].matches('WIP:%')
.or(table[:title].matches('WIP %'))
.or(table[:title].matches('[WIP]%'))
end
end end
...@@ -261,7 +261,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -261,7 +261,7 @@ class MergeRequest < ActiveRecord::Base
end end
end end
WIP_REGEX = /\A\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze WIP_REGEX = /\A*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i.freeze
def self.work_in_progress?(title) def self.work_in_progress?(title)
!!(title =~ WIP_REGEX) !!(title =~ WIP_REGEX)
......
...@@ -33,13 +33,13 @@ ...@@ -33,13 +33,13 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } } %li.filter-dropdown-item{ data: { action: 'submit' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
= sprite_icon('search') = sprite_icon('search')
%span %span
Press Enter or click to search Press Enter or click to search
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass -# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue -# haml lint's ClassAttributeWithStaticValue
%svg %svg
...@@ -60,7 +60,7 @@ ...@@ -60,7 +60,7 @@
#js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-assignee.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' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
No Assignee No Assignee
%li.divider.droplab-item-ignore %li.divider.droplab-item-ignore
- if current_user - if current_user
...@@ -73,38 +73,46 @@ ...@@ -73,38 +73,46 @@
#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' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
No Milestone No Milestone
%li.filter-dropdown-item{ data: { value: 'upcoming' } } %li.filter-dropdown-item{ data: { value: 'upcoming' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
Upcoming Upcoming
%li.filter-dropdown-item{ 'data-value' => 'started' } %li.filter-dropdown-item{ 'data-value' => 'started' }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
Started Started
%li.divider.droplab-item-ignore %li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link.js-data-value %button.btn.btn-link.js-data-value{ type: 'button' }
{{title}} {{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-label.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' } }
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
No Label No Label
%li.divider.droplab-item-ignore %li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
%span.dropdown-label-box{ style: 'background: {{color}}' } %span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value %span.label-title.js-data-value
{{title}} {{title}}
#js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link %button.btn.btn-link{ type: 'button' }
%gl-emoji %gl-emoji
%span.js-data-value.prepend-left-10 %span.js-data-value.prepend-left-10
{{name}} {{name}}
#js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
%button.btn.btn-link{ type: 'button' }
= _('Yes')
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
%button.btn.btn-link{ type: 'button' }
= _('No')
= render_if_exists 'shared/issuable/filter_weight', type: type = render_if_exists 'shared/issuable/filter_weight', type: type
......
---
title: Added search functionality for Work In Progress (WIP) merge requests
merge_request: 18119
author: Chantal Rollison
type: added
...@@ -47,6 +47,7 @@ Parameters: ...@@ -47,6 +47,7 @@ Parameters:
| `source_branch` | string | no | Return merge requests with the given source branch | | `source_branch` | string | no | Return merge requests with the given source branch |
| `target_branch` | string | no | Return merge requests with the given target branch | | `target_branch` | string | no | Return merge requests with the given target branch |
| `search` | string | no | Search merge requests against their `title` and `description` | | `search` | string | no | Search merge requests against their `title` and `description` |
| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests |
```json ```json
[ [
......
...@@ -7,7 +7,7 @@ have been marked a **Work In Progress**. ...@@ -7,7 +7,7 @@ have been marked a **Work In Progress**.
![Blocked Accept Button](img/wip_blocked_accept_button.png) ![Blocked Accept Button](img/wip_blocked_accept_button.png)
To mark a merge request a Work In Progress, simply start its title with `[WIP]` To mark a merge request a Work In Progress, simply start its title with `[WIP]`
or `WIP:`. As an alternative, you're also able to do it by sending a commit or `WIP:`. As an alternative, you're also able to do it by sending a commit
with its title starting with `wip` or `WIP` to the merge request's source branch. with its title starting with `wip` or `WIP` to the merge request's source branch.
![Mark as WIP](img/wip_mark_as_wip.png) ![Mark as WIP](img/wip_mark_as_wip.png)
...@@ -15,4 +15,11 @@ with its title starting with `wip` or `WIP` to the merge request's source branch ...@@ -15,4 +15,11 @@ with its title starting with `wip` or `WIP` to the merge request's source branch
To allow a Work In Progress merge request to be accepted again when it's ready, To allow a Work In Progress merge request to be accepted again when it's ready,
simply remove the `WIP` prefix. simply remove the `WIP` prefix.
![Unark as WIP](img/wip_unmark_as_wip.png) ![Unmark as WIP](img/wip_unmark_as_wip.png)
## Filtering merge requests with WIP Status
To filter merge requests with the `WIP` status, you can type `wip`
and select the value for your filter from the merge request search input.
![Filter WIP MRs](img/filter_wip_merge_requests.png)
...@@ -33,7 +33,6 @@ module API ...@@ -33,7 +33,6 @@ module API
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def find_merge_requests(args = {}) def find_merge_requests(args = {})
args = declared_params.merge(args) args = declared_params.merge(args)
args[:milestone_title] = args.delete(:milestone) args[:milestone_title] = args.delete(:milestone)
args[:label_name] = args.delete(:labels) args[:label_name] = args.delete(:labels)
args[:scope] = args[:scope].underscore if args[:scope] args[:scope] = args[:scope].underscore if args[:scope]
...@@ -97,6 +96,7 @@ module API ...@@ -97,6 +96,7 @@ module API
optional :source_branch, type: String, desc: 'Return merge requests with the given source branch' optional :source_branch, type: String, desc: 'Return merge requests with the given source branch'
optional :target_branch, type: String, desc: 'Return merge requests with the given target branch' optional :target_branch, type: String, desc: 'Return merge requests with the given target branch'
optional :search, type: String, desc: 'Search merge requests for text present in the title or description' optional :search, type: String, desc: 'Search merge requests for text present in the title or description'
optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title'
use :pagination use :pagination
end end
end end
......
...@@ -15,6 +15,7 @@ describe 'Dropdown hint', :js do ...@@ -15,6 +15,7 @@ describe 'Dropdown hint', :js do
before do before do
project.add_maintainer(user) project.add_maintainer(user)
create(:issue, project: project) create(:issue, project: project)
create(:merge_request, source_project: project, target_project: project)
end end
context 'when user not logged in' do context 'when user not logged in' do
...@@ -224,4 +225,21 @@ describe 'Dropdown hint', :js do ...@@ -224,4 +225,21 @@ describe 'Dropdown hint', :js do
end end
end end
end end
context 'merge request page' do
before do
sign_in(user)
visit project_merge_requests_path(project)
filtered_search.click
end
it 'shows the WIP menu item and opens the WIP options dropdown' do
click_hint('wip')
expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).to have_css('#js-dropdown-wip', visible: true)
expect_tokens([{ name: 'wip' }])
expect_filtered_search_input_empty
end
end
end end
...@@ -16,12 +16,18 @@ describe MergeRequestsFinder do ...@@ -16,12 +16,18 @@ describe MergeRequestsFinder do
p p
end end
let(:project4) { create(:project, :public, group: subgroup) } let(:project4) { create(:project, :public, group: subgroup) }
let(:project5) { create(:project, :public, group: subgroup) }
let(:project6) { create(:project, :public, group: subgroup) }
let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) } let!(:merge_request1) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project1) }
let!(:merge_request2) { create(:merge_request, :conflict, author: user, source_project: project2, target_project: project1, state: 'closed') } let!(:merge_request2) { create(:merge_request, :conflict, author: user, source_project: project2, target_project: project1, state: 'closed') }
let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2, state: 'locked') } 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) } let!(:merge_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3, title: 'WIP thing') }
let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4) } let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4, title: '[WIP]') }
let!(:merge_request6) { create(:merge_request, :simple, author: user, source_project: project5, target_project: project5, title: 'WIP: thing') }
let!(:merge_request7) { create(:merge_request, :simple, author: user, source_project: project6, target_project: project6, title: 'wip thing') }
let!(:merge_request8) { create(:merge_request, :simple, author: user, source_project: project1, target_project: project1, title: '[wip] thing') }
let!(:merge_request9) { create(:merge_request, :simple, author: user, source_project: project1, target_project: project2, title: 'wip: thing') }
before do before do
project1.add_maintainer(user) project1.add_maintainer(user)
...@@ -29,19 +35,21 @@ describe MergeRequestsFinder do ...@@ -29,19 +35,21 @@ describe MergeRequestsFinder do
project3.add_developer(user) project3.add_developer(user)
project2.add_developer(user2) project2.add_developer(user2)
project4.add_developer(user) project4.add_developer(user)
project5.add_developer(user)
project6.add_developer(user)
end end
describe "#execute" do describe "#execute" do
it 'filters by scope' do it 'filters by scope' do
params = { scope: 'authored', state: 'opened' } params = { scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3) expect(merge_requests.size).to eq(7)
end end
it 'filters by project' do it 'filters by project' do
params = { project_id: project1.id, scope: 'authored', state: 'opened' } params = { project_id: project1.id, scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(1) expect(merge_requests.size).to eq(2)
end end
it 'filters by group' do it 'filters by group' do
...@@ -49,7 +57,7 @@ describe MergeRequestsFinder do ...@@ -49,7 +57,7 @@ describe MergeRequestsFinder do
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(2) expect(merge_requests.size).to eq(3)
end end
it 'filters by group including subgroups', :nested_groups do it 'filters by group including subgroups', :nested_groups do
...@@ -57,13 +65,13 @@ describe MergeRequestsFinder do ...@@ -57,13 +65,13 @@ describe MergeRequestsFinder do
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3) expect(merge_requests.size).to eq(6)
end end
it 'filters by non_archived' do it 'filters by non_archived' do
params = { non_archived: true } params = { non_archived: true }
merge_requests = described_class.new(user, params).execute merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(4) expect(merge_requests.size).to eq(8)
end end
it 'filters by iid' do it 'filters by iid' do
...@@ -98,6 +106,36 @@ describe MergeRequestsFinder do ...@@ -98,6 +106,36 @@ describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merge_request3) expect(merge_requests).to contain_exactly(merge_request3)
end end
it 'filters by wip' do
params = { wip: 'yes' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request4, merge_request5, merge_request6, merge_request7, merge_request8, merge_request9)
end
it 'filters by not wip' do
params = { wip: 'no' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3)
end
it 'returns all items if no valid wip param exists' do
params = { wip: '' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3, merge_request4, merge_request5, merge_request6, merge_request7, merge_request8, merge_request9)
end
it 'adds wip to scalar params' do
scalar_params = described_class.scalar_params
expect(scalar_params).to include(:wip, :assignee_id)
end
context 'filtering by group milestone' do context 'filtering by group milestone' do
let!(:group) { create(:group, :public) } let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) } let(:group_milestone) { create(:milestone, group: group) }
...@@ -207,7 +245,7 @@ describe MergeRequestsFinder do ...@@ -207,7 +245,7 @@ describe MergeRequestsFinder do
it 'returns the number of rows for the default state' do it 'returns the number of rows for the default state' do
finder = described_class.new(user) finder = described_class.new(user)
expect(finder.row_count).to eq(3) expect(finder.row_count).to eq(7)
end end
it 'returns the number of rows for a given state' do it 'returns the number of rows for a given state' do
......
...@@ -288,13 +288,13 @@ describe('Dropdown Utils', () => { ...@@ -288,13 +288,13 @@ describe('Dropdown Utils', () => {
describe('setDataValueIfSelected', () => { describe('setDataValueIfSelected', () => {
beforeEach(() => { beforeEach(() => {
spyOn(FilteredSearchDropdownManager, 'addWordToInput') spyOn(FilteredSearchDropdownManager, 'addWordToInput').and.callFake(() => {});
.and.callFake(() => {});
}); });
it('calls addWordToInput when dataValue exists', () => { it('calls addWordToInput when dataValue exists', () => {
const selected = { const selected = {
getAttribute: () => 'value', getAttribute: () => 'value',
hasAttribute: () => false,
}; };
DropdownUtils.setDataValueIfSelected(null, selected); DropdownUtils.setDataValueIfSelected(null, selected);
...@@ -304,6 +304,7 @@ describe('Dropdown Utils', () => { ...@@ -304,6 +304,7 @@ describe('Dropdown Utils', () => {
it('returns true when dataValue exists', () => { it('returns true when dataValue exists', () => {
const selected = { const selected = {
getAttribute: () => 'value', getAttribute: () => 'value',
hasAttribute: () => false,
}; };
const result = DropdownUtils.setDataValueIfSelected(null, selected); const result = DropdownUtils.setDataValueIfSelected(null, selected);
......
...@@ -240,13 +240,17 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -240,13 +240,17 @@ describe('Filtered Search Visual Tokens', () => {
beforeEach(() => { beforeEach(() => {
setFixtures(` setFixtures(`
<div class="test-area"> <div class="test-area">
${subject.createVisualTokenElementHTML()} ${subject.createVisualTokenElementHTML('custom-token')}
</div> </div>
`); `);
tokenElement = document.querySelector('.test-area').firstElementChild; tokenElement = document.querySelector('.test-area').firstElementChild;
}); });
it('should add class name to token element', () => {
expect(document.querySelector('.test-area .custom-token')).toBeDefined();
});
it('contains name div', () => { it('contains name div', () => {
expect(tokenElement.querySelector('.name')).toEqual(jasmine.anything()); expect(tokenElement.querySelector('.name')).toEqual(jasmine.anything());
}); });
...@@ -280,7 +284,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -280,7 +284,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('addVisualTokenElement', () => { describe('addVisualTokenElement', () => {
it('renders search visual tokens', () => { it('renders search visual tokens', () => {
subject.addVisualTokenElement('search term', null, true); subject.addVisualTokenElement('search term', null, { isSearchTerm: true });
const token = tokensContainer.querySelector('.js-visual-token'); const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-term')).toEqual(true); expect(token.classList.contains('filtered-search-term')).toEqual(true);
......
...@@ -746,7 +746,7 @@ describe MergeRequest do ...@@ -746,7 +746,7 @@ describe MergeRequest do
end end
describe "#wipless_title" do describe "#wipless_title" do
['WIP ', 'WIP:', 'WIP: ', '[WIP]', '[WIP] ', ' [WIP] WIP [WIP] WIP: WIP '].each do |wip_prefix| ['WIP ', 'WIP:', 'WIP: ', '[WIP]', '[WIP] ', '[WIP] WIP [WIP] WIP: WIP '].each do |wip_prefix|
it "removes the '#{wip_prefix}' prefix" do it "removes the '#{wip_prefix}' prefix" do
wipless_title = subject.title wipless_title = subject.title
subject.title = "#{wip_prefix}#{subject.title}" subject.title = "#{wip_prefix}#{subject.title}"
......
...@@ -81,6 +81,35 @@ describe API::MergeRequests do ...@@ -81,6 +81,35 @@ describe API::MergeRequests do
let(:user2) { create(:user) } let(:user2) { create(:user) }
it 'returns an array of all merge requests except unauthorized ones' do it 'returns an array of all merge requests except unauthorized ones' do
get api('/merge_requests', user), scope: :all
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.map { |mr| mr['id'] })
.to contain_exactly(merge_request.id, merge_request_closed.id, merge_request_merged.id, merge_request_locked.id, merge_request2.id)
end
it "returns an array of no merge_requests when wip=yes" do
get api("/merge_requests", user), wip: 'yes'
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
it "returns an array of no merge_requests when wip=no" do
get api("/merge_requests", user), wip: 'no'
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.map { |mr| mr['id'] })
.to contain_exactly(merge_request.id, merge_request_closed.id, merge_request_merged.id, merge_request_locked.id, merge_request2.id)
end
it 'does not return unauthorized merge requests' do
private_project = create(:project, :private) private_project = create(:project, :private)
merge_request3 = create(:merge_request, :simple, source_project: private_project, target_project: private_project, source_branch: 'other-branch') merge_request3 = create(:merge_request, :simple, source_project: private_project, target_project: private_project, source_branch: 'other-branch')
...@@ -244,6 +273,15 @@ describe API::MergeRequests do ...@@ -244,6 +273,15 @@ describe API::MergeRequests do
expect(response).to have_gitlab_http_status(404) expect(response).to have_gitlab_http_status(404)
end end
it "returns an array of no merge_requests when wip=yes" do
get api("/projects/#{project.id}/merge_requests", user), wip: 'yes'
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
it 'returns merge_request by "iids" array' do it 'returns merge_request by "iids" array' do
get api(endpoint_path, user), iids: [merge_request.iid, merge_request_closed.iid] get api(endpoint_path, user), iids: [merge_request.iid, merge_request_closed.iid]
......
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