Commit 57e556bd authored by Rajat Jain's avatar Rajat Jain

Add Operator dropdown

Add third token in the filtered_search -- the middle token which is
an operator with one of two permissible values. Equal or Not Equal
written as `=` or `!=`.

This operator allows an user to filter issues much more efficiently
wherein they can say `author=me assignee!=someone`, to exactly pin point
the search.
parent 5a8ca1cb
...@@ -101,6 +101,11 @@ class DropDown { ...@@ -101,6 +101,11 @@ class DropDown {
render(data) { render(data) {
const children = data ? data.map(this.renderChildren.bind(this)) : []; const children = data ? data.map(this.renderChildren.bind(this)) : [];
if (this.list.querySelector('.filter-dropdown-loading')) {
return;
}
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join(''); renderableList.innerHTML = children.join('');
......
...@@ -2,6 +2,7 @@ import { __ } from '~/locale'; ...@@ -2,6 +2,7 @@ import { __ } from '~/locale';
export default IssuableTokenKeys => { export default IssuableTokenKeys => {
const wipToken = { const wipToken = {
formattedKey: __('WIP'),
key: 'wip', key: 'wip',
type: 'string', type: 'string',
param: '', param: '',
...@@ -17,6 +18,7 @@ export default IssuableTokenKeys => { ...@@ -17,6 +18,7 @@ export default IssuableTokenKeys => {
IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken); IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
const targetBranchToken = { const targetBranchToken = {
formattedKey: __('Target-Branch'),
key: 'target-branch', key: 'target-branch',
type: 'string', type: 'string',
param: '', param: '',
......
import { __ } from '~/locale';
import FilteredSearchTokenKeys from './filtered_search_token_keys'; import FilteredSearchTokenKeys from './filtered_search_token_keys';
const tokenKeys = [ const tokenKeys = [
{ {
formattedKey: __('Status'),
key: 'status', key: 'status',
type: 'string', type: 'string',
param: 'status', param: 'status',
...@@ -10,6 +12,7 @@ const tokenKeys = [ ...@@ -10,6 +12,7 @@ const tokenKeys = [
tag: 'status', tag: 'status',
}, },
{ {
formattedKey: __('Type'),
key: 'type', key: 'type',
type: 'string', type: 'string',
param: 'type', param: 'type',
...@@ -18,6 +21,7 @@ const tokenKeys = [ ...@@ -18,6 +21,7 @@ const tokenKeys = [
tag: 'type', tag: 'type',
}, },
{ {
formattedKey: __('Tag'),
key: 'tag', key: 'tag',
type: 'array', type: 'array',
param: 'name[]', param: 'name[]',
......
...@@ -4,6 +4,7 @@ import DropdownNonUser from './dropdown_non_user'; ...@@ -4,6 +4,7 @@ import DropdownNonUser from './dropdown_non_user';
import DropdownEmoji from './dropdown_emoji'; import DropdownEmoji from './dropdown_emoji';
import NullDropdown from './null_dropdown'; import NullDropdown from './null_dropdown';
import DropdownAjaxFilter from './dropdown_ajax_filter'; import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownOperator from './dropdown_operator';
import DropdownUtils from './dropdown_utils'; import DropdownUtils from './dropdown_utils';
import { mergeUrlParams } from '../lib/utils/url_utility'; import { mergeUrlParams } from '../lib/utils/url_utility';
...@@ -40,6 +41,11 @@ export default class AvailableDropdownMappings { ...@@ -40,6 +41,11 @@ export default class AvailableDropdownMappings {
gl: DropdownHint, gl: DropdownHint,
element: this.container.querySelector('#js-dropdown-hint'), element: this.container.querySelector('#js-dropdown-hint'),
}, },
operator: {
reference: null,
gl: DropdownOperator,
element: this.container.querySelector('#js-dropdown-operator'),
},
}; };
supportedTokens.forEach(type => { supportedTokens.forEach(type => {
......
...@@ -29,6 +29,7 @@ export default { ...@@ -29,6 +29,7 @@ export default {
const resultantTokens = tokens.map(token => ({ const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`, prefix: `${token.key}:`,
operator: token.operator,
suffix: `${token.symbol}${token.value}`, suffix: `${token.symbol}${token.value}`,
})); }));
...@@ -75,6 +76,7 @@ export default { ...@@ -75,6 +76,7 @@ export default {
class="filtered-search-history-dropdown-token" class="filtered-search-history-dropdown-token"
> >
<span class="name">{{ token.prefix }}</span> <span class="name">{{ token.prefix }}</span>
<span class="name">{{ token.operator }}</span>
<span class="value">{{ token.suffix }}</span> <span class="value">{{ token.suffix }}</span>
</span> </span>
</span> </span>
......
/* eslint-disable import/prefer-default-export */
export const USER_TOKEN_TYPES = ['author', 'assignee']; export const USER_TOKEN_TYPES = ['author', 'assignee'];
export const DROPDOWN_TYPE = {
hint: 'hint',
operator: 'operator',
};
...@@ -45,7 +45,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown { ...@@ -45,7 +45,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown {
getSearchInput() { getSearchInput() {
const query = DropdownUtils.getSearchInput(this.input); const query = DropdownUtils.getSearchInput(this.input);
const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.getKeys());
let value = lastToken || ''; let value = lastToken || '';
......
...@@ -3,6 +3,7 @@ import FilteredSearchDropdown from './filtered_search_dropdown'; ...@@ -3,6 +3,7 @@ import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils'; import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import { __ } from '~/locale';
export default class DropdownHint extends FilteredSearchDropdown { export default class DropdownHint extends FilteredSearchDropdown {
constructor(options = {}) { constructor(options = {}) {
...@@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown { ...@@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown {
this.dismissDropdown(); this.dismissDropdown();
this.dispatchFormSubmitEvent(); this.dispatchFormSubmitEvent();
} else { } else {
const token = selected.querySelector('.js-filter-hint').innerText.trim(); const filterItemEl = selected.closest('.filter-dropdown-item');
const tag = selected.querySelector('.js-filter-tag').innerText.trim(); const { hint: token, tag } = filterItemEl.dataset;
if (tag.length) { if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens // Get previous input values in the input field and convert them into visual tokens
...@@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown { ...@@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown {
const key = token.replace(':', ''); const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key); const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
FilteredSearchDropdownManager.addWordToInput(key, '', false, {
uppercaseTokenName, FilteredSearchDropdownManager.addWordToInput({
tokenName: key,
clicked: false,
options: {
uppercaseTokenName,
},
}); });
} }
this.dismissDropdown(); this.dismissDropdown();
...@@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown { ...@@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown {
} }
renderContent() { renderContent() {
const dropdownData = this.tokenKeys.get().map(tokenKey => ({ const searchItem = [
icon: `${gon.sprite_icons}#${tokenKey.icon}`, {
hint: tokenKey.key, hint: 'search',
tag: `:${tokenKey.tag}`, tag: 'search',
type: tokenKey.type, formattedKey: __('Search for this text'),
})); icon: `${gon.sprite_icons}#search`,
},
];
const dropdownData = this.tokenKeys
.get()
.map(tokenKey => ({
icon: `${gon.sprite_icons}#${tokenKey.icon}`,
hint: tokenKey.key,
tag: `:${tokenKey.tag}`,
type: tokenKey.type,
formattedKey: tokenKey.formattedKey,
}))
.concat(searchItem);
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData); this.droplab.setData(this.hookId, dropdownData);
super.renderContent();
} }
init() { init() {
......
import Filter from '~/droplab/plugins/filter';
import { __ } from '~/locale';
import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class DropdownOperator extends FilteredSearchDropdown {
constructor(options = {}) {
const { input, tokenKeys } = options;
super(options);
this.config = {
Filter: {
filterFunction: DropdownUtils.filterWithSymbol.bind(null, '', input),
template: 'title',
},
};
this.tokenKeys = tokenKeys;
}
itemClicked(e) {
const { selected } = e.detail;
if (selected.tagName === 'LI') {
if (selected.hasAttribute('data-value')) {
const operator = selected.dataset.value;
FilteredSearchVisualTokens.removeLastTokenPartial();
FilteredSearchDropdownManager.addWordToInput({
tokenName: this.filter,
tokenOperator: operator,
clicked: false,
});
}
}
this.dismissDropdown();
this.dispatchInputEvent();
}
renderContent(forceShowList = false) {
this.filter = FilteredSearchVisualTokens.getLastTokenPartial();
const dropdownData = [
{
tag: 'equal',
type: 'string',
title: '=',
help: __('Is'),
},
{
tag: 'not-equal',
type: 'string',
title: '!=',
help: __('Is not'),
},
];
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
super.renderContent(forceShowList);
}
init() {
this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
}
}
...@@ -62,28 +62,42 @@ export default class DropdownUtils { ...@@ -62,28 +62,42 @@ export default class DropdownUtils {
const lastKey = lastToken.key || lastToken || ''; const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array'; const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint); const itemInExistingTokens = tokens.some(t => t.key === item.hint);
const isSearchItem = updatedItem.hint === 'search';
if (isSearchItem) {
updatedItem.droplab_hidden = true;
}
if (!allowMultiple && itemInExistingTokens) { if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true; updatedItem.droplab_hidden = true;
} else if (!lastKey || _.last(searchInput.split('')) === ' ') { } else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) {
updatedItem.droplab_hidden = false; updatedItem.droplab_hidden = false;
} else if (lastKey) { } else if (lastKey) {
const split = lastKey.split(':'); const split = lastKey.split(':');
const tokenName = _.last(split[0].split(' ')); const tokenName = _.last(split[0].split(' '));
const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; const match = isSearchItem
? allowedKeys.some(key => key.startsWith(tokenName.toLowerCase()))
: updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
updatedItem.droplab_hidden = tokenName ? match : false; updatedItem.droplab_hidden = tokenName ? match : false;
} }
return updatedItem; return updatedItem;
} }
static setDataValueIfSelected(filter, selected) { static setDataValueIfSelected(filter, operator, selected) {
const dataValue = selected.getAttribute('data-value'); const dataValue = selected.getAttribute('data-value');
if (dataValue) { if (dataValue) {
FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, { FilteredSearchDropdownManager.addWordToInput({
capitalizeTokenValue: selected.hasAttribute('data-capitalize'), tokenName: filter,
tokenOperator: operator,
tokenValue: dataValue,
clicked: true,
options: {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
},
}); });
} }
...@@ -101,7 +115,11 @@ export default class DropdownUtils { ...@@ -101,7 +115,11 @@ export default class DropdownUtils {
// remove leading symbol and wrapping quotes // remove leading symbol and wrapping quotes
tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, ''); tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, '');
} }
return { tokenName, tokenValue };
const operatorEl = visualToken && visualToken.querySelector('.operator');
const tokenOperator = operatorEl && operatorEl.textContent.trim();
return { tokenName, tokenOperator, tokenValue };
} }
// Determines the full search query (visual tokens + input) // Determines the full search query (visual tokens + input)
...@@ -119,10 +137,16 @@ export default class DropdownUtils { ...@@ -119,10 +137,16 @@ export default class DropdownUtils {
tokens.forEach(token => { tokens.forEach(token => {
if (token.classList.contains('js-visual-token')) { if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name'); const name = token.querySelector('.name');
const operatorContainer = token.querySelector('.operator');
const value = token.querySelector('.value'); const value = token.querySelector('.value');
const valueContainer = token.querySelector('.value-container'); const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = ''; let valueText = '';
let operator = '';
if (operatorContainer) {
operator = operatorContainer.textContent.trim();
}
if (valueContainer && valueContainer.dataset.originalValue) { if (valueContainer && valueContainer.dataset.originalValue) {
valueText = valueContainer.dataset.originalValue; valueText = valueContainer.dataset.originalValue;
...@@ -131,7 +155,7 @@ export default class DropdownUtils { ...@@ -131,7 +155,7 @@ export default class DropdownUtils {
} }
if (token.className.indexOf('filtered-search-token') !== -1) { if (token.className.indexOf('filtered-search-token') !== -1) {
values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`); values.push(`${name.innerText.toLowerCase()}:${operator}${symbol}${valueText}`);
} else { } else {
values.push(name.innerText); values.push(name.innerText);
} }
......
import DropdownUtils from './dropdown_utils'; import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
...@@ -31,13 +32,26 @@ export default class FilteredSearchDropdown { ...@@ -31,13 +32,26 @@ export default class FilteredSearchDropdown {
itemClicked(e, getValueFunction) { itemClicked(e, getValueFunction) {
const { selected } = e.detail; const { selected } = e.detail;
if (selected.tagName === 'LI' && selected.innerHTML) { if (selected.tagName === 'LI' && selected.innerHTML) {
const dataValueSet = DropdownUtils.setDataValueIfSelected(this.filter, selected); const {
lastVisualToken: visualToken,
} = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const { tokenOperator } = DropdownUtils.getVisualTokenValues(visualToken);
const dataValueSet = DropdownUtils.setDataValueIfSelected(
this.filter,
tokenOperator,
selected,
);
if (!dataValueSet) { if (!dataValueSet) {
const value = getValueFunction(selected); const value = getValueFunction(selected);
FilteredSearchDropdownManager.addWordToInput(this.filter, value, true); FilteredSearchDropdownManager.addWordToInput({
tokenName: this.filter,
tokenOperator,
tokenValue: value,
clicked: true,
});
} }
this.resetFilters(); this.resetFilters();
......
...@@ -5,6 +5,7 @@ import FilteredSearchContainer from './container'; ...@@ -5,6 +5,7 @@ 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 FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import { DROPDOWN_TYPE } from './constants';
export default class FilteredSearchDropdownManager { export default class FilteredSearchDropdownManager {
constructor({ constructor({
...@@ -67,10 +68,16 @@ export default class FilteredSearchDropdownManager { ...@@ -67,10 +68,16 @@ export default class FilteredSearchDropdownManager {
this.mapping = availableMappings.getAllowedMappings(supportedTokens); this.mapping = availableMappings.getAllowedMappings(supportedTokens);
} }
static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) { static addWordToInput({
tokenName,
tokenOperator = '',
tokenValue = '',
clicked = false,
options = {},
}) {
const { uppercaseTokenName = false, capitalizeTokenValue = 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, tokenOperator, tokenValue, {
uppercaseTokenName, uppercaseTokenName,
capitalizeTokenValue, capitalizeTokenValue,
}); });
...@@ -129,7 +136,10 @@ export default class FilteredSearchDropdownManager { ...@@ -129,7 +136,10 @@ export default class FilteredSearchDropdownManager {
mappingKey.reference.init(); mappingKey.reference.init();
} }
if (this.currentDropdown === 'hint') { if (
this.currentDropdown === DROPDOWN_TYPE.hint ||
this.currentDropdown === DROPDOWN_TYPE.operator
) {
// Force the dropdown to show if it was clicked from the hint dropdown // Force the dropdown to show if it was clicked from the hint dropdown
forceShowList = true; forceShowList = true;
} }
...@@ -148,13 +158,19 @@ export default class FilteredSearchDropdownManager { ...@@ -148,13 +158,19 @@ export default class FilteredSearchDropdownManager {
this.droplab = new DropLab(); this.droplab = new DropLab();
} }
if (dropdownName === DROPDOWN_TYPE.operator) {
this.load(dropdownName, firstLoad);
return;
}
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown = const shouldOpenFilterDropdown =
match && this.currentDropdown !== match.key && this.mapping[match.key]; match && this.currentDropdown !== match.key && this.mapping[match.key];
const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; const shouldOpenHintDropdown = !match && this.currentDropdown !== DROPDOWN_TYPE.hint;
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
const key = match && match.key ? match.key : 'hint'; const key = match && match.key ? match.key : DROPDOWN_TYPE.hint;
this.load(key, firstLoad); this.load(key, firstLoad);
} }
} }
...@@ -169,19 +185,32 @@ export default class FilteredSearchDropdownManager { ...@@ -169,19 +185,32 @@ export default class FilteredSearchDropdownManager {
if (this.currentDropdown) { if (this.currentDropdown) {
this.updateCurrentDropdownOffset(); this.updateCurrentDropdownOffset();
} }
if (lastToken === searchToken && lastToken !== null) { if (lastToken === searchToken && lastToken !== null) {
// Token is not fully initialized yet because it has no value // Token is not fully initialized yet because it has no value
// Eg. token = 'label:' // Eg. token = 'label:'
const split = lastToken.split(':'); const split = lastToken.split(':');
const dropdownName = _.last(split[0].split(' ')); const dropdownName = _.last(split[0].split(' '));
this.loadDropdown(split.length > 1 ? dropdownName : ''); const possibleOperatorToken = _.last(split[1]);
const hasOperator = FilteredSearchVisualTokens.permissibleOperatorValues.includes(
possibleOperatorToken && possibleOperatorToken.trim(),
);
let dropdownToOpen = '';
if (split.length > 1) {
const lastOperatorToken = FilteredSearchVisualTokens.getLastTokenOperator();
dropdownToOpen = hasOperator && lastOperatorToken ? dropdownName : DROPDOWN_TYPE.operator;
}
this.loadDropdown(dropdownToOpen);
} else if (lastToken) { } else if (lastToken) {
const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator();
// Token has been initialized into an object because it has a value // Token has been initialized into an object because it has a value
this.loadDropdown(lastToken.key); this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator);
} else { } else {
this.loadDropdown('hint'); this.loadDropdown(DROPDOWN_TYPE.hint);
} }
} }
......
...@@ -14,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer'; ...@@ -14,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import DropdownUtils from './dropdown_utils'; import DropdownUtils from './dropdown_utils';
import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes';
import { __ } from '~/locale'; import { __ } from '~/locale';
export default class FilteredSearchManager { export default class FilteredSearchManager {
...@@ -58,6 +59,8 @@ export default class FilteredSearchManager { ...@@ -58,6 +59,8 @@ export default class FilteredSearchManager {
this.recentSearchesService = new RecentSearchesService(recentSearchesKey); this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
} }
static notTransformableQueryParams = ['scope', 'utf8', 'state', 'search'];
setup() { setup() {
// Fetch recent searches from localStorage // Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService this.fetchingRecentSearchesPromise = this.recentSearchesService
...@@ -84,6 +87,7 @@ export default class FilteredSearchManager { ...@@ -84,6 +87,7 @@ export default class FilteredSearchManager {
if (this.filteredSearchInput) { if (this.filteredSearchInput) {
this.tokenizer = FilteredSearchTokenizer; this.tokenizer = FilteredSearchTokenizer;
this.dropdownManager = new FilteredSearchDropdownManager({ this.dropdownManager = new FilteredSearchDropdownManager({
runnerTagsEndpoint: runnerTagsEndpoint:
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
...@@ -172,7 +176,7 @@ export default class FilteredSearchManager { ...@@ -172,7 +176,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper); this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper); this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('click', this.tokenChange);
...@@ -194,7 +198,7 @@ export default class FilteredSearchManager { ...@@ -194,7 +198,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper); this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('click', this.tokenChange);
...@@ -228,7 +232,7 @@ export default class FilteredSearchManager { ...@@ -228,7 +232,7 @@ export default class FilteredSearchManager {
if (backspaceCount === 2) { if (backspaceCount === 2) {
backspaceCount = 0; backspaceCount = 0;
this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(); this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(true);
FilteredSearchVisualTokens.removeLastTokenPartial(); FilteredSearchVisualTokens.removeLastTokenPartial();
} }
} }
...@@ -407,7 +411,12 @@ export default class FilteredSearchManager { ...@@ -407,7 +411,12 @@ export default class FilteredSearchManager {
} }
} }
handleInputVisualToken() { handleInputVisualToken(e) {
// If the keyCode was 8 then do not form new tokens
if (e.keyCode === BACKSPACE_KEY_CODE) {
return;
}
const input = this.filteredSearchInput; const input = this.filteredSearchInput;
const { tokens, searchToken } = this.tokenizer.processTokens( const { tokens, searchToken } = this.tokenizer.processTokens(
input.value, input.value,
...@@ -417,14 +426,21 @@ export default class FilteredSearchManager { ...@@ -417,14 +426,21 @@ 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.operator}${t.symbol}${t.value}`, '');
FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key), FilteredSearchVisualTokens.addFilterVisualToken(
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key), t.key,
}); t.operator,
`${t.symbol}${t.value}`,
{
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
},
);
}); });
const fragments = searchToken.split(':'); const fragments = searchToken.split(':');
if (fragments.length > 1) { if (fragments.length > 1) {
const inputValues = fragments[0].split(' '); const inputValues = fragments[0].split(' ');
const tokenKey = _.last(inputValues); const tokenKey = _.last(inputValues);
...@@ -437,19 +453,58 @@ export default class FilteredSearchManager { ...@@ -437,19 +453,58 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addSearchVisualToken(searchTerms); FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
} }
FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, { FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, null, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey), uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
}); });
input.value = input.value.replace(`${tokenKey}:`, ''); input.value = input.value.replace(`${tokenKey}:`, '');
} }
const splitSearchToken = searchToken && searchToken.split(' ');
let lastSearchToken = _.last(splitSearchToken);
lastSearchToken = lastSearchToken?.toLowerCase();
/**
* If user writes "milestone", a known token, in the input, we should not
* wait for leading colon to flush it as a filter token.
*/
if (this.filteredSearchTokenKeys.getKeys().includes(lastSearchToken)) {
if (splitSearchToken.length > 1) {
splitSearchToken.pop();
const searchVisualTokens = splitSearchToken.join(' ');
input.value = input.value.replace(searchVisualTokens, '');
FilteredSearchVisualTokens.addSearchVisualToken(searchVisualTokens);
}
FilteredSearchVisualTokens.addFilterVisualToken(lastSearchToken, null, null, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(
lastSearchToken,
),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(
lastSearchToken,
),
});
input.value = input.value.replace(lastSearchToken, '');
}
} else if (!isLastVisualTokenValid && !FilteredSearchVisualTokens.getLastTokenOperator()) {
const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
const tokenOperator = searchToken && searchToken.trim();
// Tokenize operator only if the operator token is valid
if (FilteredSearchVisualTokens.permissibleOperatorValues.includes(tokenOperator)) {
FilteredSearchVisualTokens.removeLastTokenPartial();
FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, tokenOperator, null, {
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
input.value = input.value.replace(searchToken, '').trim();
}
} else { } else {
// Keep listening to token until we determine that the user is done typing the token value // Keep listening to token until we determine that the user is done typing the token value
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] === ' ') {
const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial(); const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, { FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, null, {
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
}); });
...@@ -484,9 +539,52 @@ export default class FilteredSearchManager { ...@@ -484,9 +539,52 @@ export default class FilteredSearchManager {
return this.modifyUrlParams ? this.modifyUrlParams(urlParams) : urlParams; return this.modifyUrlParams ? this.modifyUrlParams(urlParams) : urlParams;
} }
transformParams(params) {
/**
* Extract key, value pair from the `not` query param:
* Query param looks like not[key]=value
*
* Eg. not[foo]=%bar
* key = foo; value = %bar
*/
const notKeyValueRegex = new RegExp(/not\[(\w+)\]\[?\]?=(.*)/);
return params.map(query => {
// Check if there are matches for `not` operator
const matches = query.match(notKeyValueRegex);
if (matches && matches.length === 3) {
const keyParam = matches[1];
if (
FilteredSearchManager.notTransformableQueryParams.includes(keyParam) ||
this.filteredSearchTokenKeys.searchByConditionUrl(query)
) {
return query;
}
const valueParam = matches[2];
// Not operator
const operator = encodeURIComponent('!=');
return `${keyParam}=${operator}${valueParam}`;
}
const [keyParam, valueParam] = query.split('=');
if (
FilteredSearchManager.notTransformableQueryParams.includes(keyParam) ||
this.filteredSearchTokenKeys.searchByConditionUrl(query)
) {
return query;
}
const operator = encodeURIComponent('=');
return `${keyParam}=${operator}${valueParam}`;
});
}
loadSearchParamsFromURL() { loadSearchParamsFromURL() {
const urlParams = getUrlParamsArray(); const urlParams = getUrlParamsArray();
const params = this.getAllParams(urlParams); const withOperatorParams = this.transformParams(urlParams);
const params = this.getAllParams(withOperatorParams);
const usernameParams = this.getUsernameParams(); const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false; let hasFilteredSearch = false;
...@@ -501,9 +599,14 @@ export default class FilteredSearchManager { ...@@ -501,9 +599,14 @@ export default class FilteredSearchManager {
if (condition) { if (condition) {
hasFilteredSearch = true; hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(condition.tokenKey); const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value, { FilteredSearchVisualTokens.addFilterVisualToken(
canEdit, condition.tokenKey,
}); condition.operator,
condition.value,
{
canEdit,
},
);
} else { } else {
// Sanitize value since URL converts spaces into + // Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded + // Replace before decode so that we know what was originally + versus the encoded +
...@@ -522,9 +625,12 @@ export default class FilteredSearchManager { ...@@ -522,9 +625,12 @@ export default class FilteredSearchManager {
hasFilteredSearch = true; hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(key, sanitizedValue); const canEdit = this.canEdit && this.canEdit(key, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match; const { uppercaseTokenName, capitalizeTokenValue } = match;
const operator = FilteredSearchVisualTokens.getOperatorToken(sanitizedValue);
const sanitizedToken = FilteredSearchVisualTokens.getValueToken(sanitizedValue);
FilteredSearchVisualTokens.addFilterVisualToken( FilteredSearchVisualTokens.addFilterVisualToken(
key, key,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, operator,
`${symbol}${quotationsToUse}${sanitizedToken}${quotationsToUse}`,
{ {
canEdit, canEdit,
uppercaseTokenName, uppercaseTokenName,
...@@ -537,7 +643,10 @@ export default class FilteredSearchManager { ...@@ -537,7 +643,10 @@ 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]}`, { const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]);
const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, {
canEdit, canEdit,
}); });
} }
...@@ -547,7 +656,10 @@ export default class FilteredSearchManager { ...@@ -547,7 +656,10 @@ 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]}`, { const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]);
const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, {
canEdit, canEdit,
}); });
} }
...@@ -582,7 +694,6 @@ export default class FilteredSearchManager { ...@@ -582,7 +694,6 @@ export default class FilteredSearchManager {
search(state = null) { search(state = null) {
const paths = []; const paths = [];
const searchQuery = DropdownUtils.getSearchQuery(); const searchQuery = DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery(); this.saveCurrentSearchQuery();
const tokenKeys = this.filteredSearchTokenKeys.getKeys(); const tokenKeys = this.filteredSearchTokenKeys.getKeys();
...@@ -593,6 +704,7 @@ export default class FilteredSearchManager { ...@@ -593,6 +704,7 @@ export default class FilteredSearchManager {
tokens.forEach(token => { tokens.forEach(token => {
const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue( const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue(
token.key, token.key,
token.operator,
token.value, token.value,
); );
const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
...@@ -620,7 +732,16 @@ export default class FilteredSearchManager { ...@@ -620,7 +732,16 @@ export default class FilteredSearchManager {
tokenValue = tokenValue.slice(1, tokenValue.length - 1); tokenValue = tokenValue.slice(1, tokenValue.length - 1);
} }
tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; if (token.operator === '!=') {
const isArrayParam = keyParam.endsWith('[]');
tokenPath = `not[${isArrayParam ? keyParam.slice(0, -2) : keyParam}]${
isArrayParam ? '[]' : ''
}=${encodeURIComponent(tokenValue)}`;
} else {
// Default operator is `=`
tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
}
} }
paths.push(tokenPath); paths.push(tokenPath);
......
...@@ -65,17 +65,20 @@ export default class FilteredSearchTokenKeys { ...@@ -65,17 +65,20 @@ export default class FilteredSearchTokenKeys {
return this.conditions.find(condition => condition.url === url) || null; return this.conditions.find(condition => condition.url === url) || null;
} }
searchByConditionKeyValue(key, value) { searchByConditionKeyValue(key, operator, value) {
return ( return (
this.conditions.find( this.conditions.find(
condition => condition =>
condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(), condition.tokenKey === key &&
condition.operator === operator &&
condition.value.toLowerCase() === value.toLowerCase(),
) || null ) || null
); );
} }
addExtraTokensForIssues() { addExtraTokensForIssues() {
const confidentialToken = { const confidentialToken = {
formattedKey: __('Confidential'),
key: 'confidential', key: 'confidential',
type: 'string', type: 'string',
param: '', param: '',
......
...@@ -2,10 +2,11 @@ import './filtered_search_token_keys'; ...@@ -2,10 +2,11 @@ import './filtered_search_token_keys';
export default class FilteredSearchTokenizer { export default class FilteredSearchTokenizer {
static processTokens(input, allowedKeys) { static processTokens(input, allowedKeys) {
// Regex extracts `(token):(symbol)(value)` // Regex extracts `(token):(operator)(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single) // Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp( const tokenRegex = new RegExp(
`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, `(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
'g', 'g',
); );
const tokens = []; const tokens = [];
...@@ -13,16 +14,22 @@ export default class FilteredSearchTokenizer { ...@@ -13,16 +14,22 @@ export default class FilteredSearchTokenizer {
let lastToken = null; let lastToken = null;
const searchToken = const searchToken =
input input
.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => { .replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => {
let tokenValue = v1 || v2 || v3; let tokenValue = v1 || v2 || v3;
let tokenSymbol = symbol; let tokenSymbol = symbol;
let tokenIndex = ''; let tokenIndex = '';
let tokenOperator = operator;
if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
tokenSymbol = tokenValue; tokenSymbol = tokenValue;
tokenValue = ''; tokenValue = '';
} }
if (tokenValue === '!=' || tokenValue === '=') {
tokenOperator = tokenValue;
tokenValue = '';
}
tokenIndex = `${key}:${tokenValue}`; tokenIndex = `${key}:${tokenValue}`;
// Prevent adding duplicates // Prevent adding duplicates
...@@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer { ...@@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer {
key, key,
value: tokenValue || '', value: tokenValue || '',
symbol: tokenSymbol || '', symbol: tokenSymbol || '',
operator: tokenOperator || '',
}); });
} }
...@@ -43,13 +51,12 @@ export default class FilteredSearchTokenizer { ...@@ -43,13 +51,12 @@ export default class FilteredSearchTokenizer {
if (tokens.length > 0) { if (tokens.length > 0) {
const last = tokens[tokens.length - 1]; const last = tokens[tokens.length - 1];
const lastString = `${last.key}:${last.symbol}${last.value}`; const lastString = `${last.key}:${last.operator}${last.symbol}${last.value}`;
lastToken = lastToken =
input.lastIndexOf(lastString) === input.length - lastString.length ? last : searchToken; input.lastIndexOf(lastString) === input.length - lastString.length ? last : searchToken;
} else { } else {
lastToken = searchToken; lastToken = searchToken;
} }
return { return {
tokens, tokens,
lastToken, lastToken,
......
...@@ -3,6 +3,32 @@ import { objectToQueryString } from '~/lib/utils/common_utils'; ...@@ -3,6 +3,32 @@ import { objectToQueryString } from '~/lib/utils/common_utils';
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
export default class FilteredSearchVisualTokens { export default class FilteredSearchVisualTokens {
static permissibleOperatorValues = ['=', '!='];
static getOperatorToken(value) {
let token = null;
FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => {
if (value.startsWith(operatorToken)) {
token = operatorToken;
}
});
return token;
}
static getValueToken(value) {
let newValue = value;
FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => {
if (value.startsWith(operatorToken)) {
newValue = value.slice(operatorToken.length);
}
});
return newValue;
}
static getLastVisualTokenBeforeInput() { static getLastVisualTokenBeforeInput() {
const inputLi = FilteredSearchContainer.container.querySelector('.input-token'); const inputLi = FilteredSearchContainer.container.querySelector('.input-token');
const lastVisualToken = inputLi && inputLi.previousElementSibling; const lastVisualToken = inputLi && inputLi.previousElementSibling;
...@@ -12,7 +38,9 @@ export default class FilteredSearchVisualTokens { ...@@ -12,7 +38,9 @@ export default class FilteredSearchVisualTokens {
isLastVisualTokenValid: isLastVisualTokenValid:
lastVisualToken === null || lastVisualToken === null ||
lastVisualToken.className.indexOf('filtered-search-term') !== -1 || lastVisualToken.className.indexOf('filtered-search-term') !== -1 ||
(lastVisualToken && lastVisualToken.querySelector('.value') !== null), (lastVisualToken &&
lastVisualToken.querySelector('.operator') !== null &&
lastVisualToken.querySelector('.value') !== null),
}; };
} }
...@@ -42,11 +70,17 @@ export default class FilteredSearchVisualTokens { ...@@ -42,11 +70,17 @@ export default class FilteredSearchVisualTokens {
} }
static createVisualTokenElementHTML(options = {}) { static createVisualTokenElementHTML(options = {}) {
const { canEdit = true, uppercaseTokenName = false, capitalizeTokenValue = false } = options; const {
canEdit = true,
hasOperator = false,
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = options;
return ` return `
<div class="${canEdit ? 'selectable' : 'hidden'}" role="button"> <div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div> <div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>
${hasOperator ? '<div class="operator"></div>' : ''}
<div class="value-container"> <div class="value-container">
<div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div> <div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div>
<div class="remove-token" role="button"> <div class="remove-token" role="button">
...@@ -57,18 +91,18 @@ export default class FilteredSearchVisualTokens { ...@@ -57,18 +91,18 @@ export default class FilteredSearchVisualTokens {
`; `;
} }
static renderVisualTokenValue(parentElement, tokenName, tokenValue) { static renderVisualTokenValue(parentElement, tokenName, tokenValue, tokenOperator) {
const tokenType = tokenName.toLowerCase(); 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;
const visualTokenValue = new VisualTokenValue(tokenValue, tokenType); const visualTokenValue = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
visualTokenValue.render(tokenValueContainer, tokenValueElement); visualTokenValue.render(tokenValueContainer, tokenValueElement);
} }
static addVisualTokenElement(name, value, options = {}) { static addVisualTokenElement({ name, operator, value, options = {} }) {
const { const {
isSearchTerm = false, isSearchTerm = false,
canEdit, canEdit,
...@@ -84,17 +118,32 @@ export default class FilteredSearchVisualTokens { ...@@ -84,17 +118,32 @@ export default class FilteredSearchVisualTokens {
li.classList.add(tokenClass); li.classList.add(tokenClass);
} }
const hasOperator = Boolean(operator);
if (value) { if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit, canEdit,
uppercaseTokenName, uppercaseTokenName,
operator,
hasOperator,
capitalizeTokenValue, capitalizeTokenValue,
}); });
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value, operator);
} else { } else {
li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`; const nameHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
let operatorHTML = '';
if (hasOperator) {
operatorHTML = '<div class="operator"></div>';
}
li.innerHTML = nameHTML + operatorHTML;
} }
li.querySelector('.name').innerText = name; li.querySelector('.name').innerText = name;
if (hasOperator) {
li.querySelector('.operator').innerText = operator;
}
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
const input = FilteredSearchContainer.container.querySelector('.filtered-search'); const input = FilteredSearchContainer.container.querySelector('.filtered-search');
...@@ -109,14 +158,19 @@ export default class FilteredSearchVisualTokens { ...@@ -109,14 +158,19 @@ export default class FilteredSearchVisualTokens {
if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) { if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) {
const name = FilteredSearchVisualTokens.getLastTokenPartial(); const name = FilteredSearchVisualTokens.getLastTokenPartial();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); const operator = FilteredSearchVisualTokens.getLastTokenOperator();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
hasOperator: Boolean(operator),
});
lastVisualToken.querySelector('.name').innerText = name; lastVisualToken.querySelector('.name').innerText = name;
FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value); lastVisualToken.querySelector('.operator').innerText = operator;
FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value, operator);
} }
} }
static addFilterVisualToken( static addFilterVisualToken(
tokenName, tokenName,
tokenOperator,
tokenValue, tokenValue,
{ canEdit, uppercaseTokenName = false, capitalizeTokenValue = false } = {}, { canEdit, uppercaseTokenName = false, capitalizeTokenValue = false } = {},
) { ) {
...@@ -127,21 +181,51 @@ export default class FilteredSearchVisualTokens { ...@@ -127,21 +181,51 @@ export default class FilteredSearchVisualTokens {
const { addVisualTokenElement } = FilteredSearchVisualTokens; const { addVisualTokenElement } = FilteredSearchVisualTokens;
if (isLastVisualTokenValid) { if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue, { addVisualTokenElement({
canEdit, name: tokenName,
uppercaseTokenName, operator: tokenOperator,
capitalizeTokenValue, value: tokenValue,
options: {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
});
} else if (
!isLastVisualTokenValid &&
(lastVisualToken && !lastVisualToken.querySelector('.operator'))
) {
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
addVisualTokenElement({
name: tokenName,
operator: tokenOperator,
value: tokenValue,
options: {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
}); });
} else { } else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText; const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const previousTokenOperator = lastVisualToken.querySelector('.operator').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; let value = tokenValue;
addVisualTokenElement(previousTokenName, value, { if (!value && !tokenOperator) {
canEdit, value = tokenName;
uppercaseTokenName, }
capitalizeTokenValue, addVisualTokenElement({
name: previousTokenName,
operator: previousTokenOperator,
value,
options: {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
}); });
} }
} }
...@@ -152,13 +236,18 @@ export default class FilteredSearchVisualTokens { ...@@ -152,13 +236,18 @@ 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, { FilteredSearchVisualTokens.addVisualTokenElement({
isSearchTerm: true, name: searchTerm,
operator: null,
value: null,
options: {
isSearchTerm: true,
},
}); });
} }
} }
static getLastTokenPartial() { static getLastTokenPartial(includeOperator = false) {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!lastVisualToken) return ''; if (!lastVisualToken) return '';
...@@ -175,20 +264,36 @@ export default class FilteredSearchVisualTokens { ...@@ -175,20 +264,36 @@ export default class FilteredSearchVisualTokens {
const valueText = value ? value.innerText : ''; const valueText = value ? value.innerText : '';
const nameText = name ? name.innerText : ''; const nameText = name ? name.innerText : '';
if (includeOperator) {
const operator = lastVisualToken.querySelector('.operator');
const operatorText = operator ? operator.innerText : '';
return valueText || operatorText || nameText;
}
return valueText || nameText; return valueText || nameText;
} }
static getLastTokenOperator() {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const operator = lastVisualToken && lastVisualToken.querySelector('.operator');
return operator?.innerText;
}
static removeLastTokenPartial() { static removeLastTokenPartial() {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (lastVisualToken) { if (lastVisualToken) {
const value = lastVisualToken.querySelector('.value'); const value = lastVisualToken.querySelector('.value');
const operator = lastVisualToken.querySelector('.operator');
if (value) { if (value) {
const button = lastVisualToken.querySelector('.selectable'); const button = lastVisualToken.querySelector('.selectable');
const valueContainer = lastVisualToken.querySelector('.value-container'); const valueContainer = lastVisualToken.querySelector('.value-container');
button.removeChild(valueContainer); button.removeChild(valueContainer);
lastVisualToken.innerHTML = button.innerHTML; lastVisualToken.innerHTML = button.innerHTML;
} else if (operator) {
lastVisualToken.removeChild(operator);
} else { } else {
lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken); lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken);
} }
...@@ -236,12 +341,18 @@ export default class FilteredSearchVisualTokens { ...@@ -236,12 +341,18 @@ export default class FilteredSearchVisualTokens {
tokenContainer.replaceChild(inputLi, token); tokenContainer.replaceChild(inputLi, token);
const nameElement = token.querySelector('.name'); const nameElement = token.querySelector('.name');
const operatorElement = token.querySelector('.operator');
let value; let value;
if (token.classList.contains('filtered-search-token')) { if (token.classList.contains('filtered-search-token')) {
FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, { FilteredSearchVisualTokens.addFilterVisualToken(
uppercaseTokenName: nameElement.classList.contains('text-uppercase'), nameElement.innerText,
}); operatorElement.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;
......
import { flatten } from 'underscore';
import FilteredSearchTokenKeys from './filtered_search_token_keys'; import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale'; import { __ } from '~/locale';
export const tokenKeys = [ export const tokenKeys = [
{ {
formattedKey: __('Author'),
key: 'author', key: 'author',
type: 'string', type: 'string',
param: 'username', param: 'username',
...@@ -11,6 +13,7 @@ export const tokenKeys = [ ...@@ -11,6 +13,7 @@ export const tokenKeys = [
tag: '@author', tag: '@author',
}, },
{ {
formattedKey: __('Assignee'),
key: 'assignee', key: 'assignee',
type: 'string', type: 'string',
param: 'username', param: 'username',
...@@ -19,6 +22,7 @@ export const tokenKeys = [ ...@@ -19,6 +22,7 @@ export const tokenKeys = [
tag: '@assignee', tag: '@assignee',
}, },
{ {
formattedKey: __('Milestone'),
key: 'milestone', key: 'milestone',
type: 'string', type: 'string',
param: 'title', param: 'title',
...@@ -27,6 +31,7 @@ export const tokenKeys = [ ...@@ -27,6 +31,7 @@ export const tokenKeys = [
tag: '%milestone', tag: '%milestone',
}, },
{ {
formattedKey: __('Release'),
key: 'release', key: 'release',
type: 'string', type: 'string',
param: 'tag', param: 'tag',
...@@ -35,6 +40,7 @@ export const tokenKeys = [ ...@@ -35,6 +40,7 @@ export const tokenKeys = [
tag: __('tag name'), tag: __('tag name'),
}, },
{ {
formattedKey: __('Label'),
key: 'label', key: 'label',
type: 'array', type: 'array',
param: 'name[]', param: 'name[]',
...@@ -47,6 +53,7 @@ export const tokenKeys = [ ...@@ -47,6 +53,7 @@ export const tokenKeys = [
if (gon.current_user_id) { if (gon.current_user_id) {
// Appending tokenkeys only logged-in // Appending tokenkeys only logged-in
tokenKeys.push({ tokenKeys.push({
formattedKey: __('My-Reaction'),
key: 'my-reaction', key: 'my-reaction',
type: 'string', type: 'string',
param: 'emoji', param: 'emoji',
...@@ -58,6 +65,7 @@ if (gon.current_user_id) { ...@@ -58,6 +65,7 @@ if (gon.current_user_id) {
export const alternativeTokenKeys = [ export const alternativeTokenKeys = [
{ {
formattedKey: __('Label'),
key: 'label', key: 'label',
type: 'string', type: 'string',
param: 'name', param: 'name',
...@@ -65,68 +73,88 @@ export const alternativeTokenKeys = [ ...@@ -65,68 +73,88 @@ export const alternativeTokenKeys = [
}, },
]; ];
export const conditions = [ export const conditions = flatten(
{ [
url: 'assignee_id=None', {
tokenKey: 'assignee', url: 'assignee_id=None',
value: __('None'), tokenKey: 'assignee',
}, value: __('None'),
{ },
url: 'assignee_id=Any', {
tokenKey: 'assignee', url: 'assignee_id=Any',
value: __('Any'), tokenKey: 'assignee',
}, value: __('Any'),
{ },
url: 'milestone_title=None', {
tokenKey: 'milestone', url: 'milestone_title=None',
value: __('None'), tokenKey: 'milestone',
}, value: __('None'),
{ },
url: 'milestone_title=Any', {
tokenKey: 'milestone', url: 'milestone_title=Any',
value: __('Any'), tokenKey: 'milestone',
}, value: __('Any'),
{ },
url: 'milestone_title=%23upcoming', {
tokenKey: 'milestone', url: 'milestone_title=%23upcoming',
value: __('Upcoming'), tokenKey: 'milestone',
}, value: __('Upcoming'),
{ },
url: 'milestone_title=%23started', {
tokenKey: 'milestone', url: 'milestone_title=%23started',
value: __('Started'), tokenKey: 'milestone',
}, value: __('Started'),
{ },
url: 'release_tag=None', {
tokenKey: 'release', url: 'release_tag=None',
value: __('None'), tokenKey: 'release',
}, value: __('None'),
{ },
url: 'release_tag=Any', {
tokenKey: 'release', url: 'release_tag=Any',
value: __('Any'), tokenKey: 'release',
}, value: __('Any'),
{ },
url: 'label_name[]=None', {
tokenKey: 'label', url: 'label_name[]=None',
value: __('None'), tokenKey: 'label',
}, value: __('None'),
{ },
url: 'label_name[]=Any', {
tokenKey: 'label', url: 'label_name[]=Any',
value: __('Any'), tokenKey: 'label',
}, value: __('Any'),
{ },
url: 'my_reaction_emoji=None', {
tokenKey: 'my-reaction', url: 'my_reaction_emoji=None',
value: __('None'), tokenKey: 'my-reaction',
}, value: __('None'),
{ },
url: 'my_reaction_emoji=Any', {
tokenKey: 'my-reaction', url: 'my_reaction_emoji=Any',
value: __('Any'), tokenKey: 'my-reaction',
}, value: __('Any'),
]; },
].map(condition => {
const [keyPart, valuePart] = condition.url.split('=');
const hasBrackets = keyPart.includes('[]');
const notEqualUrl = `not[${hasBrackets ? keyPart.slice(0, -2) : keyPart}]${
hasBrackets ? '[]' : ''
}=${valuePart}`;
return [
{
...condition,
operator: '=',
},
{
...condition,
operator: '!=',
url: notEqualUrl,
},
];
}),
);
const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys( const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys(
tokenKeys, tokenKeys,
......
...@@ -9,9 +9,10 @@ import UsersCache from '~/lib/utils/users_cache'; ...@@ -9,9 +9,10 @@ import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale'; import { __ } from '~/locale';
export default class VisualTokenValue { export default class VisualTokenValue {
constructor(tokenValue, tokenType) { constructor(tokenValue, tokenType, tokenOperator) {
this.tokenValue = tokenValue; this.tokenValue = tokenValue;
this.tokenType = tokenType; this.tokenType = tokenType;
this.tokenOperator = tokenOperator;
} }
render(tokenValueContainer, tokenValueElement) { render(tokenValueContainer, tokenValueElement) {
......
...@@ -2,3 +2,4 @@ export const UP_KEY_CODE = 38; ...@@ -2,3 +2,4 @@ export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40; export const DOWN_KEY_CODE = 40;
export const ENTER_KEY_CODE = 13; export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27; export const ESC_KEY_CODE = 27;
export const BACKSPACE_KEY_CODE = 8;
...@@ -88,6 +88,7 @@ ...@@ -88,6 +88,7 @@
} }
.name, .name,
.operator,
.value { .value {
display: inline-block; display: inline-block;
padding: 2px 7px; padding: 2px 7px;
...@@ -101,6 +102,12 @@ ...@@ -101,6 +102,12 @@
text-transform: capitalize; text-transform: capitalize;
} }
.operator {
background-color: $white-normal;
color: $filter-value-text-color;
margin-right: 1px;
}
.value-container { .value-container {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -147,6 +154,10 @@ ...@@ -147,6 +154,10 @@
background-color: $filter-name-selected-color; background-color: $filter-name-selected-color;
} }
.operator {
box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
}
.value-container { .value-container {
box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color; box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
} }
...@@ -260,6 +271,11 @@ ...@@ -260,6 +271,11 @@
max-width: none; max-width: none;
min-width: 100%; min-width: 100%;
} }
.btn-helptext {
margin-left: auto;
color: var(--gray);
}
} }
.filtered-search-history-dropdown-wrapper { .filtered-search-history-dropdown-wrapper {
......
...@@ -90,7 +90,7 @@ module Boards ...@@ -90,7 +90,7 @@ module Boards
end end
def filter_params def filter_params
params.merge(board_id: params[:board_id], id: params[:list_id]) params.permit(*Boards::Issues::ListService.valid_params).merge(board_id: params[:board_id], id: params[:list_id])
.reject { |_, value| value.nil? } .reject { |_, value| value.nil? }
end end
......
...@@ -87,7 +87,7 @@ class IssuableFinder ...@@ -87,7 +87,7 @@ class IssuableFinder
end end
def valid_params def valid_params
@valid_params ||= scalar_params + [array_params] + [{ not: [] }] @valid_params ||= scalar_params + [array_params.merge(not: {})]
end end
end end
......
...@@ -5,6 +5,10 @@ module Boards ...@@ -5,6 +5,10 @@ module Boards
class ListService < Boards::BaseService class ListService < Boards::BaseService
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
def self.valid_params
IssuesFinder.valid_params
end
def execute def execute
fetch_issues.order_by_position_and_priority fetch_issues.order_by_position_and_priority
end end
......
...@@ -57,24 +57,22 @@ ...@@ -57,24 +57,22 @@
%li.input-token %li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') } %input.form-control.filtered-search{ search_filter_input_options('runners') }
#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 } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
= button_tag class: %w[btn btn-link] do
= sprite_icon('search')
%span
= _('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{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[btn btn-link] do = button_tag class: %w[btn btn-link] do
-# 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
%use{ 'xlink:href': "#{'{{icon}}'}" } %use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint %span.js-filter-hint
{{hint}} {{formattedKey}}
%span.js-filter-tag.dropdown-light-content #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
{{tag}} %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status| - Ci::Runner::AVAILABLE_STATUSES.each do |status|
......
...@@ -30,23 +30,22 @@ ...@@ -30,23 +30,22 @@
%li.input-token %li.input-token
%input.form-control.filtered-search{ search_filter_input_options(type) } %input.form-control.filtered-search{ search_filter_input_options(type) }
#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 } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
%button.btn.btn-link{ type: 'button' }
= sprite_icon('search')
%span
= _('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{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
%button.btn.btn-link{ type: 'button' } %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
%use{ 'xlink:href': "#{'{{icon}}'}" } %use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint %span.js-filter-hint
{{hint}} {{formattedKey}}
%span.js-filter-tag.dropdown-light-content #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
{{tag}} %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
- if current_user - if current_user
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
......
---
title: Add support for operator in filter bar
merge_request: 19011
author:
type: added
import { __ } from '~/locale';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
const tokenKeys = [ const tokenKeys = [
{ {
formattedKey: __('Author'),
key: 'author', key: 'author',
type: 'string', type: 'string',
param: 'username', param: 'username',
...@@ -10,6 +12,7 @@ const tokenKeys = [ ...@@ -10,6 +12,7 @@ const tokenKeys = [
tag: '@author', tag: '@author',
}, },
{ {
formattedKey: __('Milestone'),
key: 'milestone', key: 'milestone',
type: 'string', type: 'string',
param: 'title', param: 'title',
...@@ -18,6 +21,7 @@ const tokenKeys = [ ...@@ -18,6 +21,7 @@ const tokenKeys = [
tag: '%milestone', tag: '%milestone',
}, },
{ {
formattedKey: __('Label'),
key: 'label', key: 'label',
type: 'array', type: 'array',
param: 'name[]', param: 'name[]',
......
...@@ -9,15 +9,30 @@ export default IssuableTokenKeys => { ...@@ -9,15 +9,30 @@ export default IssuableTokenKeys => {
url: 'approver_usernames[]=None', url: 'approver_usernames[]=None',
tokenKey: 'approver', tokenKey: 'approver',
value: __('None'), value: __('None'),
operator: '=',
},
{
url: 'not[approver_usernames][]=None',
tokenKey: 'approver',
value: __('None'),
operator: '!=',
}, },
{ {
url: 'approver_usernames[]=Any', url: 'approver_usernames[]=Any',
tokenKey: 'approver', tokenKey: 'approver',
value: __('Any'), value: __('Any'),
operator: '=',
},
{
url: 'not[approver_usernames][]=Any',
tokenKey: 'approver',
value: __('Any'),
operator: '!=',
}, },
]; ];
const approversToken = { const approversToken = {
formattedKey: __('Approver'),
key: 'approver', key: 'approver',
type: 'array', type: 'array',
param: 'usernames[]', param: 'usernames[]',
......
import { __ } from '~/locale';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
const tokenKeys = [ const tokenKeys = [
{ {
formattedKey: __('Author'),
key: 'author', key: 'author',
type: 'string', type: 'string',
param: 'username', param: 'username',
...@@ -10,6 +12,7 @@ const tokenKeys = [ ...@@ -10,6 +12,7 @@ const tokenKeys = [
tag: '@author', tag: '@author',
}, },
{ {
formattedKey: __('Label'),
key: 'label', key: 'label',
type: 'array', type: 'array',
param: 'name[]', param: 'name[]',
...@@ -21,6 +24,7 @@ const tokenKeys = [ ...@@ -21,6 +24,7 @@ const tokenKeys = [
const alternativeTokenKeys = [ const alternativeTokenKeys = [
{ {
formattedKey: __('Label'),
key: 'label', key: 'label',
type: 'string', type: 'string',
param: 'name', param: 'name',
...@@ -33,6 +37,13 @@ const conditions = [ ...@@ -33,6 +37,13 @@ const conditions = [
url: 'label_name[]=No+Label', url: 'label_name[]=No+Label',
tokenKey: 'label', tokenKey: 'label',
value: 'none', value: 'none',
operator: '=',
},
{
url: 'not[label_name][]=No+Label',
tokenKey: 'label',
value: 'none',
operator: '!=',
}, },
]; ];
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
import { __ } from '~/locale'; import { __ } from '~/locale';
const weightTokenKey = { const weightTokenKey = {
formattedKey: __('Weight'),
key: 'weight', key: 'weight',
type: 'string', type: 'string',
param: '', param: '',
...@@ -18,11 +19,25 @@ const weightTokenKey = { ...@@ -18,11 +19,25 @@ const weightTokenKey = {
const weightConditions = [ const weightConditions = [
{ {
url: 'weight=None', url: 'weight=None',
operator: '=',
tokenKey: 'weight', tokenKey: 'weight',
value: __('None'), value: __('None'),
}, },
{ {
url: 'weight=Any', url: 'weight=Any',
operator: '=',
tokenKey: 'weight',
value: __('Any'),
},
{
url: 'not[weight]=None',
operator: '!=',
tokenKey: 'weight',
value: __('None'),
},
{
url: 'not[weight]=Any',
operator: '!=',
tokenKey: 'weight', tokenKey: 'weight',
value: __('Any'), value: __('Any'),
}, },
......
...@@ -42,23 +42,22 @@ ...@@ -42,23 +42,22 @@
%li.input-token %li.input-token
%input.form-control.filtered-search{ epic_endpoint_query_params(search_filter_input_options(type)) } %input.form-control.filtered-search{ epic_endpoint_query_params(search_filter_input_options(type)) }
#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 } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
%button.btn.btn-link{ type: 'button' }
= sprite_icon('search')
%span
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{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
%button.btn.btn-link{ type: 'button' } %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
%use{ 'xlink:href': "#{'{{icon}}'}" } %use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint %span.js-filter-hint
{{hint}} {{formattedKey}}
%span.js-filter-tag.dropdown-light-content #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
{{tag}} %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
- if current_user - if current_user
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
......
...@@ -25,6 +25,6 @@ describe 'Issue Boards add issue modal', :js do ...@@ -25,6 +25,6 @@ describe 'Issue Boards add issue modal', :js do
wait_for_requests wait_for_requests
find('.add-issues-modal .filtered-search').click find('.add-issues-modal .filtered-search').click
expect(page.find('.filter-dropdown')).to have_content 'weight' expect(page.find('.filter-dropdown')).to have_content 'Weight'
end end
end end
...@@ -132,8 +132,8 @@ describe 'Scoped issue boards', :js do ...@@ -132,8 +132,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click filtered_search.click
page.within('#js-dropdown-hint') do page.within('#js-dropdown-hint') do
expect(page).to have_content('label') expect(page).to have_content('Label')
expect(page).not_to have_content('assignee') expect(page).not_to have_content('Assignee')
end end
end end
...@@ -164,8 +164,8 @@ describe 'Scoped issue boards', :js do ...@@ -164,8 +164,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click filtered_search.click
page.within('#js-dropdown-hint') do page.within('#js-dropdown-hint') do
expect(page).to have_content('label') expect(page).to have_content('Label')
expect(page).not_to have_content('weight') expect(page).not_to have_content('Weight')
end end
end end
...@@ -244,8 +244,8 @@ describe 'Scoped issue boards', :js do ...@@ -244,8 +244,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click filtered_search.click
page.within('#js-dropdown-hint') do page.within('#js-dropdown-hint') do
expect(page).to have_content('label') expect(page).to have_content('Label')
expect(page).not_to have_content('milestone') expect(page).not_to have_content('Milestone')
end end
end end
end end
...@@ -293,7 +293,7 @@ describe 'Scoped issue boards', :js do ...@@ -293,7 +293,7 @@ describe 'Scoped issue boards', :js do
update_board_label(label_title) update_board_label(label_title)
input_filtered_search("label:~#{label_2_title}") input_filtered_search("label=~#{label_2_title}")
expect(page).to have_selector('.board-card', count: 0) expect(page).to have_selector('.board-card', count: 0)
end end
...@@ -338,8 +338,8 @@ describe 'Scoped issue boards', :js do ...@@ -338,8 +338,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click filtered_search.click
page.within('#js-dropdown-hint') do page.within('#js-dropdown-hint') do
expect(page).to have_content('label') expect(page).to have_content('Label')
expect(page).not_to have_content('assignee') expect(page).not_to have_content('Assignee')
end end
end end
end end
...@@ -365,8 +365,8 @@ describe 'Scoped issue boards', :js do ...@@ -365,8 +365,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click filtered_search.click
page.within('#js-dropdown-hint') do page.within('#js-dropdown-hint') do
expect(page).to have_content('label') expect(page).to have_content('Label')
expect(page).not_to have_content('weight') expect(page).not_to have_content('Weight')
end end
end end
end end
......
...@@ -24,7 +24,7 @@ describe 'epics list', :js do ...@@ -24,7 +24,7 @@ describe 'epics list', :js do
context 'editing author token' do context 'editing author token' do
before do before do
input_filtered_search('author:@root', submit: false) input_filtered_search('author=@root', submit: false)
first('.tokens-container .filtered-search-token').click first('.tokens-container .filtered-search-token').click
end end
...@@ -52,7 +52,7 @@ describe 'epics list', :js do ...@@ -52,7 +52,7 @@ describe 'epics list', :js do
context 'editing label token' do context 'editing label token' do
before do before do
input_filtered_search("label:~#{label.title}", submit: false) input_filtered_search("label=~#{label.title}", submit: false)
first('.tokens-container .filtered-search-token').click first('.tokens-container .filtered-search-token').click
end end
......
...@@ -21,7 +21,7 @@ describe 'Dropdown weight', :js do ...@@ -21,7 +21,7 @@ describe 'Dropdown weight', :js do
describe 'behavior' do describe 'behavior' do
it 'loads all the weights when opened' do it 'loads all the weights when opened' do
input_filtered_search('weight:', submit: false, extra_space: false) input_filtered_search('weight=', submit: false, extra_space: false)
expect_filtered_search_dropdown_results(filter_dropdown, 21) expect_filtered_search_dropdown_results(filter_dropdown, 21)
end end
......
...@@ -40,7 +40,7 @@ describe 'Filter issues weight', :js do ...@@ -40,7 +40,7 @@ describe 'Filter issues weight', :js do
describe 'only weight' do describe 'only weight' do
it 'filter issues by searched weight' do it 'filter issues by searched weight' do
input_filtered_search('weight:1') input_filtered_search('weight=1')
expect_issues_list_count(1) expect_issues_list_count(1)
end end
...@@ -48,7 +48,7 @@ describe 'Filter issues weight', :js do ...@@ -48,7 +48,7 @@ describe 'Filter issues weight', :js do
describe 'weight with other filters' do describe 'weight with other filters' do
it 'filters issues by searched weight and text' do it 'filters issues by searched weight and text' do
search = "weight:2 bug" search = "weight=2 bug"
input_filtered_search(search) input_filtered_search(search)
expect_issues_list_count(1) expect_issues_list_count(1)
...@@ -56,7 +56,7 @@ describe 'Filter issues weight', :js do ...@@ -56,7 +56,7 @@ describe 'Filter issues weight', :js do
end end
it 'filters issues by searched weight, author and text' do it 'filters issues by searched weight, author and text' do
search = "weight:2 author:@root bug" search = "weight=2 author=@root bug"
input_filtered_search(search) input_filtered_search(search)
expect_issues_list_count(1) expect_issues_list_count(1)
...@@ -64,7 +64,7 @@ describe 'Filter issues weight', :js do ...@@ -64,7 +64,7 @@ describe 'Filter issues weight', :js do
end end
it 'filters issues by searched weight, author, assignee and text' do it 'filters issues by searched weight, author, assignee and text' do
search = "weight:2 author:@root assignee:@root bug" search = "weight=2 author=@root assignee=@root bug"
input_filtered_search(search) input_filtered_search(search)
expect_issues_list_count(1) expect_issues_list_count(1)
...@@ -72,7 +72,7 @@ describe 'Filter issues weight', :js do ...@@ -72,7 +72,7 @@ describe 'Filter issues weight', :js do
end end
it 'filters issues by searched weight, author, assignee, label and text' do it 'filters issues by searched weight, author, assignee, label and text' do
search = "weight:2 author:@root assignee:@root label:~urgent bug" search = "weight=2 author=@root assignee=@root label=~urgent bug"
input_filtered_search(search) input_filtered_search(search)
expect_issues_list_count(1) expect_issues_list_count(1)
...@@ -80,7 +80,7 @@ describe 'Filter issues weight', :js do ...@@ -80,7 +80,7 @@ describe 'Filter issues weight', :js do
end end
it 'filters issues by searched weight, milestone and text' do it 'filters issues by searched weight, milestone and text' do
search = "weight:2 milestone:%version1 bug" search = "weight=2 milestone=%version1 bug"
input_filtered_search(search) input_filtered_search(search)
expect_issues_list_count(1) expect_issues_list_count(1)
......
...@@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by approvers', :js do ...@@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by approver:none' do context 'filtering by approver:none' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search('approver:none') input_filtered_search('approver=none')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
...@@ -45,7 +45,7 @@ describe 'Merge Requests > User filters by approvers', :js do ...@@ -45,7 +45,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by approver:any' do context 'filtering by approver:any' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search('approver:any') input_filtered_search('approver=any')
expect(page).to have_issuable_counts(open: 3, closed: 0, all: 3) expect(page).to have_issuable_counts(open: 3, closed: 0, all: 3)
...@@ -58,7 +58,7 @@ describe 'Merge Requests > User filters by approvers', :js do ...@@ -58,7 +58,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by approver:@username' do context 'filtering by approver:@username' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search("approver:@#{first_user.username}") input_filtered_search("approver=@#{first_user.username}")
expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
...@@ -71,7 +71,7 @@ describe 'Merge Requests > User filters by approvers', :js do ...@@ -71,7 +71,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by multiple approvers' do context 'filtering by multiple approvers' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search("approver:@#{first_user.username} approver:@#{user.username}") 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_issuable_counts(open: 1, closed: 0, all: 1)
...@@ -84,7 +84,7 @@ describe 'Merge Requests > User filters by approvers', :js do ...@@ -84,7 +84,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by an approver from a group' do context 'filtering by an approver from a group' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search("approver:@#{group_user.username}") input_filtered_search("approver=@#{group_user.username}")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
......
...@@ -2,6 +2,7 @@ import IssuableFilteredSearchTokenKeys from 'ee/filtered_search/issuable_filtere ...@@ -2,6 +2,7 @@ import IssuableFilteredSearchTokenKeys from 'ee/filtered_search/issuable_filtere
describe('Issues Filtered Search Token Keys (EE)', () => { describe('Issues Filtered Search Token Keys (EE)', () => {
const weightTokenKey = { const weightTokenKey = {
formattedKey: 'Weight',
key: 'weight', key: 'weight',
type: 'string', type: 'string',
param: '', param: '',
...@@ -41,7 +42,7 @@ describe('Issues Filtered Search Token Keys (EE)', () => { ...@@ -41,7 +42,7 @@ describe('Issues Filtered Search Token Keys (EE)', () => {
it('should return weightConditions as part of conditions', () => { it('should return weightConditions as part of conditions', () => {
const weightConditions = conditions.filter(c => c.tokenKey === 'weight'); const weightConditions = conditions.filter(c => c.tokenKey === 'weight');
expect(weightConditions.length).toBe(2); expect(weightConditions.length).toBe(4);
}); });
}); });
...@@ -91,6 +92,7 @@ describe('Issues Filtered Search Token Keys (EE)', () => { ...@@ -91,6 +92,7 @@ describe('Issues Filtered Search Token Keys (EE)', () => {
const weightConditions = conditions.filter(c => c.tokenKey === 'weight'); const weightConditions = conditions.filter(c => c.tokenKey === 'weight');
const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue( const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(
weightConditions[0].tokenKey, weightConditions[0].tokenKey,
weightConditions[0].operator,
weightConditions[0].value, weightConditions[0].value,
); );
......
...@@ -2032,6 +2032,9 @@ msgstr "" ...@@ -2032,6 +2032,9 @@ msgstr ""
msgid "Approved the current merge request." msgid "Approved the current merge request."
msgstr "" msgstr ""
msgid "Approver"
msgstr ""
msgid "Apr" msgid "Apr"
msgstr "" msgstr ""
...@@ -9942,6 +9945,12 @@ msgstr "" ...@@ -9942,6 +9945,12 @@ msgstr ""
msgid "Invocations" msgid "Invocations"
msgstr "" msgstr ""
msgid "Is"
msgstr ""
msgid "Is not"
msgstr ""
msgid "Is using license seat:" msgid "Is using license seat:"
msgstr "" msgstr ""
...@@ -11672,6 +11681,9 @@ msgstr "" ...@@ -11672,6 +11681,9 @@ msgstr ""
msgid "Multiple uploaders found: %{uploader_types}" msgid "Multiple uploaders found: %{uploader_types}"
msgstr "" msgstr ""
msgid "My-Reaction"
msgstr ""
msgid "Name" msgid "Name"
msgstr "" msgstr ""
...@@ -13299,9 +13311,6 @@ msgstr "" ...@@ -13299,9 +13311,6 @@ msgstr ""
msgid "Press %{key}-C to copy" msgid "Press %{key}-C to copy"
msgstr "" msgstr ""
msgid "Press Enter or click to search"
msgstr ""
msgid "Prevent adding new members to project membership within this group" msgid "Prevent adding new members to project membership within this group"
msgstr "" msgstr ""
...@@ -15706,6 +15715,9 @@ msgstr "" ...@@ -15706,6 +15715,9 @@ msgstr ""
msgid "Search for projects, issues, etc." msgid "Search for projects, issues, etc."
msgstr "" msgstr ""
msgid "Search for this text"
msgstr ""
msgid "Search forks" msgid "Search forks"
msgstr "" msgstr ""
...@@ -17713,6 +17725,9 @@ msgstr "" ...@@ -17713,6 +17725,9 @@ msgstr ""
msgid "Target branch" msgid "Target branch"
msgstr "" msgstr ""
msgid "Target-Branch"
msgstr ""
msgid "Team" msgid "Team"
msgstr "" msgstr ""
...@@ -20279,6 +20294,9 @@ msgstr "" ...@@ -20279,6 +20294,9 @@ msgstr ""
msgid "Vulnerability|Severity" msgid "Vulnerability|Severity"
msgstr "" msgstr ""
msgid "WIP"
msgstr ""
msgid "Wait for the file to load to copy its contents" msgid "Wait for the file to load to copy its contents"
msgstr "" msgstr ""
......
...@@ -57,7 +57,7 @@ describe "Admin Runners" do ...@@ -57,7 +57,7 @@ describe "Admin Runners" do
expect(page).to have_content 'runner-active' expect(page).to have_content 'runner-active'
expect(page).to have_content 'runner-paused' expect(page).to have_content 'runner-paused'
input_filtered_search_keys('status:active') input_filtered_search_keys('status=active')
expect(page).to have_content 'runner-active' expect(page).to have_content 'runner-active'
expect(page).not_to have_content 'runner-paused' expect(page).not_to have_content 'runner-paused'
end end
...@@ -68,7 +68,7 @@ describe "Admin Runners" do ...@@ -68,7 +68,7 @@ describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
input_filtered_search_keys('status:offline') input_filtered_search_keys('status=offline')
expect(page).not_to have_content 'runner-active' expect(page).not_to have_content 'runner-active'
expect(page).not_to have_content 'runner-paused' expect(page).not_to have_content 'runner-paused'
...@@ -83,12 +83,12 @@ describe "Admin Runners" do ...@@ -83,12 +83,12 @@ describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
input_filtered_search_keys('status:active') input_filtered_search_keys('status=active')
expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-a-1'
expect(page).to have_content 'runner-b-1' expect(page).to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2' expect(page).not_to have_content 'runner-a-2'
input_filtered_search_keys('status:active runner-a') input_filtered_search_keys('status=active runner-a')
expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-a-1'
expect(page).not_to have_content 'runner-b-1' expect(page).not_to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2' expect(page).not_to have_content 'runner-a-2'
...@@ -105,7 +105,7 @@ describe "Admin Runners" do ...@@ -105,7 +105,7 @@ describe "Admin Runners" do
expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-project'
expect(page).to have_content 'runner-group' expect(page).to have_content 'runner-group'
input_filtered_search_keys('type:project_type') input_filtered_search_keys('type=project_type')
expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-project'
expect(page).not_to have_content 'runner-group' expect(page).not_to have_content 'runner-group'
end end
...@@ -116,7 +116,7 @@ describe "Admin Runners" do ...@@ -116,7 +116,7 @@ describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
input_filtered_search_keys('type:instance_type') input_filtered_search_keys('type=instance_type')
expect(page).not_to have_content 'runner-project' expect(page).not_to have_content 'runner-project'
expect(page).not_to have_content 'runner-group' expect(page).not_to have_content 'runner-group'
...@@ -131,12 +131,12 @@ describe "Admin Runners" do ...@@ -131,12 +131,12 @@ describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
input_filtered_search_keys('type:project_type') input_filtered_search_keys('type=project_type')
expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-a-1'
expect(page).to have_content 'runner-b-1' expect(page).to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2' expect(page).not_to have_content 'runner-a-2'
input_filtered_search_keys('type:project_type runner-a') input_filtered_search_keys('type=project_type runner-a')
expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-a-1'
expect(page).not_to have_content 'runner-b-1' expect(page).not_to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2' expect(page).not_to have_content 'runner-a-2'
...@@ -153,7 +153,7 @@ describe "Admin Runners" do ...@@ -153,7 +153,7 @@ describe "Admin Runners" do
expect(page).to have_content 'runner-blue' expect(page).to have_content 'runner-blue'
expect(page).to have_content 'runner-red' expect(page).to have_content 'runner-red'
input_filtered_search_keys('tag:blue') input_filtered_search_keys('tag=blue')
expect(page).to have_content 'runner-blue' expect(page).to have_content 'runner-blue'
expect(page).not_to have_content 'runner-red' expect(page).not_to have_content 'runner-red'
...@@ -165,7 +165,7 @@ describe "Admin Runners" do ...@@ -165,7 +165,7 @@ describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
input_filtered_search_keys('tag:red') input_filtered_search_keys('tag=red')
expect(page).not_to have_content 'runner-blue' expect(page).not_to have_content 'runner-blue'
expect(page).not_to have_content 'runner-blue' expect(page).not_to have_content 'runner-blue'
...@@ -179,13 +179,13 @@ describe "Admin Runners" do ...@@ -179,13 +179,13 @@ describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
input_filtered_search_keys('tag:blue') input_filtered_search_keys('tag=blue')
expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-a-1'
expect(page).to have_content 'runner-b-1' expect(page).to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2' expect(page).not_to have_content 'runner-a-2'
input_filtered_search_keys('tag:blue runner-a') input_filtered_search_keys('tag=blue runner-a')
expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-a-1'
expect(page).not_to have_content 'runner-b-1' expect(page).not_to have_content 'runner-b-1'
......
...@@ -628,7 +628,7 @@ describe 'Issue Boards', :js do ...@@ -628,7 +628,7 @@ describe 'Issue Boards', :js do
end end
def set_filter(type, text) def set_filter(type, text)
find('.filtered-search').native.send_keys("#{type}:#{text}") find('.filtered-search').native.send_keys("#{type}=#{text}")
end end
def submit_filter def submit_filter
......
...@@ -211,7 +211,7 @@ describe 'Issue Boards add issue modal filtering', :js do ...@@ -211,7 +211,7 @@ describe 'Issue Boards add issue modal filtering', :js do
end end
def set_filter(type, text = '') def set_filter(type, text = '')
find('.add-issues-modal .filtered-search').native.send_keys("#{type}:#{text}") find('.add-issues-modal .filtered-search').native.send_keys("#{type}=#{text}")
end end
def submit_filter def submit_filter
......
...@@ -28,14 +28,14 @@ describe 'Dashboard Issues filtering', :js do ...@@ -28,14 +28,14 @@ describe 'Dashboard Issues filtering', :js do
context 'filtering by milestone' do context 'filtering by milestone' do
it 'shows all issues with no milestone' do it 'shows all issues with no milestone' do
input_filtered_search("milestone:none") input_filtered_search("milestone=none")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_selector('.issue', count: 1) expect(page).to have_selector('.issue', count: 1)
end end
it 'shows all issues with the selected milestone' do it 'shows all issues with the selected milestone' do
input_filtered_search("milestone:%\"#{milestone.title}\"") input_filtered_search("milestone=%\"#{milestone.title}\"")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_selector('.issue', count: 1) expect(page).to have_selector('.issue', count: 1)
...@@ -63,7 +63,7 @@ describe 'Dashboard Issues filtering', :js do ...@@ -63,7 +63,7 @@ describe 'Dashboard Issues filtering', :js do
let!(:label_link) { create(:label_link, label: label, target: issue) } let!(:label_link) { create(:label_link, label: label, target: issue) }
it 'shows all issues with the selected label' do it 'shows all issues with the selected label' do
input_filtered_search("label:~#{label.title}") input_filtered_search("label=~#{label.title}")
page.within 'ul.content-list' do page.within 'ul.content-list' do
expect(page).to have_content issue.title expect(page).to have_content issue.title
......
...@@ -30,7 +30,7 @@ RSpec.describe 'Dashboard Issues' do ...@@ -30,7 +30,7 @@ RSpec.describe 'Dashboard Issues' do
it 'shows issues when current user is author', :js do it 'shows issues when current user is author', :js do
reset_filters reset_filters
input_filtered_search("author:#{current_user.to_reference}") input_filtered_search("author=#{current_user.to_reference}")
expect(page).to have_content(authored_issue.title) expect(page).to have_content(authored_issue.title)
expect(page).to have_content(authored_issue_on_public_project.title) expect(page).to have_content(authored_issue_on_public_project.title)
......
...@@ -107,7 +107,7 @@ describe 'Dashboard Merge Requests' do ...@@ -107,7 +107,7 @@ describe 'Dashboard Merge Requests' do
it 'shows authored merge requests', :js do it 'shows authored merge requests', :js do
reset_filters reset_filters
input_filtered_search("author:#{current_user.to_reference}") input_filtered_search("author=#{current_user.to_reference}")
expect(page).to have_content(authored_merge_request.title) expect(page).to have_content(authored_merge_request.title)
expect(page).to have_content(authored_merge_request_from_fork.title) expect(page).to have_content(authored_merge_request_from_fork.title)
...@@ -120,7 +120,7 @@ describe 'Dashboard Merge Requests' do ...@@ -120,7 +120,7 @@ describe 'Dashboard Merge Requests' do
it 'shows labeled merge requests', :js do it 'shows labeled merge requests', :js do
reset_filters reset_filters
input_filtered_search("label:#{label.name}") input_filtered_search("label=#{label.name}")
expect(page).to have_content(labeled_merge_request.title) expect(page).to have_content(labeled_merge_request.title)
......
...@@ -48,7 +48,7 @@ describe 'Group issues page' do ...@@ -48,7 +48,7 @@ describe 'Group issues page' do
let(:user2) { user_outside_group } let(:user2) { user_outside_group }
it 'filters by only group users' do it 'filters by only group users' do
filtered_search.set('assignee:') filtered_search.set('assignee=')
expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name) expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name)
expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name) expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name)
......
...@@ -52,7 +52,7 @@ describe 'Group merge requests page' do ...@@ -52,7 +52,7 @@ describe 'Group merge requests page' do
let(:user2) { user_outside_group } let(:user2) { user_outside_group }
it 'filters by assignee only group users' do it 'filters by assignee only group users' do
filtered_search.set('assignee:') filtered_search.set('assignee=')
expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name) expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name)
expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name) expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name)
......
...@@ -20,13 +20,13 @@ describe 'Dropdown assignee', :js do ...@@ -20,13 +20,13 @@ describe 'Dropdown assignee', :js do
describe 'behavior' do describe 'behavior' do
it 'loads all the assignees when opened' do it 'loads all the assignees when opened' do
input_filtered_search('assignee:', submit: false, extra_space: false) input_filtered_search('assignee=', submit: false, extra_space: false)
expect_filtered_search_dropdown_results(filter_dropdown, 2) expect_filtered_search_dropdown_results(filter_dropdown, 2)
end end
it 'shows current user at top of dropdown' do it 'shows current user at top of dropdown' do
input_filtered_search('assignee:', submit: false, extra_space: false) input_filtered_search('assignee=', submit: false, extra_space: false)
expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name) expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name)
end end
...@@ -35,7 +35,7 @@ describe 'Dropdown assignee', :js do ...@@ -35,7 +35,7 @@ describe 'Dropdown assignee', :js do
describe 'selecting from dropdown without Ajax call' do describe 'selecting from dropdown without Ajax call' do
before do before do
Gitlab::Testing::RequestBlockerMiddleware.block_requests! Gitlab::Testing::RequestBlockerMiddleware.block_requests!
input_filtered_search('assignee:', submit: false, extra_space: false) input_filtered_search('assignee=', submit: false, extra_space: false)
end end
after do after do
......
...@@ -20,13 +20,13 @@ describe 'Dropdown author', :js do ...@@ -20,13 +20,13 @@ describe 'Dropdown author', :js do
describe 'behavior' do describe 'behavior' do
it 'loads all the authors when opened' do it 'loads all the authors when opened' do
input_filtered_search('author:', submit: false, extra_space: false) input_filtered_search('author=', submit: false, extra_space: false)
expect_filtered_search_dropdown_results(filter_dropdown, 2) expect_filtered_search_dropdown_results(filter_dropdown, 2)
end end
it 'shows current user at top of dropdown' do it 'shows current user at top of dropdown' do
input_filtered_search('author:', submit: false, extra_space: false) input_filtered_search('author=', submit: false, extra_space: false)
expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name) expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name)
end end
...@@ -35,7 +35,7 @@ describe 'Dropdown author', :js do ...@@ -35,7 +35,7 @@ describe 'Dropdown author', :js do
describe 'selecting from dropdown without Ajax call' do describe 'selecting from dropdown without Ajax call' do
before do before do
Gitlab::Testing::RequestBlockerMiddleware.block_requests! Gitlab::Testing::RequestBlockerMiddleware.block_requests!
input_filtered_search('author:', submit: false, extra_space: false) input_filtered_search('author=', submit: false, extra_space: false)
end end
after do after do
......
...@@ -27,14 +27,14 @@ describe 'Dropdown base', :js do ...@@ -27,14 +27,14 @@ describe 'Dropdown base', :js do
it 'shows loading indicator when opened' do it 'shows loading indicator when opened' do
slow_requests do slow_requests do
# We aren't using `input_filtered_search` because we want to see the loading indicator # We aren't using `input_filtered_search` because we want to see the loading indicator
filtered_search.set('assignee:') filtered_search.set('assignee=')
expect(page).to have_css("#{js_dropdown_assignee} .filter-dropdown-loading", visible: true) expect(page).to have_css("#{js_dropdown_assignee} .filter-dropdown-loading", visible: true)
end end
end end
it 'hides loading indicator when loaded' do it 'hides loading indicator when loaded' do
input_filtered_search('assignee:', submit: false, extra_space: false) input_filtered_search('assignee=', submit: false, extra_space: false)
expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading') expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading')
end end
...@@ -42,7 +42,7 @@ describe 'Dropdown base', :js do ...@@ -42,7 +42,7 @@ describe 'Dropdown base', :js do
describe 'caching requests' do describe 'caching requests' do
it 'caches requests after the first load' do it 'caches requests after the first load' do
input_filtered_search('assignee:', submit: false, extra_space: false) input_filtered_search('assignee=', submit: false, extra_space: false)
initial_size = dropdown_assignee_size initial_size = dropdown_assignee_size
expect(initial_size).to be > 0 expect(initial_size).to be > 0
...@@ -50,7 +50,7 @@ describe 'Dropdown base', :js do ...@@ -50,7 +50,7 @@ describe 'Dropdown base', :js do
new_user = create(:user) new_user = create(:user)
project.add_maintainer(new_user) project.add_maintainer(new_user)
find('.filtered-search-box .clear-search').click find('.filtered-search-box .clear-search').click
input_filtered_search('assignee:', submit: false, extra_space: false) input_filtered_search('assignee=', submit: false, extra_space: false)
expect(dropdown_assignee_size).to eq(initial_size) expect(dropdown_assignee_size).to eq(initial_size)
end end
......
...@@ -26,8 +26,8 @@ describe 'Dropdown emoji', :js do ...@@ -26,8 +26,8 @@ describe 'Dropdown emoji', :js do
end end
describe 'behavior' do describe 'behavior' do
it 'does not open when the search bar has my-reaction:' do it 'does not open when the search bar has my-reaction=' do
filtered_search.set('my-reaction:') filtered_search.set('my-reaction=')
expect(page).not_to have_css(js_dropdown_emoji) expect(page).not_to have_css(js_dropdown_emoji)
end end
...@@ -42,20 +42,20 @@ describe 'Dropdown emoji', :js do ...@@ -42,20 +42,20 @@ describe 'Dropdown emoji', :js do
end end
describe 'behavior' do describe 'behavior' do
it 'opens when the search bar has my-reaction:' do it 'opens when the search bar has my-reaction=' do
filtered_search.set('my-reaction:') filtered_search.set('my-reaction=')
expect(page).to have_css(js_dropdown_emoji, visible: true) expect(page).to have_css(js_dropdown_emoji, visible: true)
end end
it 'loads all the emojis when opened' do it 'loads all the emojis when opened' do
input_filtered_search('my-reaction:', submit: false, extra_space: false) input_filtered_search('my-reaction=', submit: false, extra_space: false)
expect_filtered_search_dropdown_results(filter_dropdown, 3) expect_filtered_search_dropdown_results(filter_dropdown, 3)
end end
it 'shows the most populated emoji at top of dropdown' do it 'shows the most populated emoji at top of dropdown' do
input_filtered_search('my-reaction:', submit: false, extra_space: false) input_filtered_search('my-reaction=', submit: false, extra_space: false)
expect(first("#{js_dropdown_emoji} .filter-dropdown li")).to have_content(award_emoji_star.name) expect(first("#{js_dropdown_emoji} .filter-dropdown li")).to have_content(award_emoji_star.name)
end end
......
...@@ -9,11 +9,16 @@ describe 'Dropdown hint', :js do ...@@ -9,11 +9,16 @@ describe 'Dropdown hint', :js do
let!(:user) { create(:user) } let!(:user) { create(:user) }
let(:filtered_search) { find('.filtered-search') } let(:filtered_search) { find('.filtered-search') }
let(:js_dropdown_hint) { '#js-dropdown-hint' } let(:js_dropdown_hint) { '#js-dropdown-hint' }
let(:js_dropdown_operator) { '#js-dropdown-operator' }
def click_hint(text) def click_hint(text)
find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click
end end
def click_operator(op)
find("#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value='#{op}']").click
end
before do before do
project.add_maintainer(user) project.add_maintainer(user)
create(:issue, project: project) create(:issue, project: project)
...@@ -27,7 +32,7 @@ describe 'Dropdown hint', :js do ...@@ -27,7 +32,7 @@ describe 'Dropdown hint', :js do
it 'does not exist my-reaction dropdown item' do it 'does not exist my-reaction dropdown item' do
expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).not_to have_content('my-reaction') expect(page).not_to have_content('My-reaction')
end end
end end
...@@ -54,15 +59,6 @@ describe 'Dropdown hint', :js do ...@@ -54,15 +59,6 @@ describe 'Dropdown hint', :js do
end end
describe 'filtering' do describe 'filtering' do
it 'does not filter `Press Enter or click to search`' do
filtered_search.set('randomtext')
hint_dropdown = find(js_dropdown_hint)
expect(hint_dropdown).to have_content('Press Enter or click to search')
expect(hint_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 0)
end
it 'filters with text' do it 'filters with text' do
filtered_search.set('a') filtered_search.set('a')
...@@ -76,21 +72,27 @@ describe 'Dropdown hint', :js do ...@@ -76,21 +72,27 @@ describe 'Dropdown hint', :js do
end end
it 'opens the token dropdown when you click on it' do it 'opens the token dropdown when you click on it' do
click_hint('author') click_hint('Author')
expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).to have_css(js_dropdown_operator, visible: true)
click_operator('=')
expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).to have_css(js_dropdown_operator, visible: false)
expect(page).to have_css('#js-dropdown-author', visible: true) expect(page).to have_css('#js-dropdown-author', visible: true)
expect_tokens([{ name: 'Author' }]) expect_tokens([{ name: 'Author', operator: '=' }])
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
end end
describe 'reselecting from dropdown' do describe 'reselecting from dropdown' do
it 'reuses existing token text' do it 'reuses existing token text' do
filtered_search.send_keys('author:') filtered_search.send_keys('author')
filtered_search.send_keys(:backspace) filtered_search.send_keys(:backspace)
filtered_search.send_keys(:backspace) filtered_search.send_keys(:backspace)
click_hint('author') click_hint('Author')
expect_tokens([{ name: 'Author' }]) expect_tokens([{ name: 'Author' }])
expect_filtered_search_input_empty expect_filtered_search_input_empty
......
...@@ -21,7 +21,7 @@ describe 'Dropdown label', :js do ...@@ -21,7 +21,7 @@ describe 'Dropdown label', :js do
describe 'behavior' do describe 'behavior' do
it 'loads all the labels when opened' do it 'loads all the labels when opened' do
create(:label, project: project, title: 'bug-label') create(:label, project: project, title: 'bug-label')
filtered_search.set('label:') filtered_search.set('label=')
expect_filtered_search_dropdown_results(filter_dropdown, 1) expect_filtered_search_dropdown_results(filter_dropdown, 1)
end end
......
...@@ -23,7 +23,7 @@ describe 'Dropdown milestone', :js do ...@@ -23,7 +23,7 @@ describe 'Dropdown milestone', :js do
describe 'behavior' do describe 'behavior' do
before do before do
filtered_search.set('milestone:') filtered_search.set('milestone=')
end end
it 'loads all the milestones when opened' do it 'loads all the milestones when opened' do
......
...@@ -23,7 +23,7 @@ describe 'Dropdown release', :js do ...@@ -23,7 +23,7 @@ describe 'Dropdown release', :js do
describe 'behavior' do describe 'behavior' do
before do before do
filtered_search.set('release:') filtered_search.set('release=')
end end
it 'loads all the releases when opened' do it 'loads all the releases when opened' do
......
...@@ -67,7 +67,7 @@ describe 'Filter issues', :js do ...@@ -67,7 +67,7 @@ describe 'Filter issues', :js do
it 'filters by all available tokens' do it 'filters by all available tokens' do
search_term = 'issue' search_term = 'issue'
input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}") input_filtered_search("assignee=@#{user.username} author=@#{user.username} label=~#{caps_sensitive_label.title} milestone=%#{milestone.title} #{search_term}")
wait_for_requests wait_for_requests
...@@ -84,7 +84,7 @@ describe 'Filter issues', :js do ...@@ -84,7 +84,7 @@ describe 'Filter issues', :js do
describe 'filter issues by author' do describe 'filter issues by author' do
context 'only author' do context 'only author' do
it 'filters issues by searched author' do it 'filters issues by searched author' do
input_filtered_search("author:@#{user.username}") input_filtered_search("author=@#{user.username}")
wait_for_requests wait_for_requests
...@@ -98,7 +98,7 @@ describe 'Filter issues', :js do ...@@ -98,7 +98,7 @@ describe 'Filter issues', :js do
describe 'filter issues by assignee' do describe 'filter issues by assignee' do
context 'only assignee' do context 'only assignee' do
it 'filters issues by searched assignee' do it 'filters issues by searched assignee' do
input_filtered_search("assignee:@#{user.username}") input_filtered_search("assignee=@#{user.username}")
wait_for_requests wait_for_requests
...@@ -108,7 +108,7 @@ describe 'Filter issues', :js do ...@@ -108,7 +108,7 @@ describe 'Filter issues', :js do
end end
it 'filters issues by no assignee' do it 'filters issues by no assignee' do
input_filtered_search('assignee:none') input_filtered_search('assignee=none')
expect_tokens([assignee_token('None')]) expect_tokens([assignee_token('None')])
expect_issues_list_count(3) expect_issues_list_count(3)
...@@ -122,7 +122,7 @@ describe 'Filter issues', :js do ...@@ -122,7 +122,7 @@ describe 'Filter issues', :js do
it 'filters issues by multiple assignees' do it 'filters issues by multiple assignees' do
create(:issue, project: project, author: user, assignees: [user2, user]) create(:issue, project: project, author: user, assignees: [user2, user])
input_filtered_search("assignee:@#{user.username} assignee:@#{user2.username}") input_filtered_search("assignee=@#{user.username} assignee=@#{user2.username}")
expect_tokens([ expect_tokens([
assignee_token(user.name), assignee_token(user.name),
...@@ -138,15 +138,31 @@ describe 'Filter issues', :js do ...@@ -138,15 +138,31 @@ describe 'Filter issues', :js do
describe 'filter issues by label' do describe 'filter issues by label' do
context 'only label' do context 'only label' do
it 'filters issues by searched label' do it 'filters issues by searched label' do
input_filtered_search("label:~#{bug_label.title}") input_filtered_search("label=~#{bug_label.title}")
expect_tokens([label_token(bug_label.title)]) expect_tokens([label_token(bug_label.title)])
expect_issues_list_count(2) expect_issues_list_count(2)
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'filters issues not containing searched label' do
input_filtered_search("label!=~#{bug_label.title}")
expect_tokens([label_token(bug_label.title)])
expect_issues_list_count(6)
expect_filtered_search_input_empty
end
it 'filters issues by no label' do
input_filtered_search('label=none')
expect_tokens([label_token('None', false)])
expect_issues_list_count(4)
expect_filtered_search_input_empty
end
it 'filters issues by no label' do it 'filters issues by no label' do
input_filtered_search('label:none') input_filtered_search('label!=none')
expect_tokens([label_token('None', false)]) expect_tokens([label_token('None', false)])
expect_issues_list_count(4) expect_issues_list_count(4)
...@@ -154,7 +170,18 @@ describe 'Filter issues', :js do ...@@ -154,7 +170,18 @@ describe 'Filter issues', :js do
end end
it 'filters issues by multiple labels' do it 'filters issues by multiple labels' do
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title}") input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title}")
expect_tokens([
label_token(bug_label.title),
label_token(caps_sensitive_label.title)
])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
it 'filters issues by multiple labels with not operator' do
input_filtered_search("label!=~#{bug_label.title} label=~#{caps_sensitive_label.title}")
expect_tokens([ expect_tokens([
label_token(bug_label.title), label_token(bug_label.title),
...@@ -169,22 +196,42 @@ describe 'Filter issues', :js do ...@@ -169,22 +196,42 @@ describe 'Filter issues', :js do
special_issue = create(:issue, title: "Issue with special character label", project: project) special_issue = create(:issue, title: "Issue with special character label", project: project)
special_issue.labels << special_label special_issue.labels << special_label
input_filtered_search("label:~#{special_label.title}") input_filtered_search("label=~#{special_label.title}")
expect_tokens([label_token(special_label.title)]) expect_tokens([label_token(special_label.title)])
expect_issues_list_count(1) expect_issues_list_count(1)
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'filters issues by label not containing special characters' do
special_label = create(:label, project: project, title: '!@#{$%^&*()-+[]<>?/:{}|\}')
special_issue = create(:issue, title: "Issue with special character label", project: project)
special_issue.labels << special_label
input_filtered_search("label!=~#{special_label.title}")
expect_tokens([label_token(special_label.title)])
expect_issues_list_count(8)
expect_filtered_search_input_empty
end
it 'does not show issues for unused labels' do it 'does not show issues for unused labels' do
new_label = create(:label, project: project, title: 'new_label') new_label = create(:label, project: project, title: 'new_label')
input_filtered_search("label:~#{new_label.title}") input_filtered_search("label=~#{new_label.title}")
expect_tokens([label_token(new_label.title)]) expect_tokens([label_token(new_label.title)])
expect_no_issues_list expect_no_issues_list
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'does show issues for bug label' do
input_filtered_search("label!=~#{bug_label.title}")
expect_tokens([label_token(bug_label.title)])
expect_issues_list_count(6)
expect_filtered_search_input_empty
end
end end
context 'label with multiple words' do context 'label with multiple words' do
...@@ -193,7 +240,7 @@ describe 'Filter issues', :js do ...@@ -193,7 +240,7 @@ describe 'Filter issues', :js do
special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project) special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project)
special_multiple_issue.labels << special_multiple_label special_multiple_issue.labels << special_multiple_label
input_filtered_search("label:~'#{special_multiple_label.title}'") input_filtered_search("label=~'#{special_multiple_label.title}'")
# Check for search results (which makes sure that the page has changed) # Check for search results (which makes sure that the page has changed)
expect_issues_list_count(1) expect_issues_list_count(1)
...@@ -205,7 +252,7 @@ describe 'Filter issues', :js do ...@@ -205,7 +252,7 @@ describe 'Filter issues', :js do
end end
it 'single quotes' do it 'single quotes' do
input_filtered_search("label:~'#{multiple_words_label.title}'") input_filtered_search("label=~'#{multiple_words_label.title}'")
expect_issues_list_count(1) expect_issues_list_count(1)
expect_tokens([label_token("\"#{multiple_words_label.title}\"")]) expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
...@@ -213,7 +260,7 @@ describe 'Filter issues', :js do ...@@ -213,7 +260,7 @@ describe 'Filter issues', :js do
end end
it 'double quotes' do it 'double quotes' do
input_filtered_search("label:~\"#{multiple_words_label.title}\"") input_filtered_search("label=~\"#{multiple_words_label.title}\"")
expect_tokens([label_token("\"#{multiple_words_label.title}\"")]) expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
expect_issues_list_count(1) expect_issues_list_count(1)
...@@ -225,7 +272,7 @@ describe 'Filter issues', :js do ...@@ -225,7 +272,7 @@ describe 'Filter issues', :js do
double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project) double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project)
double_quotes_label_issue.labels << double_quotes_label double_quotes_label_issue.labels << double_quotes_label
input_filtered_search("label:~'#{double_quotes_label.title}'") input_filtered_search("label=~'#{double_quotes_label.title}'")
expect_tokens([label_token("'#{double_quotes_label.title}'")]) expect_tokens([label_token("'#{double_quotes_label.title}'")])
expect_issues_list_count(1) expect_issues_list_count(1)
...@@ -237,7 +284,7 @@ describe 'Filter issues', :js do ...@@ -237,7 +284,7 @@ describe 'Filter issues', :js do
single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project) single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project)
single_quotes_label_issue.labels << single_quotes_label single_quotes_label_issue.labels << single_quotes_label
input_filtered_search("label:~\"#{single_quotes_label.title}\"") input_filtered_search("label=~\"#{single_quotes_label.title}\"")
expect_tokens([label_token("\"#{single_quotes_label.title}\"")]) expect_tokens([label_token("\"#{single_quotes_label.title}\"")])
expect_issues_list_count(1) expect_issues_list_count(1)
...@@ -249,7 +296,7 @@ describe 'Filter issues', :js do ...@@ -249,7 +296,7 @@ describe 'Filter issues', :js do
it 'filters issues by searched label, label2, author, assignee, milestone and text' do it 'filters issues by searched label, label2, author, assignee, milestone and text' do
search_term = 'bug' search_term = 'bug'
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}") input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title} author=@#{user.username} assignee=@#{user.username} milestone=%#{milestone.title} #{search_term}")
wait_for_requests wait_for_requests
...@@ -263,6 +310,24 @@ describe 'Filter issues', :js do ...@@ -263,6 +310,24 @@ describe 'Filter issues', :js do
expect_issues_list_count(1) expect_issues_list_count(1)
expect_filtered_search_input(search_term) expect_filtered_search_input(search_term)
end end
it 'filters issues by searched label, label2, author, assignee, not included in a milestone' do
search_term = 'bug'
input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title} author=@#{user.username} assignee=@#{user.username} milestone!=%#{milestone.title} #{search_term}")
wait_for_requests
expect_tokens([
label_token(bug_label.title),
label_token(caps_sensitive_label.title),
author_token(user.name),
assignee_token(user.name),
milestone_token(milestone.title, false, '!=')
])
expect_issues_list_count(0)
expect_filtered_search_input(search_term)
end
end end
context 'issue label clicked' do context 'issue label clicked' do
...@@ -279,7 +344,7 @@ describe 'Filter issues', :js do ...@@ -279,7 +344,7 @@ describe 'Filter issues', :js do
describe 'filter issues by milestone' do describe 'filter issues by milestone' do
context 'only milestone' do context 'only milestone' do
it 'filters issues by searched milestone' do it 'filters issues by searched milestone' do
input_filtered_search("milestone:%#{milestone.title}") input_filtered_search("milestone=%#{milestone.title}")
expect_tokens([milestone_token(milestone.title)]) expect_tokens([milestone_token(milestone.title)])
expect_issues_list_count(5) expect_issues_list_count(5)
...@@ -287,53 +352,102 @@ describe 'Filter issues', :js do ...@@ -287,53 +352,102 @@ describe 'Filter issues', :js do
end end
it 'filters issues by no milestone' do it 'filters issues by no milestone' do
input_filtered_search("milestone:none") input_filtered_search("milestone=none")
expect_tokens([milestone_token('None', false)]) expect_tokens([milestone_token('None', false)])
expect_issues_list_count(3) expect_issues_list_count(3)
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'filters issues by negation of no milestone' do
input_filtered_search("milestone!=none ")
expect_tokens([milestone_token('None', false, '!=')])
expect_issues_list_count(5)
expect_filtered_search_input_empty
end
it 'filters issues by upcoming milestones' do it 'filters issues by upcoming milestones' do
create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone| create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone|
create(:issue, project: project, milestone: future_milestone, author: user) create(:issue, project: project, milestone: future_milestone, author: user)
end end
input_filtered_search("milestone:upcoming") input_filtered_search("milestone=upcoming")
expect_tokens([milestone_token('Upcoming', false)]) expect_tokens([milestone_token('Upcoming', false)])
expect_issues_list_count(1) expect_issues_list_count(1)
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'filters issues by negation of upcoming milestones' do
create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone|
create(:issue, project: project, milestone: future_milestone, author: user)
end
input_filtered_search("milestone!=upcoming")
expect_tokens([milestone_token('Upcoming', false, '!=')])
expect_issues_list_count(8)
expect_filtered_search_input_empty
end
it 'filters issues by started milestones' do it 'filters issues by started milestones' do
input_filtered_search("milestone:started") input_filtered_search("milestone=started")
expect_tokens([milestone_token('Started', false)]) expect_tokens([milestone_token('Started', false)])
expect_issues_list_count(5) expect_issues_list_count(5)
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'filters issues by negation of started milestones' do
input_filtered_search("milestone!=started")
expect_tokens([milestone_token('Started', false, '!=')])
expect_issues_list_count(3)
expect_filtered_search_input_empty
end
it 'filters issues by milestone containing special characters' do it 'filters issues by milestone containing special characters' do
special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
create(:issue, project: project, milestone: special_milestone) create(:issue, project: project, milestone: special_milestone)
input_filtered_search("milestone:%#{special_milestone.title}") input_filtered_search("milestone=%#{special_milestone.title}")
expect_tokens([milestone_token(special_milestone.title)]) expect_tokens([milestone_token(special_milestone.title)])
expect_issues_list_count(1) expect_issues_list_count(1)
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'filters issues by milestone not containing special characters' do
special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
create(:issue, project: project, milestone: special_milestone)
input_filtered_search("milestone!=%#{special_milestone.title}")
expect_tokens([milestone_token(special_milestone.title, false, '!=')])
expect_issues_list_count(8)
expect_filtered_search_input_empty
end
it 'does not show issues for unused milestones' do it 'does not show issues for unused milestones' do
new_milestone = create(:milestone, title: 'new', project: project) new_milestone = create(:milestone, title: 'new', project: project)
input_filtered_search("milestone:%#{new_milestone.title}") input_filtered_search("milestone=%#{new_milestone.title}")
expect_tokens([milestone_token(new_milestone.title)]) expect_tokens([milestone_token(new_milestone.title)])
expect_no_issues_list expect_no_issues_list
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
it 'show issues for unused milestones' do
new_milestone = create(:milestone, title: 'new', project: project)
input_filtered_search("milestone!=%#{new_milestone.title}")
expect_tokens([milestone_token(new_milestone.title, false, '!=')])
expect_issues_list_count(8)
expect_filtered_search_input_empty
end
end end
end end
...@@ -407,7 +521,7 @@ describe 'Filter issues', :js do ...@@ -407,7 +521,7 @@ describe 'Filter issues', :js do
context 'searched text with other filters' do context 'searched text with other filters' do
it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do
input_filtered_search("bug author:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo") input_filtered_search("bug author=@#{user.username} report label=~#{bug_label.title} label=~#{caps_sensitive_label.title} milestone=%#{milestone.title} foo")
expect_issues_list_count(1) expect_issues_list_count(1)
expect_filtered_search_input('bug report foo') expect_filtered_search_input('bug report foo')
...@@ -481,7 +595,7 @@ describe 'Filter issues', :js do ...@@ -481,7 +595,7 @@ describe 'Filter issues', :js do
end end
it 'milestone dropdown loads milestones' do it 'milestone dropdown loads milestones' do
input_filtered_search("milestone:", submit: false) input_filtered_search("milestone=", submit: false)
within('#js-dropdown-milestone') do within('#js-dropdown-milestone') do
expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
...@@ -489,7 +603,7 @@ describe 'Filter issues', :js do ...@@ -489,7 +603,7 @@ describe 'Filter issues', :js do
end end
it 'label dropdown load labels' do it 'label dropdown load labels' do
input_filtered_search("label:", submit: false) input_filtered_search("label=", submit: false)
within('#js-dropdown-label') do within('#js-dropdown-label') do
expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3) expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3)
......
...@@ -41,8 +41,8 @@ describe 'Recent searches', :js do ...@@ -41,8 +41,8 @@ describe 'Recent searches', :js do
items = all('.filtered-search-history-dropdown-item', visible: false, count: 2) items = all('.filtered-search-history-dropdown-item', visible: false, count: 2)
expect(items[0].text).to eq('label: ~qux garply') expect(items[0].text).to eq('label: = ~qux garply')
expect(items[1].text).to eq('label: ~foo bar') expect(items[1].text).to eq('label: = ~foo bar')
end end
it 'saved recent searches are restored last on the list' do it 'saved recent searches are restored last on the list' do
......
...@@ -34,7 +34,7 @@ describe 'Search bar', :js do ...@@ -34,7 +34,7 @@ describe 'Search bar', :js do
it 'selects item' do it 'selects item' do
filtered_search.native.send_keys(:down, :down, :enter) filtered_search.native.send_keys(:down, :down, :enter)
expect_tokens([author_token]) expect_tokens([{ name: 'Assignee' }])
expect_filtered_search_input_empty expect_filtered_search_input_empty
end end
end end
...@@ -78,7 +78,7 @@ describe 'Search bar', :js do ...@@ -78,7 +78,7 @@ describe 'Search bar', :js do
filtered_search.click filtered_search.click
original_size = page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size original_size = page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size
filtered_search.set('author') filtered_search.set('autho')
expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
......
...@@ -36,8 +36,9 @@ describe 'Visual tokens', :js do ...@@ -36,8 +36,9 @@ describe 'Visual tokens', :js do
describe 'editing a single token' do describe 'editing a single token' do
before do before do
input_filtered_search('author:@root assignee:none', submit: false) input_filtered_search('author=@root assignee=none', submit: false)
first('.tokens-container .filtered-search-token').click first('.tokens-container .filtered-search-token').click
wait_for_requests
end end
it 'opens author dropdown' do it 'opens author dropdown' do
...@@ -76,8 +77,8 @@ describe 'Visual tokens', :js do ...@@ -76,8 +77,8 @@ describe 'Visual tokens', :js do
describe 'editing multiple tokens' do describe 'editing multiple tokens' do
before do before do
input_filtered_search('author:@root assignee:none', submit: false) input_filtered_search('author=@root assignee=none', submit: false)
first('.tokens-container .filtered-search-token').double_click first('.tokens-container .filtered-search-token').click
end end
it 'opens author dropdown' do it 'opens author dropdown' do
...@@ -85,27 +86,33 @@ describe 'Visual tokens', :js do ...@@ -85,27 +86,33 @@ describe 'Visual tokens', :js do
end end
it 'opens assignee dropdown' do it 'opens assignee dropdown' do
find('.tokens-container .filtered-search-token', text: 'Assignee').double_click find('.tokens-container .filtered-search-token', text: 'Assignee').click
expect(page).to have_css('#js-dropdown-assignee', visible: true) expect(page).to have_css('#js-dropdown-assignee', visible: true)
end end
end end
describe 'editing a search term while editing another filter token' do describe 'editing a search term while editing another filter token' do
before do before do
input_filtered_search('author assignee:', submit: false) input_filtered_search('foo assignee=', submit: false)
first('.tokens-container .filtered-search-term').double_click first('.tokens-container .filtered-search-term').click
end end
it 'opens author dropdown' do it 'opens author dropdown' do
find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'author').click find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'Author').click
expect(page).to have_css('#js-dropdown-operator', visible: true)
expect(page).to have_css('#js-dropdown-author', visible: false)
find('#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value="="]').click
expect(page).to have_css('#js-dropdown-operator', visible: false)
expect(page).to have_css('#js-dropdown-author', visible: true) expect(page).to have_css('#js-dropdown-author', visible: true)
end end
end end
describe 'add new token after editing existing token' do describe 'add new token after editing existing token' do
before do before do
input_filtered_search('author:@root assignee:none', submit: false) input_filtered_search('author=@root assignee=none', submit: false)
first('.tokens-container .filtered-search-token').double_click first('.tokens-container .filtered-search-token').double_click
filtered_search.send_keys(' ') filtered_search.send_keys(' ')
end end
...@@ -116,7 +123,7 @@ describe 'Visual tokens', :js do ...@@ -116,7 +123,7 @@ describe 'Visual tokens', :js do
end end
it 'opens token dropdown' do it 'opens token dropdown' do
filtered_search.send_keys('author:') filtered_search.send_keys('author=')
expect(page).to have_css('#js-dropdown-author', visible: true) expect(page).to have_css('#js-dropdown-author', visible: true)
end end
...@@ -124,7 +131,7 @@ describe 'Visual tokens', :js do ...@@ -124,7 +131,7 @@ describe 'Visual tokens', :js do
describe 'visual tokens' do describe 'visual tokens' do
it 'creates visual token' do it 'creates visual token' do
filtered_search.send_keys('author:@thomas ') filtered_search.send_keys('author=@thomas ')
token = page.all('.tokens-container .filtered-search-token')[1] token = page.all('.tokens-container .filtered-search-token')[1]
expect(token.find('.name').text).to eq('Author') expect(token.find('.name').text).to eq('Author')
...@@ -133,7 +140,7 @@ describe 'Visual tokens', :js do ...@@ -133,7 +140,7 @@ describe 'Visual tokens', :js do
end end
it 'does not tokenize incomplete token' do it 'does not tokenize incomplete token' do
filtered_search.send_keys('author:') filtered_search.send_keys('author=')
find('body').click find('body').click
token = page.all('.tokens-container .js-visual-token')[1] token = page.all('.tokens-container .js-visual-token')[1]
...@@ -145,7 +152,7 @@ describe 'Visual tokens', :js do ...@@ -145,7 +152,7 @@ describe 'Visual tokens', :js do
describe 'search using incomplete visual tokens' do describe 'search using incomplete visual tokens' do
before do before do
input_filtered_search('author:@root assignee:none', extra_space: false) input_filtered_search('author=@root assignee=none', extra_space: false)
end end
it 'tokenizes the search term to complete visual token' do it 'tokenizes the search term to complete visual token' do
......
...@@ -70,7 +70,7 @@ describe 'Labels Hierarchy', :js do ...@@ -70,7 +70,7 @@ describe 'Labels Hierarchy', :js do
end end
it 'does not filter by descendant group labels' do it 'does not filter by descendant group labels' do
filtered_search.set("label:") filtered_search.set("label=")
wait_for_requests wait_for_requests
...@@ -134,7 +134,7 @@ describe 'Labels Hierarchy', :js do ...@@ -134,7 +134,7 @@ describe 'Labels Hierarchy', :js do
end end
it 'does not filter by descendant group project labels' do it 'does not filter by descendant group project labels' do
filtered_search.set("label:") filtered_search.set("label=")
wait_for_requests wait_for_requests
...@@ -227,7 +227,7 @@ describe 'Labels Hierarchy', :js do ...@@ -227,7 +227,7 @@ describe 'Labels Hierarchy', :js do
it_behaves_like 'filtering by ancestor labels for projects' it_behaves_like 'filtering by ancestor labels for projects'
it 'does not filter by descendant group labels' do it 'does not filter by descendant group labels' do
filtered_search.set("label:") filtered_search.set("label=")
wait_for_requests wait_for_requests
......
...@@ -23,7 +23,7 @@ describe 'Merge Requests > Filters generic behavior', :js do ...@@ -23,7 +23,7 @@ describe 'Merge Requests > Filters generic behavior', :js do
context 'when filtered by a label' do context 'when filtered by a label' do
before do before do
input_filtered_search('label:~bug') input_filtered_search('label=~bug')
end end
describe 'state tabs' do describe 'state tabs' do
......
...@@ -18,7 +18,7 @@ describe 'Merge Requests > User filters by assignees', :js do ...@@ -18,7 +18,7 @@ describe 'Merge Requests > User filters by assignees', :js do
context 'filtering by assignee:none' do context 'filtering by assignee:none' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search('assignee:none') input_filtered_search('assignee=none')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) 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 'Bugfix1'
...@@ -26,9 +26,9 @@ describe 'Merge Requests > User filters by assignees', :js do ...@@ -26,9 +26,9 @@ describe 'Merge Requests > User filters by assignees', :js do
end end
end end
context 'filtering by assignee:@username' do context 'filtering by assignee=@username' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search("assignee:@#{user.username}") input_filtered_search("assignee=@#{user.username}")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix1' expect(page).to have_content 'Bugfix1'
......
...@@ -22,7 +22,7 @@ describe 'Merge Requests > User filters by labels', :js do ...@@ -22,7 +22,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:none' do context 'filtering by label:none' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search('label:none') input_filtered_search('label=none')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).not_to have_content 'Bugfix1' expect(page).not_to have_content 'Bugfix1'
...@@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by labels', :js do ...@@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:~enhancement' do context 'filtering by label:~enhancement' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search('label:~enhancement') input_filtered_search('label=~enhancement')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2' expect(page).to have_content 'Bugfix2'
...@@ -42,7 +42,7 @@ describe 'Merge Requests > User filters by labels', :js do ...@@ -42,7 +42,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:~enhancement and label:~bug' do context 'filtering by label:~enhancement and label:~bug' do
it 'applies the filters' do it 'applies the filters' do
input_filtered_search('label:~bug label:~enhancement') input_filtered_search('label=~bug label=~enhancement')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2' expect(page).to have_content 'Bugfix2'
......
...@@ -18,14 +18,14 @@ describe 'Merge Requests > User filters by milestones', :js do ...@@ -18,14 +18,14 @@ describe 'Merge Requests > User filters by milestones', :js do
end end
it 'filters by no milestone' do it 'filters by no milestone' do
input_filtered_search('milestone:none') input_filtered_search('milestone=none')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_css('.merge-request', count: 1) expect(page).to have_css('.merge-request', count: 1)
end end
it 'filters by a specific milestone' do it 'filters by a specific milestone' do
input_filtered_search("milestone:%'#{milestone.title}'") input_filtered_search("milestone=%'#{milestone.title}'")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_css('.merge-request', count: 1) expect(page).to have_css('.merge-request', count: 1)
...@@ -33,7 +33,7 @@ describe 'Merge Requests > User filters by milestones', :js do ...@@ -33,7 +33,7 @@ describe 'Merge Requests > User filters by milestones', :js do
describe 'filters by upcoming milestone' do describe 'filters by upcoming milestone' do
it 'does not show merge requests with no expiry' do it 'does not show merge requests with no expiry' do
input_filtered_search('milestone:upcoming') input_filtered_search('milestone=upcoming')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).to have_css('.merge-request', count: 0) expect(page).to have_css('.merge-request', count: 0)
...@@ -43,7 +43,7 @@ describe 'Merge Requests > User filters by milestones', :js do ...@@ -43,7 +43,7 @@ describe 'Merge Requests > User filters by milestones', :js do
let(:milestone) { create(:milestone, project: project, due_date: Date.tomorrow) } let(:milestone) { create(:milestone, project: project, due_date: Date.tomorrow) }
it 'shows merge requests' do it 'shows merge requests' do
input_filtered_search('milestone:upcoming') input_filtered_search('milestone=upcoming')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_css('.merge-request', count: 1) expect(page).to have_css('.merge-request', count: 1)
...@@ -54,7 +54,7 @@ describe 'Merge Requests > User filters by milestones', :js do ...@@ -54,7 +54,7 @@ describe 'Merge Requests > User filters by milestones', :js do
let(:milestone) { create(:milestone, project: project, due_date: Date.yesterday) } let(:milestone) { create(:milestone, project: project, due_date: Date.yesterday) }
it 'does not show any merge requests' do it 'does not show any merge requests' do
input_filtered_search('milestone:upcoming') input_filtered_search('milestone=upcoming')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).to have_css('.merge-request', count: 0) expect(page).to have_css('.merge-request', count: 0)
......
...@@ -20,7 +20,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do ...@@ -20,7 +20,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do
describe 'filtering by label:~"Won\'t fix" and assignee:~bug' do describe 'filtering by label:~"Won\'t fix" and assignee:~bug' do
it 'applies the filters' do it 'applies the filters' do
input_filtered_search("label:~\"Won't fix\" assignee:@#{user.username}") input_filtered_search("label=~\"Won't fix\" assignee=@#{user.username}")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2' expect(page).to have_content 'Bugfix2'
...@@ -30,7 +30,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do ...@@ -30,7 +30,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do
describe 'filtering by text, author, assignee, milestone, and label' do describe 'filtering by text, author, assignee, milestone, and label' do
it 'filters by text, author, assignee, milestone, and label' do it 'filters by text, author, assignee, milestone, and label' do
input_filtered_search_keys("author:@#{user.username} assignee:@#{user.username} milestone:%\"v1.1\" label:~\"Won't fix\" Bug") input_filtered_search_keys("author=@#{user.username} assignee=@#{user.username} milestone=%\"v1.1\" label=~\"Won't fix\" Bug")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2' expect(page).to have_content 'Bugfix2'
......
...@@ -17,7 +17,7 @@ describe 'Merge Requests > User filters by target branch', :js do ...@@ -17,7 +17,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:master' do context 'filtering by target-branch:master' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search('target-branch:master') input_filtered_search('target-branch=master')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content mr1.title expect(page).to have_content mr1.title
...@@ -27,7 +27,7 @@ describe 'Merge Requests > User filters by target branch', :js do ...@@ -27,7 +27,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:merged-target' do context 'filtering by target-branch:merged-target' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search('target-branch:merged-target') input_filtered_search('target-branch=merged-target')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).not_to have_content mr1.title expect(page).not_to have_content mr1.title
...@@ -37,7 +37,7 @@ describe 'Merge Requests > User filters by target branch', :js do ...@@ -37,7 +37,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:feature' do context 'filtering by target-branch:feature' do
it 'applies the filter' do it 'applies the filter' do
input_filtered_search('target-branch:feature') input_filtered_search('target-branch=feature')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).not_to have_content mr1.title expect(page).not_to have_content mr1.title
......
...@@ -124,6 +124,7 @@ describe('Filtered Search Token Keys', () => { ...@@ -124,6 +124,7 @@ describe('Filtered Search Token Keys', () => {
const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue( const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue(
null, null,
null, null,
null,
); );
expect(condition).toBeNull(); expect(condition).toBeNull();
...@@ -132,6 +133,7 @@ describe('Filtered Search Token Keys', () => { ...@@ -132,6 +133,7 @@ describe('Filtered Search Token Keys', () => {
it('should return condition when found by tokenKey and value', () => { it('should return condition when found by tokenKey and value', () => {
const result = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue( const result = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue(
conditions[0].tokenKey, conditions[0].tokenKey,
conditions[0].operator,
conditions[0].value, conditions[0].value,
); );
......
...@@ -398,14 +398,21 @@ describe('DropLab DropDown', function() { ...@@ -398,14 +398,21 @@ describe('DropLab DropDown', function() {
describe('render', function() { describe('render', function() {
beforeEach(function() { beforeEach(function() {
this.list = { querySelector: () => {}, dispatchEvent: () => {} };
this.dropdown = { renderChildren: () => {}, list: this.list };
this.renderableList = {}; this.renderableList = {};
this.list = {
querySelector: q => {
if (q === '.filter-dropdown-loading') {
return false;
}
return this.renderableList;
},
dispatchEvent: () => {},
};
this.dropdown = { renderChildren: () => {}, list: this.list };
this.data = [0, 1]; this.data = [0, 1];
this.customEvent = {}; this.customEvent = {};
spyOn(this.dropdown, 'renderChildren').and.callFake(data => data); spyOn(this.dropdown, 'renderChildren').and.callFake(data => data);
spyOn(this.list, 'querySelector').and.returnValue(this.renderableList);
spyOn(this.list, 'dispatchEvent'); spyOn(this.list, 'dispatchEvent');
spyOn(this.data, 'map').and.callThrough(); spyOn(this.data, 'map').and.callThrough();
spyOn(window, 'CustomEvent').and.returnValue(this.customEvent); spyOn(window, 'CustomEvent').and.returnValue(this.customEvent);
......
...@@ -222,7 +222,7 @@ describe('Dropdown Utils', () => { ...@@ -222,7 +222,7 @@ describe('Dropdown Utils', () => {
hasAttribute: () => false, hasAttribute: () => false,
}; };
DropdownUtils.setDataValueIfSelected(null, selected); DropdownUtils.setDataValueIfSelected(null, '=', selected);
expect(FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1); expect(FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
}); });
...@@ -233,9 +233,11 @@ describe('Dropdown Utils', () => { ...@@ -233,9 +233,11 @@ describe('Dropdown Utils', () => {
hasAttribute: () => false, hasAttribute: () => false,
}; };
const result = DropdownUtils.setDataValueIfSelected(null, selected); const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
expect(result).toBe(true); expect(result).toBe(true);
expect(result2).toBe(true);
}); });
it('returns false when dataValue does not exist', () => { it('returns false when dataValue does not exist', () => {
...@@ -243,9 +245,11 @@ describe('Dropdown Utils', () => { ...@@ -243,9 +245,11 @@ describe('Dropdown Utils', () => {
getAttribute: () => null, getAttribute: () => null,
}; };
const result = DropdownUtils.setDataValueIfSelected(null, selected); const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
expect(result).toBe(false); expect(result).toBe(false);
expect(result2).toBe(false);
}); });
}); });
...@@ -349,7 +353,7 @@ describe('Dropdown Utils', () => { ...@@ -349,7 +353,7 @@ describe('Dropdown Utils', () => {
beforeEach(() => { beforeEach(() => {
loadFixtures(issueListFixture); loadFixtures(issueListFixture);
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
const tokensContainer = document.querySelector('.tokens-container'); const tokensContainer = document.querySelector('.tokens-container');
...@@ -364,7 +368,7 @@ describe('Dropdown Utils', () => { ...@@ -364,7 +368,7 @@ describe('Dropdown Utils', () => {
const searchQuery = DropdownUtils.getSearchQuery(); const searchQuery = DropdownUtils.getSearchQuery();
expect(searchQuery).toBe(' search term author:original dance'); expect(searchQuery).toBe(' search term author:=original dance');
}); });
}); });
}); });
...@@ -27,7 +27,7 @@ describe('Filtered Search Dropdown Manager', () => { ...@@ -27,7 +27,7 @@ describe('Filtered Search Dropdown Manager', () => {
describe('input has no existing value', () => { describe('input has no existing value', () => {
it('should add just tokenName', () => { it('should add just tokenName', () => {
FilteredSearchDropdownManager.addWordToInput('milestone'); FilteredSearchDropdownManager.addWordToInput({ tokenName: 'milestone' });
const token = document.querySelector('.tokens-container .js-visual-token'); const token = document.querySelector('.tokens-container .js-visual-token');
...@@ -36,8 +36,8 @@ describe('Filtered Search Dropdown Manager', () => { ...@@ -36,8 +36,8 @@ describe('Filtered Search Dropdown Manager', () => {
expect(getInputValue()).toBe(''); expect(getInputValue()).toBe('');
}); });
it('should add tokenName and tokenValue', () => { it('should add tokenName, tokenOperator, and tokenValue', () => {
FilteredSearchDropdownManager.addWordToInput('label'); FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' });
let token = document.querySelector('.tokens-container .js-visual-token'); let token = document.querySelector('.tokens-container .js-visual-token');
...@@ -45,13 +45,27 @@ describe('Filtered Search Dropdown Manager', () => { ...@@ -45,13 +45,27 @@ describe('Filtered Search Dropdown Manager', () => {
expect(token.querySelector('.name').innerText).toBe('label'); expect(token.querySelector('.name').innerText).toBe('label');
expect(getInputValue()).toBe(''); expect(getInputValue()).toBe('');
FilteredSearchDropdownManager.addWordToInput('label', 'none'); FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label', tokenOperator: '=' });
token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(getInputValue()).toBe('');
FilteredSearchDropdownManager.addWordToInput({
tokenName: 'label',
tokenOperator: '=',
tokenValue: 'none',
});
// We have to get that reference again // We have to get that reference again
// Because FilteredSearchDropdownManager deletes the previous token // Because FilteredSearchDropdownManager deletes the previous token
token = document.querySelector('.tokens-container .js-visual-token'); token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label'); expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(token.querySelector('.value').innerText).toBe('none'); expect(token.querySelector('.value').innerText).toBe('none');
expect(getInputValue()).toBe(''); expect(getInputValue()).toBe('');
}); });
...@@ -60,7 +74,7 @@ describe('Filtered Search Dropdown Manager', () => { ...@@ -60,7 +74,7 @@ describe('Filtered Search Dropdown Manager', () => {
describe('input has existing value', () => { describe('input has existing value', () => {
it('should be able to just add tokenName', () => { it('should be able to just add tokenName', () => {
setInputValue('a'); setInputValue('a');
FilteredSearchDropdownManager.addWordToInput('author'); FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' });
const token = document.querySelector('.tokens-container .js-visual-token'); const token = document.querySelector('.tokens-container .js-visual-token');
...@@ -70,29 +84,40 @@ describe('Filtered Search Dropdown Manager', () => { ...@@ -70,29 +84,40 @@ describe('Filtered Search Dropdown Manager', () => {
}); });
it('should replace tokenValue', () => { it('should replace tokenValue', () => {
FilteredSearchDropdownManager.addWordToInput('author'); FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' });
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author', tokenOperator: '=' });
setInputValue('roo'); setInputValue('roo');
FilteredSearchDropdownManager.addWordToInput(null, '@root'); FilteredSearchDropdownManager.addWordToInput({
tokenName: null,
tokenOperator: '=',
tokenValue: '@root',
});
const token = document.querySelector('.tokens-container .js-visual-token'); const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('author'); expect(token.querySelector('.name').innerText).toBe('author');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(token.querySelector('.value').innerText).toBe('@root'); expect(token.querySelector('.value').innerText).toBe('@root');
expect(getInputValue()).toBe(''); expect(getInputValue()).toBe('');
}); });
it('should add tokenValues containing spaces', () => { it('should add tokenValues containing spaces', () => {
FilteredSearchDropdownManager.addWordToInput('label'); FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' });
setInputValue('"test '); setInputValue('"test ');
FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\''); FilteredSearchDropdownManager.addWordToInput({
tokenName: 'label',
tokenOperator: '=',
tokenValue: '~\'"test me"\'',
});
const token = document.querySelector('.tokens-container .js-visual-token'); const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label'); expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(token.querySelector('.value').innerText).toBe('~\'"test me"\''); expect(token.querySelector('.value').innerText).toBe('~\'"test me"\'');
expect(getInputValue()).toBe(''); expect(getInputValue()).toBe('');
}); });
......
...@@ -201,8 +201,8 @@ describe('Filtered Search Manager', function() { ...@@ -201,8 +201,8 @@ describe('Filtered Search Manager', function() {
it('removes duplicated tokens', done => { it('removes duplicated tokens', done => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
`); `);
spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => { spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => {
...@@ -234,7 +234,7 @@ describe('Filtered Search Manager', function() { ...@@ -234,7 +234,7 @@ describe('Filtered Search Manager', function() {
it('should not render placeholder when there are tokens and no input', () => { it('should not render placeholder when there are tokens and no input', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
); );
const event = new Event('input'); const event = new Event('input');
...@@ -252,7 +252,7 @@ describe('Filtered Search Manager', function() { ...@@ -252,7 +252,7 @@ describe('Filtered Search Manager', function() {
describe('tokens and no input', () => { describe('tokens and no input', () => {
beforeEach(() => { beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
); );
}); });
...@@ -306,7 +306,7 @@ describe('Filtered Search Manager', function() { ...@@ -306,7 +306,7 @@ describe('Filtered Search Manager', function() {
it('removes token even when it is already selected', () => { it('removes token even when it is already selected', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
); );
tokensContainer.querySelector('.js-visual-token .remove-token').click(); tokensContainer.querySelector('.js-visual-token .remove-token').click();
...@@ -319,7 +319,7 @@ describe('Filtered Search Manager', function() { ...@@ -319,7 +319,7 @@ describe('Filtered Search Manager', function() {
spyOn(FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough(); spyOn(FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'), FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'),
); );
tokensContainer.querySelector('.js-visual-token .remove-token').click(); tokensContainer.querySelector('.js-visual-token .remove-token').click();
}); });
...@@ -338,7 +338,7 @@ describe('Filtered Search Manager', function() { ...@@ -338,7 +338,7 @@ describe('Filtered Search Manager', function() {
beforeEach(() => { beforeEach(() => {
initializeManager(); initializeManager();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
); );
}); });
...@@ -424,7 +424,7 @@ describe('Filtered Search Manager', function() { ...@@ -424,7 +424,7 @@ describe('Filtered Search Manager', function() {
}); });
it('Clicking the "x" clear button, clears the input', () => { it('Clicking the "x" clear button, clears the input', () => {
const inputValue = 'label:~bug '; const inputValue = 'label:=~bug';
manager.filteredSearchInput.value = inputValue; manager.filteredSearchInput.value = inputValue;
manager.filteredSearchInput.dispatchEvent(new Event('input')); manager.filteredSearchInput.dispatchEvent(new Event('input'));
......
...@@ -6,9 +6,10 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -6,9 +6,10 @@ describe('Filtered Search Visual Tokens', () => {
const findElements = tokenElement => { const findElements = tokenElement => {
const tokenNameElement = tokenElement.querySelector('.name'); const tokenNameElement = tokenElement.querySelector('.name');
const tokenOperatorElement = tokenElement.querySelector('.operator');
const tokenValueContainer = tokenElement.querySelector('.value-container'); const tokenValueContainer = tokenElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value'); const tokenValueElement = tokenValueContainer.querySelector('.value');
return { tokenNameElement, tokenValueContainer, tokenValueElement }; return { tokenNameElement, tokenOperatorElement, tokenValueContainer, tokenValueElement };
}; };
let tokensContainer; let tokensContainer;
...@@ -23,8 +24,8 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -23,8 +24,8 @@ describe('Filtered Search Visual Tokens', () => {
`); `);
tokensContainer = document.querySelector('.tokens-container'); tokensContainer = document.querySelector('.tokens-container');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
}); });
describe('getLastVisualTokenBeforeInput', () => { describe('getLastVisualTokenBeforeInput', () => {
...@@ -62,7 +63,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -62,7 +63,7 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${bugLabelToken.outerHTML} ${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
`); `);
const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput();
...@@ -92,7 +93,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -92,7 +93,7 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${bugLabelToken.outerHTML} ${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createInputHTML()} ${FilteredSearchSpecHelper.createInputHTML()}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
`); `);
const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput();
...@@ -105,7 +106,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -105,7 +106,7 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')}
${FilteredSearchSpecHelper.createInputHTML()} ${FilteredSearchSpecHelper.createInputHTML()}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
`); `);
const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput();
...@@ -150,8 +151,8 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -150,8 +151,8 @@ describe('Filtered Search Visual Tokens', () => {
it('removes the selected class from buttons', () => { it('removes the selected class from buttons', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@author')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@author')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '%123', true)} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '%123', true)}
`); `);
const selected = tokensContainer.querySelector('.js-visual-token .selected'); const selected = tokensContainer.querySelector('.js-visual-token .selected');
...@@ -169,7 +170,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -169,7 +170,7 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${bugLabelToken.outerHTML} ${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~awesome')}
`); `);
}); });
...@@ -206,7 +207,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -206,7 +207,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('removeSelectedToken', () => { describe('removeSelectedToken', () => {
it('does not remove when there are no selected tokens', () => { it('does not remove when there are no selected tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'), FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'),
); );
expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null);
...@@ -218,7 +219,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -218,7 +219,7 @@ describe('Filtered Search Visual Tokens', () => {
it('removes selected token', () => { it('removes selected token', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
); );
expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null);
...@@ -281,16 +282,22 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -281,16 +282,22 @@ describe('Filtered Search Visual Tokens', () => {
describe('addVisualTokenElement', () => { describe('addVisualTokenElement', () => {
it('renders search visual tokens', () => { it('renders search visual tokens', () => {
subject.addVisualTokenElement('search term', null, { isSearchTerm: true }); subject.addVisualTokenElement({
name: 'search term',
operator: '=',
value: null,
options: { 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);
expect(token.querySelector('.name').innerText).toEqual('search term'); expect(token.querySelector('.name').innerText).toEqual('search term');
expect(token.querySelector('.operator').innerText).toEqual('=');
expect(token.querySelector('.value')).toEqual(null); expect(token.querySelector('.value')).toEqual(null);
}); });
it('renders filter visual token name', () => { it('renders filter visual token name', () => {
subject.addVisualTokenElement('milestone'); subject.addVisualTokenElement({ name: 'milestone' });
const token = tokensContainer.querySelector('.js-visual-token'); const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('search-token-milestone')).toEqual(true); expect(token.classList.contains('search-token-milestone')).toEqual(true);
...@@ -299,22 +306,23 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -299,22 +306,23 @@ describe('Filtered Search Visual Tokens', () => {
expect(token.querySelector('.value')).toEqual(null); expect(token.querySelector('.value')).toEqual(null);
}); });
it('renders filter visual token name and value', () => { it('renders filter visual token name, operator, and value', () => {
subject.addVisualTokenElement('label', 'Frontend'); subject.addVisualTokenElement({ name: 'label', operator: '!=', value: 'Frontend' });
const token = tokensContainer.querySelector('.js-visual-token'); const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('search-token-label')).toEqual(true); expect(token.classList.contains('search-token-label')).toEqual(true);
expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('label'); expect(token.querySelector('.name').innerText).toEqual('label');
expect(token.querySelector('.operator').innerText).toEqual('!=');
expect(token.querySelector('.value').innerText).toEqual('Frontend'); expect(token.querySelector('.value').innerText).toEqual('Frontend');
}); });
it('inserts visual token before input', () => { it('inserts visual token before input', () => {
tokensContainer.appendChild( tokensContainer.appendChild(
FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root'), FilteredSearchSpecHelper.createFilterVisualToken('assignee', '=', '@root'),
); );
subject.addVisualTokenElement('label', 'Frontend'); subject.addVisualTokenElement({ name: 'label', operator: '!=', value: 'Frontend' });
const tokens = tokensContainer.querySelectorAll('.js-visual-token'); const tokens = tokensContainer.querySelectorAll('.js-visual-token');
const labelToken = tokens[0]; const labelToken = tokens[0];
const assigneeToken = tokens[1]; const assigneeToken = tokens[1];
...@@ -323,18 +331,20 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -323,18 +331,20 @@ describe('Filtered Search Visual Tokens', () => {
expect(labelToken.classList.contains('filtered-search-token')).toEqual(true); expect(labelToken.classList.contains('filtered-search-token')).toEqual(true);
expect(labelToken.querySelector('.name').innerText).toEqual('label'); expect(labelToken.querySelector('.name').innerText).toEqual('label');
expect(labelToken.querySelector('.value').innerText).toEqual('Frontend'); expect(labelToken.querySelector('.value').innerText).toEqual('Frontend');
expect(labelToken.querySelector('.operator').innerText).toEqual('!=');
expect(assigneeToken.classList.contains('search-token-assignee')).toEqual(true); expect(assigneeToken.classList.contains('search-token-assignee')).toEqual(true);
expect(assigneeToken.classList.contains('filtered-search-token')).toEqual(true); expect(assigneeToken.classList.contains('filtered-search-token')).toEqual(true);
expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee'); expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee');
expect(assigneeToken.querySelector('.value').innerText).toEqual('@root'); expect(assigneeToken.querySelector('.value').innerText).toEqual('@root');
expect(assigneeToken.querySelector('.operator').innerText).toEqual('=');
}); });
}); });
describe('addValueToPreviousVisualTokenElement', () => { describe('addValueToPreviousVisualTokenElement', () => {
it('does not add when previous visual token element has no value', () => { it('does not add when previous visual token element has no value', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root'), FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root'),
); );
const original = tokensContainer.innerHTML; const original = tokensContainer.innerHTML;
...@@ -345,7 +355,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -345,7 +355,7 @@ describe('Filtered Search Visual Tokens', () => {
it('does not add when previous visual token element is a search', () => { it('does not add when previous visual token element is a search', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
`); `);
...@@ -357,7 +367,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -357,7 +367,7 @@ describe('Filtered Search Visual Tokens', () => {
it('adds value to previous visual filter token', () => { it('adds value to previous visual filter token', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label'), FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label', '='),
); );
const original = tokensContainer.innerHTML; const original = tokensContainer.innerHTML;
...@@ -377,25 +387,28 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -377,25 +387,28 @@ describe('Filtered Search Visual Tokens', () => {
expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('milestone'); expect(token.querySelector('.name').innerText).toEqual('milestone');
expect(token.querySelector('.operator')).toEqual(null);
expect(token.querySelector('.value')).toEqual(null); expect(token.querySelector('.value')).toEqual(null);
}); });
it('creates visual token with just tokenValue', () => { it('creates visual token with just tokenValue', () => {
subject.addFilterVisualToken('milestone'); subject.addFilterVisualToken('milestone', '=');
subject.addFilterVisualToken('%8.17'); subject.addFilterVisualToken('%8.17');
const token = tokensContainer.querySelector('.js-visual-token'); const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('milestone'); expect(token.querySelector('.name').innerText).toEqual('milestone');
expect(token.querySelector('.operator').innerText).toEqual('=');
expect(token.querySelector('.value').innerText).toEqual('%8.17'); expect(token.querySelector('.value').innerText).toEqual('%8.17');
}); });
it('creates full visual token', () => { it('creates full visual token', () => {
subject.addFilterVisualToken('assignee', '@john'); subject.addFilterVisualToken('assignee', '=', '@john');
const token = tokensContainer.querySelector('.js-visual-token'); const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('assignee'); expect(token.querySelector('.name').innerText).toEqual('assignee');
expect(token.querySelector('.operator').innerText).toEqual('=');
expect(token.querySelector('.value').innerText).toEqual('@john'); expect(token.querySelector('.value').innerText).toEqual('@john');
}); });
}); });
...@@ -412,7 +425,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -412,7 +425,7 @@ describe('Filtered Search Visual Tokens', () => {
it('appends to previous search visual token if previous token was a search token', () => { it('appends to previous search visual token if previous token was a search token', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
`); `);
...@@ -467,7 +480,11 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -467,7 +480,11 @@ describe('Filtered Search Visual Tokens', () => {
describe('removeLastTokenPartial', () => { describe('removeLastTokenPartial', () => {
it('should remove the last token value if it exists', () => { it('should remove the last token value if it exists', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~"Community Contribution"'), FilteredSearchSpecHelper.createFilterVisualTokenHTML(
'label',
'=',
'~"Community Contribution"',
),
); );
expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null); expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null);
...@@ -507,7 +524,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -507,7 +524,7 @@ describe('Filtered Search Visual Tokens', () => {
it('adds search visual token if previous visual token is valid', () => { it('adds search visual token if previous visual token is valid', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', 'none'), FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', '=', 'none'),
); );
const input = document.querySelector('.filtered-search'); const input = document.querySelector('.filtered-search');
...@@ -523,7 +540,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -523,7 +540,7 @@ describe('Filtered Search Visual Tokens', () => {
it('adds value to previous visual token element if previous visual token is invalid', () => { it('adds value to previous visual token element if previous visual token is invalid', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee'), FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('assignee', '='),
); );
const input = document.querySelector('.filtered-search'); const input = document.querySelector('.filtered-search');
...@@ -534,6 +551,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -534,6 +551,7 @@ describe('Filtered Search Visual Tokens', () => {
expect(input.value).toEqual(''); expect(input.value).toEqual('');
expect(updatedToken.querySelector('.name').innerText).toEqual('assignee'); expect(updatedToken.querySelector('.name').innerText).toEqual('assignee');
expect(updatedToken.querySelector('.operator').innerText).toEqual('=');
expect(updatedToken.querySelector('.value').innerText).toEqual('@john'); expect(updatedToken.querySelector('.value').innerText).toEqual('@john');
}); });
}); });
...@@ -544,9 +562,9 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -544,9 +562,9 @@ describe('Filtered Search Visual Tokens', () => {
beforeEach(() => { beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'upcoming')}
`); `);
input = document.querySelector('.filtered-search'); input = document.querySelector('.filtered-search');
...@@ -614,7 +632,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -614,7 +632,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('moveInputTotheRight', () => { describe('moveInputTotheRight', () => {
it('does nothing if the input is already the right most element', () => { it('does nothing if the input is already the right most element', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'), FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none'),
); );
spyOn(subject, 'tokenizeInput').and.callFake(() => {}); spyOn(subject, 'tokenizeInput').and.callFake(() => {});
...@@ -628,12 +646,12 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -628,12 +646,12 @@ describe('Filtered Search Visual Tokens', () => {
it("tokenize's input", () => { it("tokenize's input", () => {
tokensContainer.innerHTML = ` tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label', '=')}
${FilteredSearchSpecHelper.createInputHTML()} ${FilteredSearchSpecHelper.createInputHTML()}
${bugLabelToken.outerHTML} ${bugLabelToken.outerHTML}
`; `;
document.querySelector('.filtered-search').value = 'none'; tokensContainer.querySelector('.filtered-search').value = 'none';
subject.moveInputToTheRight(); subject.moveInputToTheRight();
const value = tokensContainer.querySelector('.js-visual-token .value'); const value = tokensContainer.querySelector('.js-visual-token .value');
...@@ -643,7 +661,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -643,7 +661,7 @@ describe('Filtered Search Visual Tokens', () => {
it('converts input into search term token if last token is valid', () => { it('converts input into search term token if last token is valid', () => {
tokensContainer.innerHTML = ` tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')}
${FilteredSearchSpecHelper.createInputHTML()} ${FilteredSearchSpecHelper.createInputHTML()}
${bugLabelToken.outerHTML} ${bugLabelToken.outerHTML}
`; `;
...@@ -658,7 +676,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -658,7 +676,7 @@ describe('Filtered Search Visual Tokens', () => {
it('moves the input to the right most element', () => { it('moves the input to the right most element', () => {
tokensContainer.innerHTML = ` tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')}
${FilteredSearchSpecHelper.createInputHTML()} ${FilteredSearchSpecHelper.createInputHTML()}
${bugLabelToken.outerHTML} ${bugLabelToken.outerHTML}
`; `;
...@@ -670,8 +688,8 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -670,8 +688,8 @@ describe('Filtered Search Visual Tokens', () => {
it('tokenizes input even if input is the right most element', () => { it('tokenizes input even if input is the right most element', () => {
tokensContainer.innerHTML = ` tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')}
${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label')}
${FilteredSearchSpecHelper.createInputHTML('', '~bug')} ${FilteredSearchSpecHelper.createInputHTML('', '~bug')}
`; `;
......
...@@ -138,6 +138,7 @@ describe('Issues Filtered Search Token Keys', () => { ...@@ -138,6 +138,7 @@ describe('Issues Filtered Search Token Keys', () => {
const conditions = IssuableFilteredSearchTokenKeys.getConditions(); const conditions = IssuableFilteredSearchTokenKeys.getConditions();
const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue( const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(
conditions[0].tokenKey, conditions[0].tokenKey,
conditions[0].operator,
conditions[0].value, conditions[0].value,
); );
......
...@@ -10,9 +10,11 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -10,9 +10,11 @@ describe('Filtered Search Visual Tokens', () => {
const tokenNameElement = tokenElement.querySelector('.name'); const tokenNameElement = tokenElement.querySelector('.name');
const tokenValueContainer = tokenElement.querySelector('.value-container'); const tokenValueContainer = tokenElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value'); const tokenValueElement = tokenValueContainer.querySelector('.value');
const tokenOperatorElement = tokenElement.querySelector('.operator');
const tokenType = tokenNameElement.innerText.toLowerCase(); const tokenType = tokenNameElement.innerText.toLowerCase();
const tokenValue = tokenValueElement.innerText; const tokenValue = tokenValueElement.innerText;
const subject = new VisualTokenValue(tokenValue, tokenType); const tokenOperator = tokenOperatorElement.innerText;
const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
return { subject, tokenValueContainer, tokenValueElement }; return { subject, tokenValueContainer, tokenValueElement };
}; };
...@@ -28,8 +30,8 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -28,8 +30,8 @@ describe('Filtered Search Visual Tokens', () => {
`); `);
tokensContainer = document.querySelector('.tokens-container'); tokensContainer = document.querySelector('.tokens-container');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
}); });
describe('updateUserTokenAppearance', () => { describe('updateUserTokenAppearance', () => {
...@@ -140,10 +142,12 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -140,10 +142,12 @@ describe('Filtered Search Visual Tokens', () => {
const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label', 'label',
'=',
'~doesnotexist', '~doesnotexist',
); );
const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label', 'label',
'=',
'~"some space"', '~"some space"',
); );
......
export default class FilteredSearchSpecHelper { export default class FilteredSearchSpecHelper {
static createFilterVisualTokenHTML(name, value, isSelected) { static createFilterVisualTokenHTML(name, operator, value, isSelected) {
return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML; return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected)
.outerHTML;
} }
static createFilterVisualToken(name, value, isSelected = false) { static createFilterVisualToken(name, operator, value, isSelected = false) {
const li = document.createElement('li'); const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`); li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
li.innerHTML = ` li.innerHTML = `
<div class="selectable ${isSelected ? 'selected' : ''}" role="button"> <div class="selectable ${isSelected ? 'selected' : ''}" role="button">
<div class="name">${name}</div> <div class="name">${name}</div>
<div class="operator">${operator}</div>
<div class="value-container"> <div class="value-container">
<div class="value">${value}</div> <div class="value">${value}</div>
<div class="remove-token" role="button"> <div class="remove-token" role="button">
...@@ -30,6 +32,15 @@ export default class FilteredSearchSpecHelper { ...@@ -30,6 +32,15 @@ export default class FilteredSearchSpecHelper {
`; `;
} }
static createNameOperatorFilterVisualTokenHTML(name, operator) {
return `
<li class="js-visual-token filtered-search-token">
<div class="name">${name}</div>
<div class="operator">${operator}</div>
</li>
`;
}
static createSearchVisualToken(name) { static createSearchVisualToken(name) {
const li = document.createElement('li'); const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-term'); li.classList.add('js-visual-token', 'filtered-search-term');
......
...@@ -26,7 +26,7 @@ module FilteredSearchHelpers ...@@ -26,7 +26,7 @@ module FilteredSearchHelpers
# Select a label clicking in the search dropdown instead # Select a label clicking in the search dropdown instead
# of entering label names on the input. # of entering label names on the input.
def select_label_on_dropdown(label_title) def select_label_on_dropdown(label_title)
input_filtered_search("label:", submit: false) input_filtered_search("label=", submit: false)
within('#js-dropdown-label') do within('#js-dropdown-label') do
wait_for_requests wait_for_requests
...@@ -71,7 +71,7 @@ module FilteredSearchHelpers ...@@ -71,7 +71,7 @@ module FilteredSearchHelpers
end end
def init_label_search def init_label_search
filtered_search.set('label:') filtered_search.set('label=')
# This ensures the dropdown is shown # This ensures the dropdown is shown
expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading') expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading')
end end
...@@ -90,6 +90,7 @@ module FilteredSearchHelpers ...@@ -90,6 +90,7 @@ module FilteredSearchHelpers
el = token_elements[index] el = token_elements[index]
expect(el.find('.name')).to have_content(token[:name]) expect(el.find('.name')).to have_content(token[:name])
expect(el.find('.operator')).to have_content(token[:operator]) if token[:operator].present?
expect(el.find('.value')).to have_content(token[:value]) if token[:value].present? expect(el.find('.value')).to have_content(token[:value]) if token[:value].present?
# gl-emoji content is blank when the emoji unicode is not supported # gl-emoji content is blank when the emoji unicode is not supported
...@@ -101,8 +102,8 @@ module FilteredSearchHelpers ...@@ -101,8 +102,8 @@ module FilteredSearchHelpers
end end
end end
def create_token(token_name, token_value = nil, symbol = nil) def create_token(token_name, token_value = nil, symbol = nil, token_operator = '=')
{ name: token_name, value: "#{symbol}#{token_value}" } { name: token_name, operator: token_operator, value: "#{symbol}#{token_value}" }
end end
def author_token(author_name = nil) def author_token(author_name = nil)
...@@ -113,9 +114,9 @@ module FilteredSearchHelpers ...@@ -113,9 +114,9 @@ module FilteredSearchHelpers
create_token('Assignee', assignee_name) create_token('Assignee', assignee_name)
end end
def milestone_token(milestone_name = nil, has_symbol = true) def milestone_token(milestone_name = nil, has_symbol = true, operator = '=')
symbol = has_symbol ? '%' : nil symbol = has_symbol ? '%' : nil
create_token('Milestone', milestone_name, symbol) create_token('Milestone', milestone_name, symbol, operator)
end end
def release_token(release_tag = nil) def release_token(release_tag = nil)
......
...@@ -13,7 +13,7 @@ shared_examples 'issuable user dropdown behaviors' do ...@@ -13,7 +13,7 @@ shared_examples 'issuable user dropdown behaviors' do
it 'only includes members of the project/group' do it 'only includes members of the project/group' do
visit issuables_path visit issuables_path
filtered_search.set("#{dropdown}:") filtered_search.set("#{dropdown}=")
expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).to have_content(user_in_dropdown.name) expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).to have_content(user_in_dropdown.name)
expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).not_to have_content(user_not_in_dropdown.name) expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).not_to have_content(user_not_in_dropdown.name)
......
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