Commit 38f3d59f authored by Chantal Rollison's avatar Chantal Rollison Committed by Tim Zallmann

#13650 added wip search functionality and tests

parent 82ece8ad
......@@ -51,7 +51,11 @@ export default class DropdownHint extends FilteredSearchDropdown {
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.dispatchInputEvent();
......
......@@ -143,7 +143,9 @@ export default class DropdownUtils {
const dataValue = selected.getAttribute('data-value');
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
......
......@@ -91,6 +91,11 @@ export default class FilteredSearchDropdownManager {
gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
wip: {
reference: null,
gl: DropdownNonUser,
element: this.container.querySelector('#js-dropdown-wip'),
},
status: {
reference: null,
gl: NullDropdown,
......@@ -136,10 +141,16 @@ export default class FilteredSearchDropdownManager {
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');
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, {
uppercaseTokenName,
capitalizeTokenValue,
});
input.value = '';
if (clicked) {
......
......@@ -405,7 +405,10 @@ export default class FilteredSearchManager {
if (isLastVisualTokenValid) {
tokens.forEach((t) => {
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(':');
......@@ -421,7 +424,10 @@ export default class FilteredSearchManager {
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}:`, '');
}
} else {
......@@ -429,7 +435,10 @@ export default class FilteredSearchManager {
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
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
input.value = input.value.replace(searchToken, '').trim();
......@@ -480,7 +489,7 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addFilterVisualToken(
condition.tokenKey,
condition.value,
canEdit,
{ canEdit },
);
} else {
// Sanitize value since URL converts spaces into +
......@@ -506,10 +515,15 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
canEdit,
{
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
);
} else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10);
......@@ -517,7 +531,7 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'assignee';
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') {
const id = parseInt(value, 10);
......@@ -525,7 +539,7 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { canEdit });
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
......@@ -561,15 +575,17 @@ export default class FilteredSearchManager {
this.saveCurrentSearchQuery();
const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
const tokenKeys = this.filteredSearchTokenKeys.getKeys();
const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys);
const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
tokens.forEach((token) => {
const condition = this.filteredSearchTokenKeys
.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
// e.g. 'my-reaction' => 'my_reaction'
const underscoredKey = token.key.replace('-', '_');
......@@ -581,6 +597,10 @@ export default class FilteredSearchManager {
} else {
let tokenValue = token.value;
if (tokenConfig.lowercaseValueOnSubmit) {
tokenValue = tokenValue.toLowerCase();
}
if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') ||
(tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) {
tokenValue = tokenValue.slice(1, tokenValue.length - 1);
......
......@@ -23,6 +23,16 @@ export default class FilteredSearchTokenKeys {
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) {
return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null;
}
......@@ -55,4 +65,21 @@ export default class FilteredSearchTokenKeys {
return this.conditions
.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 {
}
}
static createVisualTokenElementHTML(canEdit = true) {
static createVisualTokenElementHTML(options = {}) {
const {
canEdit = true,
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = options;
return `
<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"></div>
<div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div>
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
......@@ -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');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit);
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else {
li.innerHTML = '<div class="name"></div>';
li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
}
li.querySelector('.name').innerText = name;
......@@ -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 }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const { addVisualTokenElement } = FilteredSearchVisualTokens;
if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue, false, canEdit);
addVisualTokenElement(tokenName, tokenValue, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
addVisualTokenElement(previousTokenName, value, false, canEdit);
addVisualTokenElement(previousTokenName, value, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
});
}
}
......@@ -235,7 +263,9 @@ export default class FilteredSearchVisualTokens {
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
} else {
FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, true);
FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, {
isSearchTerm: true,
});
}
}
......@@ -306,7 +336,9 @@ export default class FilteredSearchVisualTokens {
let value;
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');
value = valueContainerElement.dataset.originalValue;
......
......@@ -4,6 +4,8 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
isGroupDecendent: true,
......
......@@ -7,10 +7,13 @@ import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
new UsersSelect(); // eslint-disable-line no-new
......
......@@ -27,13 +27,17 @@
# updated_before: datetime
#
class MergeRequestsFinder < IssuableFinder
def self.scalar_params
@scalar_params ||= super + [:wip]
end
def klass
MergeRequest
end
def filter_items(_items)
items = by_source_branch(super)
items = by_wip(items)
by_target_branch(items)
end
......@@ -61,5 +65,24 @@ def by_target_branch(items)
items.where(target_branch: target_branch)
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
......@@ -261,7 +261,7 @@ def self.set_latest_merge_request_diff_ids!
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)
!!(title =~ WIP_REGEX)
......
......@@ -33,13 +33,13 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
= sprite_icon('search')
%span
Press Enter or click to search
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
......@@ -60,7 +60,7 @@
#js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
No Assignee
%li.divider.droplab-item-ignore
- if current_user
......@@ -73,38 +73,46 @@
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
No Milestone
%li.filter-dropdown-item{ data: { value: 'upcoming' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
Upcoming
%li.filter-dropdown-item{ 'data-value' => 'started' }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
Started
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value
%button.btn.btn-link.js-data-value{ type: 'button' }
{{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
No Label
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{title}}
#js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
%button.btn.btn-link{ type: 'button' }
%gl-emoji
%span.js-data-value.prepend-left-10
{{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
......
---
title: Added search functionality for Work In Progress (WIP) merge requests
merge_request: 18119
author: Chantal Rollison
type: added
......@@ -47,6 +47,7 @@ Parameters:
| `source_branch` | string | no | Return merge requests with the given source branch |
| `target_branch` | string | no | Return merge requests with the given target branch |
| `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
[
......
......@@ -7,7 +7,7 @@ have been marked a **Work In Progress**.
![Blocked Accept Button](img/wip_blocked_accept_button.png)
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.
![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
To allow a Work In Progress merge request to be accepted again when it's ready,
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 @@ def self.update_params_at_least_one_of
# rubocop: disable CodeReuse/ActiveRecord
def find_merge_requests(args = {})
args = declared_params.merge(args)
args[:milestone_title] = args.delete(:milestone)
args[:label_name] = args.delete(:labels)
args[:scope] = args[:scope].underscore if args[:scope]
......@@ -97,6 +96,7 @@ def serializer_options_for(merge_requests)
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 :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
end
end
......
......@@ -15,6 +15,7 @@ def click_hint(text)
before do
project.add_maintainer(user)
create(:issue, project: project)
create(:merge_request, source_project: project, target_project: project)
end
context 'when user not logged in' do
......@@ -224,4 +225,21 @@ def click_hint(text)
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
......@@ -16,12 +16,18 @@
p
end
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_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_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3) }
let!(:merge_request5) { create(:merge_request, :simple, author: user, source_project: project4, target_project: project4) }
let!(:merge_request3) { create(:merge_request, :simple, author: user, source_project: project2, target_project: project2, state: 'locked', title: 'thing WIP thing') }
let!(:merge_request4) { create(:merge_request, :simple, author: user, source_project: project3, target_project: project3, title: 'WIP thing') }
let!(:merge_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
project1.add_maintainer(user)
......@@ -29,19 +35,21 @@
project3.add_developer(user)
project2.add_developer(user2)
project4.add_developer(user)
project5.add_developer(user)
project6.add_developer(user)
end
describe "#execute" do
it 'filters by scope' do
params = { scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(3)
expect(merge_requests.size).to eq(7)
end
it 'filters by project' do
params = { project_id: project1.id, scope: 'authored', state: 'opened' }
merge_requests = described_class.new(user, params).execute
expect(merge_requests.size).to eq(1)