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 {
render(data) {
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;
renderableList.innerHTML = children.join('');
......
......@@ -2,6 +2,7 @@ import { __ } from '~/locale';
export default IssuableTokenKeys => {
const wipToken = {
formattedKey: __('WIP'),
key: 'wip',
type: 'string',
param: '',
......@@ -17,6 +18,7 @@ export default IssuableTokenKeys => {
IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
const targetBranchToken = {
formattedKey: __('Target-Branch'),
key: 'target-branch',
type: 'string',
param: '',
......
import { __ } from '~/locale';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
const tokenKeys = [
{
formattedKey: __('Status'),
key: 'status',
type: 'string',
param: 'status',
......@@ -10,6 +12,7 @@ const tokenKeys = [
tag: 'status',
},
{
formattedKey: __('Type'),
key: 'type',
type: 'string',
param: 'type',
......@@ -18,6 +21,7 @@ const tokenKeys = [
tag: 'type',
},
{
formattedKey: __('Tag'),
key: 'tag',
type: 'array',
param: 'name[]',
......
......@@ -4,6 +4,7 @@ import DropdownNonUser from './dropdown_non_user';
import DropdownEmoji from './dropdown_emoji';
import NullDropdown from './null_dropdown';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownOperator from './dropdown_operator';
import DropdownUtils from './dropdown_utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
......@@ -40,6 +41,11 @@ export default class AvailableDropdownMappings {
gl: DropdownHint,
element: this.container.querySelector('#js-dropdown-hint'),
},
operator: {
reference: null,
gl: DropdownOperator,
element: this.container.querySelector('#js-dropdown-operator'),
},
};
supportedTokens.forEach(type => {
......
......@@ -29,6 +29,7 @@ export default {
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
operator: token.operator,
suffix: `${token.symbol}${token.value}`,
}));
......@@ -75,6 +76,7 @@ export default {
class="filtered-search-history-dropdown-token"
>
<span class="name">{{ token.prefix }}</span>
<span class="name">{{ token.operator }}</span>
<span class="value">{{ token.suffix }}</span>
</span>
</span>
......
/* eslint-disable import/prefer-default-export */
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 {
getSearchInput() {
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 || '';
......
......@@ -3,6 +3,7 @@ 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';
import { __ } from '~/locale';
export default class DropdownHint extends FilteredSearchDropdown {
constructor(options = {}) {
......@@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown {
this.dismissDropdown();
this.dispatchFormSubmitEvent();
} else {
const token = selected.querySelector('.js-filter-hint').innerText.trim();
const tag = selected.querySelector('.js-filter-tag').innerText.trim();
const filterItemEl = selected.closest('.filter-dropdown-item');
const { hint: token, tag } = filterItemEl.dataset;
if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens
......@@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown {
const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
FilteredSearchDropdownManager.addWordToInput(key, '', false, {
uppercaseTokenName,
FilteredSearchDropdownManager.addWordToInput({
tokenName: key,
clicked: false,
options: {
uppercaseTokenName,
},
});
}
this.dismissDropdown();
......@@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown {
}
renderContent() {
const dropdownData = this.tokenKeys.get().map(tokenKey => ({
icon: `${gon.sprite_icons}#${tokenKey.icon}`,
hint: tokenKey.key,
tag: `:${tokenKey.tag}`,
type: tokenKey.type,
}));
const searchItem = [
{
hint: 'search',
tag: 'search',
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.setData(this.hookId, dropdownData);
super.renderContent();
}
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 {
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
const isSearchItem = updatedItem.hint === 'search';
if (isSearchItem) {
updatedItem.droplab_hidden = true;
}
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
} else if (!lastKey || _.last(searchInput.split('')) === ' ') {
} else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.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;
}
return updatedItem;
}
static setDataValueIfSelected(filter, selected) {
static setDataValueIfSelected(filter, operator, selected) {
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
FilteredSearchDropdownManager.addWordToInput({
tokenName: filter,
tokenOperator: operator,
tokenValue: dataValue,
clicked: true,
options: {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
},
});
}
......@@ -101,7 +115,11 @@ export default class DropdownUtils {
// remove leading symbol and wrapping quotes
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)
......@@ -119,10 +137,16 @@ export default class DropdownUtils {
tokens.forEach(token => {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
const operatorContainer = token.querySelector('.operator');
const value = token.querySelector('.value');
const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
let operator = '';
if (operatorContainer) {
operator = operatorContainer.textContent.trim();
}
if (valueContainer && valueContainer.dataset.originalValue) {
valueText = valueContainer.dataset.originalValue;
......@@ -131,7 +155,7 @@ export default class DropdownUtils {
}
if (token.className.indexOf('filtered-search-token') !== -1) {
values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`);
values.push(`${name.innerText.toLowerCase()}:${operator}${symbol}${valueText}`);
} else {
values.push(name.innerText);
}
......
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
......@@ -31,13 +32,26 @@ export default class FilteredSearchDropdown {
itemClicked(e, getValueFunction) {
const { selected } = e.detail;
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) {
const value = getValueFunction(selected);
FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
FilteredSearchDropdownManager.addWordToInput({
tokenName: this.filter,
tokenOperator,
tokenValue: value,
clicked: true,
});
}
this.resetFilters();
......
......@@ -5,6 +5,7 @@ import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import { DROPDOWN_TYPE } from './constants';
export default class FilteredSearchDropdownManager {
constructor({
......@@ -67,10 +68,16 @@ export default class FilteredSearchDropdownManager {
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 input = FilteredSearchContainer.container.querySelector('.filtered-search');
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, {
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenOperator, tokenValue, {
uppercaseTokenName,
capitalizeTokenValue,
});
......@@ -129,7 +136,10 @@ export default class FilteredSearchDropdownManager {
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
forceShowList = true;
}
......@@ -148,13 +158,19 @@ export default class FilteredSearchDropdownManager {
this.droplab = new DropLab();
}
if (dropdownName === DROPDOWN_TYPE.operator) {
this.load(dropdownName, firstLoad);
return;
}
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown =
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) {
const key = match && match.key ? match.key : 'hint';
const key = match && match.key ? match.key : DROPDOWN_TYPE.hint;
this.load(key, firstLoad);
}
}
......@@ -169,19 +185,32 @@ export default class FilteredSearchDropdownManager {
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
}
if (lastToken === searchToken && lastToken !== null) {
// Token is not fully initialized yet because it has no value
// Eg. token = 'label:'
const split = lastToken.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) {
const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator();
// 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 {
this.loadDropdown('hint');
this.loadDropdown(DROPDOWN_TYPE.hint);
}
}
......
......@@ -65,17 +65,20 @@ export default class FilteredSearchTokenKeys {
return this.conditions.find(condition => condition.url === url) || null;
}
searchByConditionKeyValue(key, value) {
searchByConditionKeyValue(key, operator, value) {
return (
this.conditions.find(
condition =>
condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(),
condition.tokenKey === key &&
condition.operator === operator &&
condition.value.toLowerCase() === value.toLowerCase(),
) || null
);
}
addExtraTokensForIssues() {
const confidentialToken = {
formattedKey: __('Confidential'),
key: 'confidential',
type: 'string',
param: '',
......
......@@ -2,10 +2,11 @@ import './filtered_search_token_keys';
export default class FilteredSearchTokenizer {
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)
const tokenRegex = new RegExp(
`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
`(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
'g',
);
const tokens = [];
......@@ -13,16 +14,22 @@ export default class FilteredSearchTokenizer {
let lastToken = null;
const searchToken =
input
.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => {
.replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => {
let tokenValue = v1 || v2 || v3;
let tokenSymbol = symbol;
let tokenIndex = '';
let tokenOperator = operator;
if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
tokenSymbol = tokenValue;
tokenValue = '';
}
if (tokenValue === '!=' || tokenValue === '=') {
tokenOperator = tokenValue;
tokenValue = '';
}
tokenIndex = `${key}:${tokenValue}`;
// Prevent adding duplicates
......@@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer {
key,
value: tokenValue || '',
symbol: tokenSymbol || '',
operator: tokenOperator || '',
});
}
......@@ -43,13 +51,12 @@ export default class FilteredSearchTokenizer {
if (tokens.length > 0) {
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 =
input.lastIndexOf(lastString) === input.length - lastString.length ? last : searchToken;
} else {
lastToken = searchToken;
}
return {
tokens,
lastToken,
......
import { flatten } from 'underscore';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale';
export const tokenKeys = [
{
formattedKey: __('Author'),
key: 'author',
type: 'string',
param: 'username',
......@@ -11,6 +13,7 @@ export const tokenKeys = [
tag: '@author',
},
{
formattedKey: __('Assignee'),
key: 'assignee',
type: 'string',
param: 'username',
......@@ -19,6 +22,7 @@ export const tokenKeys = [
tag: '@assignee',
},
{
formattedKey: __('Milestone'),
key: 'milestone',
type: 'string',
param: 'title',
......@@ -27,6 +31,7 @@ export const tokenKeys = [
tag: '%milestone',
},
{
formattedKey: __('Release'),
key: 'release',
type: 'string',
param: 'tag',
......@@ -35,6 +40,7 @@ export const tokenKeys = [
tag: __('tag name'),
},
{
formattedKey: __('Label'),
key: 'label',
type: 'array',
param: 'name[]',
......@@ -47,6 +53,7 @@ export const tokenKeys = [
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
formattedKey: __('My-Reaction'),
key: 'my-reaction',
type: 'string',
param: 'emoji',
......@@ -58,6 +65,7 @@ if (gon.current_user_id) {
export const alternativeTokenKeys = [
{
formattedKey: __('Label'),
key: 'label',
type: 'string',
param: 'name',
......@@ -65,68 +73,88 @@ export const alternativeTokenKeys = [
},
];
export const conditions = [
{
url: 'assignee_id=None',
tokenKey: 'assignee',
value: __('None'),
},
{
url: 'assignee_id=Any',
tokenKey: 'assignee',
value: __('Any'),
},
{
url: 'milestone_title=None',
tokenKey: 'milestone',
value: __('None'),
},
{
url: 'milestone_title=Any',
tokenKey: 'milestone',
value: __('Any'),
},
{
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: __('Upcoming'),
},
{
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: __('Started'),
},
{
url: 'release_tag=None',
tokenKey: 'release',
value: __('None'),
},
{
url: 'release_tag=Any',
tokenKey: 'release',
value: __('Any'),
},
{
url: 'label_name[]=None',
tokenKey: 'label',
value: __('None'),
},
{
url: 'label_name[]=Any',
tokenKey: 'label',
value: __('Any'),
},
{
url: 'my_reaction_emoji=None',
tokenKey: 'my-reaction',
value: __('None'),
},
{
url: 'my_reaction_emoji=Any',
tokenKey: 'my-reaction',
value: __('Any'),
},
];
export const conditions = flatten(
[
{
url: 'assignee_id=None',
tokenKey: 'assignee',
value: __('None'),
},
{
url: 'assignee_id=Any',
tokenKey: 'assignee',
value: __('Any'),
},
{
url: 'milestone_title=None',
tokenKey: 'milestone',
value: __('None'),
},
{
url: 'milestone_title=Any',
tokenKey: 'milestone',
value: __('Any'),
},
{
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: __('Upcoming'),
},
{
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: __('Started'),
},
{
url: 'release_tag=None',
tokenKey: 'release',
value: __('None'),
},
{
url: 'release_tag=Any',
tokenKey: 'release',
value: __('Any'),
},
{
url: 'label_name[]=None',
tokenKey: 'label',
value: __('None'),
},
{
url: 'label_name[]=Any',
tokenKey: 'label',
value: __('Any'),
},
{
url: 'my_reaction_emoji=None',
tokenKey: 'my-reaction',
value: __('None'),
},
{
url: 'my_reaction_emoji=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(
tokenKeys,
......
......@@ -9,9 +9,10 @@ import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
export default class VisualTokenValue {
constructor(tokenValue, tokenType) {
constructor(tokenValue, tokenType, tokenOperator) {
this.tokenValue = tokenValue;
this.tokenType = tokenType;
this.tokenOperator = tokenOperator;
}
render(tokenValueContainer, tokenValueElement) {
......
......@@ -2,3 +2,4 @@ export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
export const BACKSPACE_KEY_CODE = 8;
......@@ -88,6 +88,7 @@
}
.name,
.operator,
.value {
display: inline-block;
padding: 2px 7px;
......@@ -101,6 +102,12 @@
text-transform: capitalize;
}
.operator {
background-color: $white-normal;
color: $filter-value-text-color;
margin-right: 1px;
}
.value-container {
display: flex;
align-items: center;
......@@ -147,6 +154,10 @@
background-color: $filter-name-selected-color;
}
.operator {
box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
}
.value-container {
box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
}
......@@ -260,6 +271,11 @@
max-width: none;
min-width: 100%;
}
.btn-helptext {
margin-left: auto;
color: var(--gray);
}
}
.filtered-search-history-dropdown-wrapper {
......
......@@ -90,7 +90,7 @@ module Boards
end
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? }
end
......
......@@ -87,7 +87,7 @@ class IssuableFinder
end
def valid_params
@valid_params ||= scalar_params + [array_params] + [{ not: [] }]
@valid_params ||= scalar_params + [array_params.merge(not: {})]
end
end
......
......@@ -5,6 +5,10 @@ module Boards
class ListService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
def self.valid_params
IssuesFinder.valid_params
end
def execute
fetch_issues.order_by_position_and_priority
end
......
......@@ -57,24 +57,22 @@
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#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 } }
%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
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{hint}}
%span.js-filter-tag.dropdown-light-content
{{tag}}
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%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
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
......
......@@ -30,23 +30,22 @@
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options(type) }
#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 } }
%li.filter-dropdown-item
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
%button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{hint}}
%span.js-filter-tag.dropdown-light-content
{{tag}}
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%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
- if current_user
%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';
const tokenKeys = [
{
formattedKey: __('Author'),
key: 'author',
type: 'string',
param: 'username',
......@@ -10,6 +12,7 @@ const tokenKeys = [
tag: '@author',
},
{
formattedKey: __('Milestone'),
key: 'milestone',
type: 'string',
param: 'title',
......@@ -18,6 +21,7 @@ const tokenKeys = [
tag: '%milestone',
},
{
formattedKey: __('Label'),
key: 'label',
type: 'array',
param: 'name[]',
......
......@@ -9,15 +9,30 @@ export default IssuableTokenKeys => {
url: 'approver_usernames[]=None',
tokenKey: 'approver',
value: __('None'),
operator: '=',
},
{
url: 'not[approver_usernames][]=None',
tokenKey: 'approver',
value: __('None'),
operator: '!=',
},
{
url: 'approver_usernames[]=Any',
tokenKey: 'approver',
value: __('Any'),
operator: '=',
},
{
url: 'not[approver_usernames][]=Any',
tokenKey: 'approver',
value: __('Any'),
operator: '!=',
},
];
const approversToken = {
formattedKey: __('Approver'),
key: 'approver',
type: 'array',
param: 'usernames[]',
......
import { __ } from '~/locale';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
const tokenKeys = [
{
formattedKey: __('Author'),
key: 'author',
type: 'string',
param: 'username',
......@@ -10,6 +12,7 @@ const tokenKeys = [
tag: '@author',
},
{
formattedKey: __('Label'),
key: 'label',
type: 'array',
param: 'name[]',
......@@ -21,6 +24,7 @@ const tokenKeys = [
const alternativeTokenKeys = [
{
formattedKey: __('Label'),
key: 'label',
type: 'string',
param: 'name',
......@@ -33,6 +37,13 @@ const conditions = [
url: 'label_name[]=No+Label',
tokenKey: 'label',
value: 'none',
operator: '=',
},
{
url: 'not[label_name][]=No+Label',
tokenKey: 'label',
value: 'none',
operator: '!=',
},
];
......
......@@ -7,6 +7,7 @@ import {
import { __ } from '~/locale';
const weightTokenKey = {
formattedKey: __('Weight'),
key: 'weight',
type: 'string',
param: '',
......@@ -18,11 +19,25 @@ const weightTokenKey = {
const weightConditions = [
{
url: 'weight=None',
operator: '=',
tokenKey: 'weight',
value: __('None'),
},
{
url: 'weight=Any',
operator: '=',
tokenKey: 'weight',
value: __('Any'),
},
{
url: 'not[weight]=None',
operator: '!=',
tokenKey: 'weight',
value: __('None'),
},
{
url: 'not[weight]=Any',
operator: '!=',
tokenKey: 'weight',
value: __('Any'),
},
......
......@@ -42,23 +42,22 @@
%li.input-token
%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
%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 } }
%li.filter-dropdown-item
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
%button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{hint}}
%span.js-filter-tag.dropdown-light-content
{{tag}}
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%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
- if current_user
%ul{ data: { dropdown: true } }
......
......@@ -25,6 +25,6 @@ describe 'Issue Boards add issue modal', :js do
wait_for_requests
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
......@@ -132,8 +132,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click
page.within('#js-dropdown-hint') do
expect(page).to have_content('label')
expect(page).not_to have_content('assignee')
expect(page).to have_content('Label')
expect(page).not_to have_content('Assignee')
end
end
......@@ -164,8 +164,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click
page.within('#js-dropdown-hint') do
expect(page).to have_content('label')
expect(page).not_to have_content('weight')
expect(page).to have_content('Label')
expect(page).not_to have_content('Weight')
end
end
......@@ -244,8 +244,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click
page.within('#js-dropdown-hint') do
expect(page).to have_content('label')
expect(page).not_to have_content('milestone')
expect(page).to have_content('Label')
expect(page).not_to have_content('Milestone')
end
end
end
......@@ -293,7 +293,7 @@ describe 'Scoped issue boards', :js do
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)
end
......@@ -338,8 +338,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click
page.within('#js-dropdown-hint') do
expect(page).to have_content('label')
expect(page).not_to have_content('assignee')
expect(page).to have_content('Label')
expect(page).not_to have_content('Assignee')
end
end
end
......@@ -365,8 +365,8 @@ describe 'Scoped issue boards', :js do
filtered_search.click
page.within('#js-dropdown-hint') do
expect(page).to have_content('label')
expect(page).not_to have_content('weight')
expect(page).to have_content('Label')
expect(page).not_to have_content('Weight')
end
end
end
......
......@@ -24,7 +24,7 @@ describe 'epics list', :js do
context 'editing author token' do
before do
input_filtered_search('author:@root', submit: false)
input_filtered_search('author=@root', submit: false)
first('.tokens-container .filtered-search-token').click
end
......@@ -52,7 +52,7 @@ describe 'epics list', :js do
context 'editing label token' 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
end
......
......@@ -21,7 +21,7 @@ describe 'Dropdown weight', :js do
describe 'behavior' 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)
end
......
......@@ -40,7 +40,7 @@ describe 'Filter issues weight', :js do
describe 'only weight' do
it 'filter issues by searched weight' do
input_filtered_search('weight:1')
input_filtered_search('weight=1')
expect_issues_list_count(1)
end
......@@ -48,7 +48,7 @@ describe 'Filter issues weight', :js do
describe 'weight with other filters' do
it 'filters issues by searched weight and text' do
search = "weight:2 bug"
search = "weight=2 bug"
input_filtered_search(search)
expect_issues_list_count(1)
......@@ -56,7 +56,7 @@ describe 'Filter issues weight', :js do
end
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)
expect_issues_list_count(1)
......@@ -64,7 +64,7 @@ describe 'Filter issues weight', :js do
end
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)
expect_issues_list_count(1)
......@@ -72,7 +72,7 @@ describe 'Filter issues weight', :js do
end
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)
expect_issues_list_count(1)
......@@ -80,7 +80,7 @@ describe 'Filter issues weight', :js do
end
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)
expect_issues_list_count(1)
......
......@@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by approver:none' 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)
......@@ -45,7 +45,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by approver:any' 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)
......@@ -58,7 +58,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by approver:@username' 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)
......@@ -71,7 +71,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by multiple approvers' 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)
......@@ -84,7 +84,7 @@ describe 'Merge Requests > User filters by approvers', :js do
context 'filtering by an approver from a group' 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)
......
......@@ -2,6 +2,7 @@ import IssuableFilteredSearchTokenKeys from 'ee/filtered_search/issuable_filtere
describe('Issues Filtered Search Token Keys (EE)', () => {
const weightTokenKey = {
formattedKey: 'Weight',
key: 'weight',
type: 'string',
param: '',
......@@ -41,7 +42,7 @@ describe('Issues Filtered Search Token Keys (EE)', () => {
it('should return weightConditions as part of conditions', () => {
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)', () => {
const weightConditions = conditions.filter(c => c.tokenKey === 'weight');
const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(
weightConditions[0].tokenKey,
weightConditions[0].operator,
weightConditions[0].value,
);
......
......@@ -2032,6 +2032,9 @@ msgstr ""
msgid "Approved the current merge request."
msgstr ""
msgid "Approver"
msgstr ""
msgid "Apr"
msgstr ""
......@@ -9942,6 +9945,12 @@ msgstr ""
msgid "Invocations"
msgstr ""
msgid "Is"
msgstr ""
msgid "Is not"
msgstr ""
msgid "Is using license seat:"
msgstr ""
......@@ -11672,6 +11681,9 @@ msgstr ""
msgid "Multiple uploaders found: %{uploader_types}"
msgstr ""
msgid "My-Reaction"
msgstr ""
msgid "Name"
msgstr ""
......@@ -13299,9 +13311,6 @@ msgstr ""
msgid "Press %{key}-C to copy"
msgstr ""
msgid "Press Enter or click to search"
msgstr ""
msgid "Prevent adding new members to project membership within this group"
msgstr ""
......@@ -15706,6 +15715,9 @@ msgstr ""
msgid "Search for projects, issues, etc."
msgstr ""
msgid "Search for this text"
msgstr ""
msgid "Search forks"
msgstr ""
......@@ -17713,6 +17725,9 @@ msgstr ""
msgid "Target branch"
msgstr ""
msgid "Target-Branch"
msgstr ""
msgid "Team"
msgstr ""
......@@ -20279,6 +20294,9 @@ msgstr ""
msgid "Vulnerability|Severity"
msgstr ""
msgid "WIP"
msgstr ""
msgid "Wait for the file to load to copy its contents"
msgstr ""
......
......@@ -57,7 +57,7 @@ describe "Admin Runners" do
expect(page).to have_content 'runner-active'
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).not_to have_content 'runner-paused'
end
......@@ -68,7 +68,7 @@ describe "Admin Runners" do
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-paused'
......@@ -83,12 +83,12 @@ describe "Admin Runners" do
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-b-1'
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).not_to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
......@@ -105,7 +105,7 @@ describe "Admin Runners" do
expect(page).to have_content 'runner-project'
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).not_to have_content 'runner-group'
end
......@@ -116,7 +116,7 @@ describe "Admin Runners" do
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-group'
......@@ -131,12 +131,12 @@ describe "Admin Runners" do
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-b-1'
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).not_to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
......@@ -153,7 +153,7 @@ describe "Admin Runners" do
expect(page).to have_content 'runner-blue'
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).not_to have_content 'runner-red'
......@@ -165,7 +165,7 @@ describe "Admin Runners" do
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'
......@@ -179,13 +179,13 @@ describe "Admin Runners" do
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-b-1'
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).not_to have_content 'runner-b-1'
......
......@@ -628,7 +628,7 @@ describe 'Issue Boards', :js do
end
def set_filter(type, text)
find('.filtered-search').native.send_keys("#{type}:#{text}")
find('.filtered-search').native.send_keys("#{type}=#{text}")
end
def submit_filter
......
......@@ -211,7 +211,7 @@ describe 'Issue Boards add issue modal filtering', :js do
end
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
def submit_filter
......
......@@ -28,14 +28,14 @@ describe 'Dashboard Issues filtering', :js do
context 'filtering by 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_selector('.issue', count: 1)
end
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_selector('.issue', count: 1)
......@@ -63,7 +63,7 @@ describe 'Dashboard Issues filtering', :js do
let!(:label_link) { create(:label_link, label: label, target: issue) }
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
expect(page).to have_content issue.title
......
......@@ -30,7 +30,7 @@ RSpec.describe 'Dashboard Issues' do
it 'shows issues when current user is author', :js do
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_on_public_project.title)
......
......@@ -107,7 +107,7 @@ describe 'Dashboard Merge Requests' do
it 'shows authored merge requests', :js do
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_from_fork.title)
......@@ -120,7 +120,7 @@ describe 'Dashboard Merge Requests' do
it 'shows labeled merge requests', :js do
reset_filters
input_filtered_search("label:#{label.name}")
input_filtered_search("label=#{label.name}")
expect(page).to have_content(labeled_merge_request.title)
......
......@@ -48,7 +48,7 @@ describe 'Group issues page' do
let(:user2) { user_outside_group }
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')).not_to have_content(user2.name)
......
......@@ -52,7 +52,7 @@ describe 'Group merge requests page' do
let(:user2) { user_outside_group }
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')).not_to have_content(user2.name)
......
......@@ -20,13 +20,13 @@ describe 'Dropdown assignee', :js do
describe 'behavior' 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)
end
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)
end
......@@ -35,7 +35,7 @@ describe 'Dropdown assignee', :js do
describe 'selecting from dropdown without Ajax call' do
before do
Gitlab::Testing::RequestBlockerMiddleware.block_requests!
input_filtered_search('assignee:', submit: false, extra_space: false)
input_filtered_search('assignee=', submit: false, extra_space: false)
end
after do
......
......@@ -20,13 +20,13 @@ describe 'Dropdown author', :js do
describe 'behavior' 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)
end
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)
end
......@@ -35,7 +35,7 @@ describe 'Dropdown author', :js do
describe 'selecting from dropdown without Ajax call' do
before do
Gitlab::Testing::RequestBlockerMiddleware.block_requests!
input_filtered_search('author:', submit: false, extra_space: false)
input_filtered_search('author=', submit: false, extra_space: false)
end
after do
......
......@@ -27,14 +27,14 @@ describe 'Dropdown base', :js do
it 'shows loading indicator when opened' do
slow_requests do
# 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)
end
end
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')
end
......@@ -42,7 +42,7 @@ describe 'Dropdown base', :js do
describe 'caching requests' 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
expect(initial_size).to be > 0
......@@ -50,7 +50,7 @@ describe 'Dropdown base', :js do
new_user = create(:user)
project.add_maintainer(new_user)
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)
end
......
......@@ -26,8 +26,8 @@ describe 'Dropdown emoji', :js do
end
describe 'behavior' do
it 'does not open when the search bar has my-reaction:' do
filtered_search.set('my-reaction:')
it 'does not open when the search bar has my-reaction=' do
filtered_search.set('my-reaction=')
expect(page).not_to have_css(js_dropdown_emoji)
end
......@@ -42,20 +42,20 @@ describe 'Dropdown emoji', :js do
end
describe 'behavior' do
it 'opens when the search bar has my-reaction:' do
filtered_search.set('my-reaction:')
it 'opens when the search bar has my-reaction=' do
filtered_search.set('my-reaction=')
expect(page).to have_css(js_dropdown_emoji, visible: true)
end
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)
end
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)
end
......
......@@ -9,11 +9,16 @@ describe 'Dropdown hint', :js do
let!(:user) { create(:user) }
let(:filtered_search) { find('.filtered-search') }
let(:js_dropdown_hint) { '#js-dropdown-hint' }
let(:js_dropdown_operator) { '#js-dropdown-operator' }
def click_hint(text)
find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click
end
def click_operator(op)
find("#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value='#{op}']").click
end
before do
project.add_maintainer(user)
create(:issue, project: project)
......@@ -27,7 +32,7 @@ describe 'Dropdown hint', :js do
it 'does not exist my-reaction dropdown item' do
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
......@@ -54,15 +59,6 @@ describe 'Dropdown hint', :js do
end
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
filtered_search.set('a')
......@@ -76,21 +72,27 @@ describe 'Dropdown hint', :js do
end
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_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_tokens([{ name: 'Author' }])
expect_tokens([{ name: 'Author', operator: '=' }])
expect_filtered_search_input_empty
end
end
describe 'reselecting from dropdown' 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)
click_hint('author')
click_hint('Author')
expect_tokens([{ name: 'Author' }])
expect_filtered_search_input_empty
......
......@@ -21,7 +21,7 @@ describe 'Dropdown label', :js do
describe 'behavior' do
it 'loads all the labels when opened' do
create(:label, project: project, title: 'bug-label')
filtered_search.set('label:')
filtered_search.set('label=')
expect_filtered_search_dropdown_results(filter_dropdown, 1)
end
......
......@@ -23,7 +23,7 @@ describe 'Dropdown milestone', :js do
describe 'behavior' do
before do
filtered_search.set('milestone:')
filtered_search.set('milestone=')
end
it 'loads all the milestones when opened' do
......
......@@ -23,7 +23,7 @@ describe 'Dropdown release', :js do
describe 'behavior' do
before do
filtered_search.set('release:')
filtered_search.set('release=')
end
it 'loads all the releases when opened' do
......
......@@ -41,8 +41,8 @@ describe 'Recent searches', :js do
items = all('.filtered-search-history-dropdown-item', visible: false, count: 2)
expect(items[0].text).to eq('label: ~qux garply')
expect(items[1].text).to eq('label: ~foo bar')
expect(items[0].text).to eq('label: = ~qux garply')
expect(items[1].text).to eq('label: = ~foo bar')
end
it 'saved recent searches are restored last on the list' do
......
......@@ -34,7 +34,7 @@ describe 'Search bar', :js do
it 'selects item' do
filtered_search.native.send_keys(:down, :down, :enter)
expect_tokens([author_token])
expect_tokens([{ name: 'Assignee' }])
expect_filtered_search_input_empty
end
end
......@@ -78,7 +78,7 @@ describe 'Search bar', :js do
filtered_search.click
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)
......
......@@ -36,8 +36,9 @@ describe 'Visual tokens', :js do
describe 'editing a single token' 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
wait_for_requests
end
it 'opens author dropdown' do
......@@ -76,8 +77,8 @@ describe 'Visual tokens', :js do
describe 'editing multiple tokens' do
before do
input_filtered_search('author:@root assignee:none', submit: false)
first('.tokens-container .filtered-search-token').double_click
input_filtered_search('author=@root assignee=none', submit: false)
first('.tokens-container .filtered-search-token').click
end
it 'opens author dropdown' do
......@@ -85,27 +86,33 @@ describe 'Visual tokens', :js do
end
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)
end
end
describe 'editing a search term while editing another filter token' do
before do
input_filtered_search('author assignee:', submit: false)
first('.tokens-container .filtered-search-term').double_click
input_filtered_search('foo assignee=', submit: false)
first('.tokens-container .filtered-search-term').click
end
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)
end
end
describe 'add new token after editing existing token' 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
filtered_search.send_keys(' ')
end
......@@ -116,7 +123,7 @@ describe 'Visual tokens', :js do
end
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)
end
......@@ -124,7 +131,7 @@ describe 'Visual tokens', :js do
describe 'visual tokens' 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]
expect(token.find('.name').text).to eq('Author')
......@@ -133,7 +140,7 @@ describe 'Visual tokens', :js do
end
it 'does not tokenize incomplete token' do
filtered_search.send_keys('author:')
filtered_search.send_keys('author=')
find('body').click
token = page.all('.tokens-container .js-visual-token')[1]
......@@ -145,7 +152,7 @@ describe 'Visual tokens', :js do
describe 'search using incomplete visual tokens' do
before do
input_filtered_search('author:@root assignee:none', extra_space: false)
input_filtered_search('author=@root assignee=none', extra_space: false)
end
it 'tokenizes the search term to complete visual token' do
......
......@@ -70,7 +70,7 @@ describe 'Labels Hierarchy', :js do
end
it 'does not filter by descendant group labels' do
filtered_search.set("label:")
filtered_search.set("label=")
wait_for_requests
......@@ -134,7 +134,7 @@ describe 'Labels Hierarchy', :js do
end
it 'does not filter by descendant group project labels' do
filtered_search.set("label:")
filtered_search.set("label=")
wait_for_requests
......@@ -227,7 +227,7 @@ describe 'Labels Hierarchy', :js do
it_behaves_like 'filtering by ancestor labels for projects'
it 'does not filter by descendant group labels' do
filtered_search.set("label:")
filtered_search.set("label=")
wait_for_requests
......
......@@ -23,7 +23,7 @@ describe 'Merge Requests > Filters generic behavior', :js do
context 'when filtered by a label' do
before do
input_filtered_search('label:~bug')
input_filtered_search('label=~bug')
end
describe 'state tabs' do
......
......@@ -18,7 +18,7 @@ describe 'Merge Requests > User filters by assignees', :js do
context 'filtering by assignee:none' 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).not_to have_content 'Bugfix1'
......@@ -26,9 +26,9 @@ describe 'Merge Requests > User filters by assignees', :js do
end
end
context 'filtering by assignee:@username' do
context 'filtering by assignee=@username' 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_content 'Bugfix1'
......
......@@ -22,7 +22,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:none' 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).not_to have_content 'Bugfix1'
......@@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:~enhancement' 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_content 'Bugfix2'
......@@ -42,7 +42,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:~enhancement and label:~bug' 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_content 'Bugfix2'
......
......@@ -18,14 +18,14 @@ describe 'Merge Requests > User filters by milestones', :js do
end
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_css('.merge-request', count: 1)
end
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_css('.merge-request', count: 1)
......@@ -33,7 +33,7 @@ describe 'Merge Requests > User filters by milestones', :js do
describe 'filters by upcoming milestone' 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_css('.merge-request', count: 0)
......@@ -43,7 +43,7 @@ describe 'Merge Requests > User filters by milestones', :js do
let(:milestone) { create(:milestone, project: project, due_date: Date.tomorrow) }
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_css('.merge-request', count: 1)
......@@ -54,7 +54,7 @@ describe 'Merge Requests > User filters by milestones', :js do
let(:milestone) { create(:milestone, project: project, due_date: Date.yesterday) }
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_css('.merge-request', count: 0)
......
......@@ -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
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_content 'Bugfix2'
......@@ -30,7 +30,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do
describe 'filtering 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_content 'Bugfix2'
......
......@@ -17,7 +17,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:master' 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_content mr1.title
......@@ -27,7 +27,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:merged-target' 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).not_to have_content mr1.title
......@@ -37,7 +37,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:feature' 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).not_to have_content mr1.title
......
......@@ -124,6 +124,7 @@ describe('Filtered Search Token Keys', () => {
const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue(
null,
null,
null,
);
expect(condition).toBeNull();
......@@ -132,6 +133,7 @@ describe('Filtered Search Token Keys', () => {
it('should return condition when found by tokenKey and value', () => {
const result = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue(
conditions[0].tokenKey,
conditions[0].operator,
conditions[0].value,
);
......
......@@ -398,14 +398,21 @@ describe('DropLab DropDown', function() {
describe('render', function() {
beforeEach(function() {
this.list = { querySelector: () => {}, dispatchEvent: () => {} };
this.dropdown = { renderChildren: () => {}, list: this.list };
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.customEvent = {};
spyOn(this.dropdown, 'renderChildren').and.callFake(data => data);
spyOn(this.list, 'querySelector').and.returnValue(this.renderableList);
spyOn(this.list, 'dispatchEvent');
spyOn(this.data, 'map').and.callThrough();
spyOn(window, 'CustomEvent').and.returnValue(this.customEvent);
......
......@@ -222,7 +222,7 @@ describe('Dropdown Utils', () => {
hasAttribute: () => false,
};
DropdownUtils.setDataValueIfSelected(null, selected);
DropdownUtils.setDataValueIfSelected(null, '=', selected);
expect(FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
});
......@@ -233,9 +233,11 @@ describe('Dropdown Utils', () => {
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(result2).toBe(true);
});
it('returns false when dataValue does not exist', () => {
......@@ -243,9 +245,11 @@ describe('Dropdown Utils', () => {
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(result2).toBe(false);
});
});
......@@ -349,7 +353,7 @@ describe('Dropdown Utils', () => {
beforeEach(() => {
loadFixtures(issueListFixture);
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
const tokensContainer = document.querySelector('.tokens-container');
......@@ -364,7 +368,7 @@ describe('Dropdown Utils', () => {
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', () => {
describe('input has no existing value', () => {
it('should add just tokenName', () => {
FilteredSearchDropdownManager.addWordToInput('milestone');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'milestone' });
const token = document.querySelector('.tokens-container .js-visual-token');
......@@ -36,8 +36,8 @@ describe('Filtered Search Dropdown Manager', () => {
expect(getInputValue()).toBe('');
});
it('should add tokenName and tokenValue', () => {
FilteredSearchDropdownManager.addWordToInput('label');
it('should add tokenName, tokenOperator, and tokenValue', () => {
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' });
let token = document.querySelector('.tokens-container .js-visual-token');
......@@ -45,13 +45,27 @@ describe('Filtered Search Dropdown Manager', () => {
expect(token.querySelector('.name').innerText).toBe('label');
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
// Because FilteredSearchDropdownManager deletes the previous token
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(token.querySelector('.value').innerText).toBe('none');
expect(getInputValue()).toBe('');
});
......@@ -60,7 +74,7 @@ describe('Filtered Search Dropdown Manager', () => {
describe('input has existing value', () => {
it('should be able to just add tokenName', () => {
setInputValue('a');
FilteredSearchDropdownManager.addWordToInput('author');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' });
const token = document.querySelector('.tokens-container .js-visual-token');
......@@ -70,29 +84,40 @@ describe('Filtered Search Dropdown Manager', () => {
});
it('should replace tokenValue', () => {
FilteredSearchDropdownManager.addWordToInput('author');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' });
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author', tokenOperator: '=' });
setInputValue('roo');
FilteredSearchDropdownManager.addWordToInput(null, '@root');
FilteredSearchDropdownManager.addWordToInput({
tokenName: null,
tokenOperator: '=',
tokenValue: '@root',
});
const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('author');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(token.querySelector('.value').innerText).toBe('@root');
expect(getInputValue()).toBe('');
});
it('should add tokenValues containing spaces', () => {
FilteredSearchDropdownManager.addWordToInput('label');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' });
setInputValue('"test ');
FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\'');
FilteredSearchDropdownManager.addWordToInput({
tokenName: 'label',
tokenOperator: '=',
tokenValue: '~\'"test me"\'',
});
const 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(token.querySelector('.value').innerText).toBe('~\'"test me"\'');
expect(getInputValue()).toBe('');
});
......
......@@ -201,8 +201,8 @@ describe('Filtered Search Manager', function() {
it('removes duplicated tokens', done => {
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 => {
......@@ -234,7 +234,7 @@ describe('Filtered Search Manager', function() {
it('should not render placeholder when there are tokens and no input', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
);
const event = new Event('input');
......@@ -252,7 +252,7 @@ describe('Filtered Search Manager', function() {
describe('tokens and no input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
);
});
......@@ -306,7 +306,7 @@ describe('Filtered Search Manager', function() {
it('removes token even when it is already selected', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
);
tokensContainer.querySelector('.js-visual-token .remove-token').click();
......@@ -319,7 +319,7 @@ describe('Filtered Search Manager', function() {
spyOn(FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'),
);
tokensContainer.querySelector('.js-visual-token .remove-token').click();
});
......@@ -338,7 +338,7 @@ describe('Filtered Search Manager', function() {
beforeEach(() => {
initializeManager();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
);
});
......@@ -424,7 +424,7 @@ describe('Filtered Search Manager', function() {
});
it('Clicking the "x" clear button, clears the input', () => {
const inputValue = 'label:~bug ';
const inputValue = 'label:=~bug';
manager.filteredSearchInput.value = inputValue;
manager.filteredSearchInput.dispatchEvent(new Event('input'));
......
......@@ -138,6 +138,7 @@ describe('Issues Filtered Search Token Keys', () => {
const conditions = IssuableFilteredSearchTokenKeys.getConditions();
const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(
conditions[0].tokenKey,
conditions[0].operator,
conditions[0].value,
);
......
......@@ -10,9 +10,11 @@ describe('Filtered Search Visual Tokens', () => {
const tokenNameElement = tokenElement.querySelector('.name');
const tokenValueContainer = tokenElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
const tokenOperatorElement = tokenElement.querySelector('.operator');
const tokenType = tokenNameElement.innerText.toLowerCase();
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 };
};
......@@ -28,8 +30,8 @@ describe('Filtered Search Visual Tokens', () => {
`);
tokensContainer = document.querySelector('.tokens-container');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
});
describe('updateUserTokenAppearance', () => {
......@@ -140,10 +142,12 @@ describe('Filtered Search Visual Tokens', () => {
const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label',
'=',
'~doesnotexist',
);
const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label',
'=',
'~"some space"',
);
......
export default class FilteredSearchSpecHelper {
static createFilterVisualTokenHTML(name, value, isSelected) {
return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML;
static createFilterVisualTokenHTML(name, operator, value, isSelected) {
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');
li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
li.innerHTML = `
<div class="selectable ${isSelected ? 'selected' : ''}" role="button">
<div class="name">${name}</div>
<div class="operator">${operator}</div>
<div class="value-container">
<div class="value">${value}</div>
<div class="remove-token" role="button">
......@@ -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) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-term');
......
......@@ -26,7 +26,7 @@ module FilteredSearchHelpers
# Select a label clicking in the search dropdown instead
# of entering label names on the input.
def select_label_on_dropdown(label_title)
input_filtered_search("label:", submit: false)
input_filtered_search("label=", submit: false)
within('#js-dropdown-label') do
wait_for_requests
......@@ -71,7 +71,7 @@ module FilteredSearchHelpers
end
def init_label_search
filtered_search.set('label:')
filtered_search.set('label=')
# This ensures the dropdown is shown
expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading')
end
......@@ -90,6 +90,7 @@ module FilteredSearchHelpers
el = token_elements[index]
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?
# gl-emoji content is blank when the emoji unicode is not supported
......@@ -101,8 +102,8 @@ module FilteredSearchHelpers
end
end
def create_token(token_name, token_value = nil, symbol = nil)
{ name: token_name, value: "#{symbol}#{token_value}" }
def create_token(token_name, token_value = nil, symbol = nil, token_operator = '=')
{ name: token_name, operator: token_operator, value: "#{symbol}#{token_value}" }
end
def author_token(author_name = nil)
......@@ -113,9 +114,9 @@ module FilteredSearchHelpers
create_token('Assignee', assignee_name)
end
def milestone_token(milestone_name = nil, has_symbol = true)
def milestone_token(milestone_name = nil, has_symbol = true, operator = '=')
symbol = has_symbol ? '%' : nil
create_token('Milestone', milestone_name, symbol)
create_token('Milestone', milestone_name, symbol, operator)
end
def release_token(release_tag = nil)
......
......@@ -13,7 +13,7 @@ shared_examples 'issuable user dropdown behaviors' do
it 'only includes members of the project/group' do
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")).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