Commit a72a9af0 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent b085478c
...@@ -20,4 +20,4 @@ schedule:package-and-qa:notify-failure: ...@@ -20,4 +20,4 @@ schedule:package-and-qa:notify-failure:
- 'notify_on_job_failure schedule:package-and-qa qa-master "${NOTIFICATION_MESSAGE}" ci_failing' - 'notify_on_job_failure schedule:package-and-qa qa-master "${NOTIFICATION_MESSAGE}" ci_failing'
needs: ["schedule:package-and-qa"] needs: ["schedule:package-and-qa"]
allow_failure: true allow_failure: true
when: always when: manual # TODO: remove notify job if not necessary
...@@ -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, {
FilteredSearchDropdownManager.addWordToInput({
tokenName: key,
clicked: false,
options: {
uppercaseTokenName, 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 = [
{
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}`, icon: `${gon.sprite_icons}#${tokenKey.icon}`,
hint: tokenKey.key, hint: tokenKey.key,
tag: `:${tokenKey.tag}`, tag: `:${tokenKey.tag}`,
type: tokenKey.type, 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({
tokenName: filter,
tokenOperator: operator,
tokenValue: dataValue,
clicked: true,
options: {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'), 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);
} }
} }
......
...@@ -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,
......
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,7 +73,8 @@ export const alternativeTokenKeys = [ ...@@ -65,7 +73,8 @@ export const alternativeTokenKeys = [
}, },
]; ];
export const conditions = [ export const conditions = flatten(
[
{ {
url: 'assignee_id=None', url: 'assignee_id=None',
tokenKey: 'assignee', tokenKey: 'assignee',
...@@ -126,7 +135,26 @@ export const conditions = [ ...@@ -126,7 +135,26 @@ export const conditions = [
tokenKey: 'my-reaction', tokenKey: 'my-reaction',
value: __('Any'), 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
......
# frozen_string_literal: true
class ResourceWeightEvent < ApplicationRecord
include Gitlab::Utils::StrongMemoize
validates :user, presence: true
validates :issue, presence: true
belongs_to :user
belongs_to :issue
scope :by_issue, ->(issue) { where(issue_id: issue.id) }
scope :created_after, ->(time) { where('created_at > ?', time) }
def discussion_id(resource = nil)
strong_memoize(:discussion_id) do
Digest::SHA1.hexdigest(discussion_id_key.join("-"))
end
end
private
def discussion_id_key
[self.class.name, created_at, user_id]
end
end
...@@ -34,7 +34,7 @@ module Ci ...@@ -34,7 +34,7 @@ module Ci
def refspecs def refspecs
specs = [] specs = []
specs << refspec_for_pipeline_ref if merge_request_ref? specs << refspec_for_pipeline_ref if should_expose_merge_request_ref?
specs << refspec_for_persistent_ref if persistent_ref_exist? specs << refspec_for_persistent_ref if persistent_ref_exist?
if git_depth > 0 if git_depth > 0
...@@ -50,6 +50,19 @@ module Ci ...@@ -50,6 +50,19 @@ module Ci
private private
# We will stop exposing merge request refs when we fully depend on persistent refs
# (i.e. remove `refspec_for_pipeline_ref` when we remove `depend_on_persistent_pipeline_ref` feature flag.)
# `ci_force_exposing_merge_request_refs` is an extra feature flag that allows us to
# forcibly expose MR refs even if the `depend_on_persistent_pipeline_ref` feature flag enabled.
# This is useful when we see an unexpected behaviors/reports from users.
# See https://gitlab.com/gitlab-org/gitlab/issues/35140.
def should_expose_merge_request_ref?
return false unless merge_request_ref?
return true if Feature.enabled?(:ci_force_exposing_merge_request_refs, project)
Feature.disabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true)
end
def create_archive(artifacts) def create_archive(artifacts)
return unless artifacts[:untracked] || artifacts[:paths] return unless artifacts[:untracked] || artifacts[:paths]
......
...@@ -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
......
# frozen_string_literal: true
# We store events about issuable label changes and weight changes in a separate
# table (not as other system notes), but we still want to display notes about
# label changes and weight changes as classic system notes in UI. This service
# generates "synthetic" notes for label event changes.
module ResourceEvents
class BaseSyntheticNotesBuilderService
include Gitlab::Utils::StrongMemoize
attr_reader :resource, :current_user, :params
def initialize(resource, current_user, params = {})
@resource = resource
@current_user = current_user
@params = params
end
def execute
synthetic_notes
end
private
def since_fetch_at(events)
return events unless params[:last_fetched_at].present?
last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i)
events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
end
def resource_parent
strong_memoize(:resource_parent) do
resource.project || resource.group
end
end
end
end
# frozen_string_literal: true # frozen_string_literal: true
# We store events about issuable label changes in a separate table (not as # We store events about issuable label changes and weight changes in separate tables (not as
# other system notes), but we still want to display notes about label changes # other system notes), but we still want to display notes about label and weight changes
# as classic system notes in UI. This service generates "synthetic" notes for # as classic system notes in UI. This service merges synthetic label and weight notes
# label event changes and merges them with classic notes and sorts them by # with classic notes and sorts them by creation time.
# creation time.
module ResourceEvents module ResourceEvents
class MergeIntoNotesService class MergeIntoNotesService
...@@ -19,39 +18,15 @@ module ResourceEvents ...@@ -19,39 +18,15 @@ module ResourceEvents
end end
def execute(notes = []) def execute(notes = [])
(notes + label_notes).sort_by { |n| n.created_at } (notes + synthetic_notes).sort_by { |n| n.created_at }
end end
private private
def label_notes def synthetic_notes
label_events_by_discussion_id.map do |discussion_id, events| SyntheticLabelNotesBuilderService.new(resource, current_user, params).execute
LabelNote.from_events(events, resource: resource, resource_parent: resource_parent)
end
end
# rubocop: disable CodeReuse/ActiveRecord
def label_events_by_discussion_id
return [] unless resource.respond_to?(:resource_label_events)
events = resource.resource_label_events.includes(:label, user: :status)
events = since_fetch_at(events)
events.group_by { |event| event.discussion_id }
end
# rubocop: enable CodeReuse/ActiveRecord
def since_fetch_at(events)
return events unless params[:last_fetched_at].present?
last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i)
events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
end
def resource_parent
strong_memoize(:resource_parent) do
resource.project || resource.group
end
end end
end end
end end
ResourceEvents::MergeIntoNotesService.prepend_if_ee('EE::ResourceEvents::MergeIntoNotesService')
# frozen_string_literal: true
# We store events about issuable label changes in a separate table (not as
# other system notes), but we still want to display notes about label changes
# as classic system notes in UI. This service generates "synthetic" notes for
# label event changes.
module ResourceEvents
class SyntheticLabelNotesBuilderService < BaseSyntheticNotesBuilderService
private
def synthetic_notes
label_events_by_discussion_id.map do |discussion_id, events|
LabelNote.from_events(events, resource: resource, resource_parent: resource_parent)
end
end
def label_events_by_discussion_id
return [] unless resource.respond_to?(:resource_label_events)
events = resource.resource_label_events.includes(:label, user: :status) # rubocop: disable CodeReuse/ActiveRecord
events = since_fetch_at(events)
events.group_by { |event| event.discussion_id }
end
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
---
title: Drop redundant index on ci_pipelines.project_id
merge_request: 22325
author:
type: other
---
title: Stop exposing MR refs in favor of persistent pipeline refs
merge_request: 22198
author:
type: fixed
# frozen_string_literal: true
class CreateResourceWeightEvent < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
create_table :resource_weight_events do |t|
t.references :user, null: false, foreign_key: { on_delete: :nullify },
index: { name: 'index_resource_weight_events_on_user_id' }
t.references :issue, null: false, foreign_key: { on_delete: :cascade },
index: false
t.integer :weight
t.datetime_with_timezone :created_at, null: false
t.index [:issue_id, :weight], name: 'index_resource_weight_events_on_issue_id_and_weight'
end
end
end
# frozen_string_literal: true
class DropIndexCiPipelinesOnProjectId < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
remove_concurrent_index :ci_pipelines, :project_id
end
def down
add_concurrent_index :ci_pipelines, :project_id
end
end
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_12_18_225624) do ActiveRecord::Schema.define(version: 2019_12_29_140154) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm" enable_extension "pg_trgm"
...@@ -871,7 +871,6 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do ...@@ -871,7 +871,6 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do
t.index ["project_id", "source"], name: "index_ci_pipelines_on_project_id_and_source" t.index ["project_id", "source"], name: "index_ci_pipelines_on_project_id_and_source"
t.index ["project_id", "status", "config_source"], name: "index_ci_pipelines_on_project_id_and_status_and_config_source" t.index ["project_id", "status", "config_source"], name: "index_ci_pipelines_on_project_id_and_status_and_config_source"
t.index ["project_id", "status", "updated_at"], name: "index_ci_pipelines_on_project_id_and_status_and_updated_at" t.index ["project_id", "status", "updated_at"], name: "index_ci_pipelines_on_project_id_and_status_and_updated_at"
t.index ["project_id"], name: "index_ci_pipelines_on_project_id"
t.index ["status"], name: "index_ci_pipelines_on_status" t.index ["status"], name: "index_ci_pipelines_on_status"
t.index ["user_id"], name: "index_ci_pipelines_on_user_id" t.index ["user_id"], name: "index_ci_pipelines_on_user_id"
end end
...@@ -3605,6 +3604,15 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do ...@@ -3605,6 +3604,15 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do
t.index ["user_id"], name: "index_resource_label_events_on_user_id" t.index ["user_id"], name: "index_resource_label_events_on_user_id"
end end
create_table "resource_weight_events", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "issue_id", null: false
t.integer "weight"
t.datetime_with_timezone "created_at", null: false
t.index ["issue_id", "weight"], name: "index_resource_weight_events_on_issue_id_and_weight"
t.index ["user_id"], name: "index_resource_weight_events_on_user_id"
end
create_table "reviews", force: :cascade do |t| create_table "reviews", force: :cascade do |t|
t.integer "author_id" t.integer "author_id"
t.integer "merge_request_id", null: false t.integer "merge_request_id", null: false
...@@ -4745,6 +4753,8 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do ...@@ -4745,6 +4753,8 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do
add_foreign_key "resource_label_events", "labels", on_delete: :nullify add_foreign_key "resource_label_events", "labels", on_delete: :nullify
add_foreign_key "resource_label_events", "merge_requests", on_delete: :cascade add_foreign_key "resource_label_events", "merge_requests", on_delete: :cascade
add_foreign_key "resource_label_events", "users", on_delete: :nullify add_foreign_key "resource_label_events", "users", on_delete: :nullify
add_foreign_key "resource_weight_events", "issues", on_delete: :cascade
add_foreign_key "resource_weight_events", "users", on_delete: :nullify
add_foreign_key "reviews", "merge_requests", on_delete: :cascade add_foreign_key "reviews", "merge_requests", on_delete: :cascade
add_foreign_key "reviews", "projects", on_delete: :cascade add_foreign_key "reviews", "projects", on_delete: :cascade
add_foreign_key "reviews", "users", column: "author_id", on_delete: :nullify add_foreign_key "reviews", "users", column: "author_id", on_delete: :nullify
......
...@@ -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 ""
...@@ -9948,6 +9951,12 @@ msgstr "" ...@@ -9948,6 +9951,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 ""
...@@ -11678,6 +11687,9 @@ msgstr "" ...@@ -11678,6 +11687,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 ""
...@@ -13305,9 +13317,6 @@ msgstr "" ...@@ -13305,9 +13317,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 ""
...@@ -15712,6 +15721,9 @@ msgstr "" ...@@ -15712,6 +15721,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 ""
...@@ -17722,6 +17734,9 @@ msgstr "" ...@@ -17722,6 +17734,9 @@ msgstr ""
msgid "Target branch" msgid "Target branch"
msgstr "" msgstr ""
msgid "Target-Branch"
msgstr ""
msgid "Team" msgid "Team"
msgstr "" msgstr ""
...@@ -20291,6 +20306,9 @@ msgstr "" ...@@ -20291,6 +20306,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 ""
......
...@@ -490,6 +490,7 @@ module QA ...@@ -490,6 +490,7 @@ module QA
autoload :Dates, 'qa/support/dates' autoload :Dates, 'qa/support/dates'
autoload :Waiter, 'qa/support/waiter' autoload :Waiter, 'qa/support/waiter'
autoload :Retrier, 'qa/support/retrier' autoload :Retrier, 'qa/support/retrier'
autoload :WaitForRequests, 'qa/support/wait_for_requests'
end end
end end
......
...@@ -8,6 +8,7 @@ module QA ...@@ -8,6 +8,7 @@ module QA
prepend Support::Page::Logging if Runtime::Env.debug? prepend Support::Page::Logging if Runtime::Env.debug?
include Capybara::DSL include Capybara::DSL
include Scenario::Actable include Scenario::Actable
include Support::WaitForRequests
extend Validatable extend Validatable
extend SingleForwardable extend SingleForwardable
...@@ -21,6 +22,8 @@ module QA ...@@ -21,6 +22,8 @@ module QA
def refresh def refresh
page.refresh page.refresh
wait_for_requests
end end
def wait(max: 60, interval: 0.1, reload: true) def wait(max: 60, interval: 0.1, reload: true)
...@@ -42,6 +45,8 @@ module QA ...@@ -42,6 +45,8 @@ module QA
end end
def scroll_to(selector, text: nil) def scroll_to(selector, text: nil)
wait_for_requests
page.execute_script <<~JS page.execute_script <<~JS
var elements = Array.from(document.querySelectorAll('#{selector}')); var elements = Array.from(document.querySelectorAll('#{selector}'));
var text = '#{text}'; var text = '#{text}';
...@@ -74,6 +79,8 @@ module QA ...@@ -74,6 +79,8 @@ module QA
end end
def find_element(name, **kwargs) def find_element(name, **kwargs)
wait_for_requests
find(element_selector_css(name), kwargs) find(element_selector_css(name), kwargs)
end end
...@@ -82,6 +89,8 @@ module QA ...@@ -82,6 +89,8 @@ module QA
end end
def all_elements(name, **kwargs) def all_elements(name, **kwargs)
wait_for_requests
all(element_selector_css(name), **kwargs) all(element_selector_css(name), **kwargs)
end end
...@@ -120,6 +129,8 @@ module QA ...@@ -120,6 +129,8 @@ module QA
end end
def has_element?(name, **kwargs) def has_element?(name, **kwargs)
wait_for_requests
wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time
text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil
...@@ -127,6 +138,8 @@ module QA ...@@ -127,6 +138,8 @@ module QA
end end
def has_no_element?(name, **kwargs) def has_no_element?(name, **kwargs)
wait_for_requests
wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time
text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil
...@@ -134,18 +147,24 @@ module QA ...@@ -134,18 +147,24 @@ module QA
end end
def has_text?(text, wait: Capybara.default_max_wait_time) def has_text?(text, wait: Capybara.default_max_wait_time)
wait_for_requests
page.has_text?(text, wait: wait) page.has_text?(text, wait: wait)
end end
def has_no_text?(text) def has_no_text?(text)
wait_for_requests
page.has_no_text? text page.has_no_text? text
end end
def has_normalized_ws_text?(text, wait: Capybara.default_max_wait_time) def has_normalized_ws_text?(text, wait: Capybara.default_max_wait_time)
page.has_text?(text.gsub(/\s+/, " "), wait: wait) has_text?(text.gsub(/\s+/, " "), wait: wait)
end end
def finished_loading? def finished_loading?
wait_for_requests
# The number of selectors should be able to be reduced after # The number of selectors should be able to be reduced after
# migration to the new spinner is complete. # migration to the new spinner is complete.
# https://gitlab.com/groups/gitlab-org/-/epics/956 # https://gitlab.com/groups/gitlab-org/-/epics/956
...@@ -153,6 +172,8 @@ module QA ...@@ -153,6 +172,8 @@ module QA
end end
def finished_loading_block? def finished_loading_block?
wait_for_requests
has_no_css?('.fa-spinner.block-loading', wait: Capybara.default_max_wait_time) has_no_css?('.fa-spinner.block-loading', wait: Capybara.default_max_wait_time)
end end
...@@ -220,10 +241,14 @@ module QA ...@@ -220,10 +241,14 @@ module QA
end end
def click_link_with_text(text) def click_link_with_text(text)
wait_for_requests
click_link text click_link text
end end
def click_body def click_body
wait_for_requests
find('body').click find('body').click
end end
......
...@@ -66,10 +66,16 @@ module QA ...@@ -66,10 +66,16 @@ module QA
def visit! def visit!
Runtime::Logger.debug(%Q[Visiting #{self.class.name} at "#{web_url}"]) Runtime::Logger.debug(%Q[Visiting #{self.class.name} at "#{web_url}"])
# Just in case an async action is not yet complete
Support::WaitForRequests.wait_for_requests
Support::Retrier.retry_until do Support::Retrier.retry_until do
visit(web_url) visit(web_url)
wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) } wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) }
end end
# Wait until the new page is ready for us to interact with it
Support::WaitForRequests.wait_for_requests
end end
def populate(*attributes) def populate(*attributes)
......
# frozen_string_literal: true
module QA
module Support
module WaitForRequests
module_function
def wait_for_requests
Waiter.wait do
finished_all_ajax_requests? && finished_all_axios_requests?
end
end
def finished_all_axios_requests?
Capybara.page.evaluate_script('window.pendingRequests || 0').zero?
end
def finished_all_ajax_requests?
return true if Capybara.page.evaluate_script('typeof jQuery === "undefined"')
Capybara.page.evaluate_script('jQuery.active').zero?
end
end
end
end
...@@ -85,7 +85,8 @@ module Trigger ...@@ -85,7 +85,8 @@ module Trigger
'TRIGGER_SOURCE' => ENV['CI_JOB_URL'], 'TRIGGER_SOURCE' => ENV['CI_JOB_URL'],
'TOP_UPSTREAM_SOURCE_PROJECT' => ENV['CI_PROJECT_PATH'], 'TOP_UPSTREAM_SOURCE_PROJECT' => ENV['CI_PROJECT_PATH'],
'TOP_UPSTREAM_SOURCE_JOB' => ENV['CI_JOB_URL'], 'TOP_UPSTREAM_SOURCE_JOB' => ENV['CI_JOB_URL'],
'TOP_UPSTREAM_SOURCE_SHA' => ENV['CI_COMMIT_SHA'] 'TOP_UPSTREAM_SOURCE_SHA' => ENV['CI_COMMIT_SHA'],
'TOP_UPSTREAM_SOURCE_REF' => ENV['CI_COMMIT_REF_NAME']
} }
end end
......
# frozen_string_literal: true
FactoryBot.define do
factory :resource_weight_event do
issue { create(:issue) }
user { issue&.author || create(:user) }
end
end
...@@ -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
......
...@@ -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
......
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination } from '@gitlab/ui';
GlEmptyState, import stubChildren from 'helpers/stub_children';
GlLoadingIcon,
GlTable,
GlLink,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlPagination,
} from '@gitlab/ui';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
import errorsList from './list_mock.json'; import errorsList from './list_mock.json';
...@@ -32,19 +24,12 @@ describe('ErrorTrackingList', () => { ...@@ -32,19 +24,12 @@ describe('ErrorTrackingList', () => {
function mountComponent({ function mountComponent({
errorTrackingEnabled = true, errorTrackingEnabled = true,
userCanEnableErrorTracking = true, userCanEnableErrorTracking = true,
sync = true, stubs = {},
stubs = {
'gl-link': GlLink,
'gl-table': GlTable,
'gl-pagination': GlPagination,
'gl-dropdown': GlDropdown,
'gl-dropdown-item': GlDropdownItem,
},
} = {}) { } = {}) {
wrapper = shallowMount(ErrorTrackingList, { wrapper = mount(ErrorTrackingList, {
localVue, localVue,
store, store,
sync, sync: false,
propsData: { propsData: {
indexPath: '/path', indexPath: '/path',
enableErrorTrackingLink: '/link', enableErrorTrackingLink: '/link',
...@@ -52,7 +37,10 @@ describe('ErrorTrackingList', () => { ...@@ -52,7 +37,10 @@ describe('ErrorTrackingList', () => {
errorTrackingEnabled, errorTrackingEnabled,
illustrationPath: 'illustration/path', illustrationPath: 'illustration/path',
}, },
stubs, stubs: {
...stubChildren(ErrorTrackingList),
...stubs,
},
data() { data() {
return { errorSearchQuery: 'search' }; return { errorSearchQuery: 'search' };
}, },
...@@ -122,7 +110,14 @@ describe('ErrorTrackingList', () => { ...@@ -122,7 +110,14 @@ describe('ErrorTrackingList', () => {
beforeEach(() => { beforeEach(() => {
store.state.list.loading = false; store.state.list.loading = false;
store.state.list.errors = errorsList; store.state.list.errors = errorsList;
mountComponent(); mountComponent({
stubs: {
GlTable: false,
GlDropdown: false,
GlDropdownItem: false,
GlLink: false,
},
});
}); });
it('shows table', () => { it('shows table', () => {
...@@ -173,7 +168,13 @@ describe('ErrorTrackingList', () => { ...@@ -173,7 +168,13 @@ describe('ErrorTrackingList', () => {
store.state.list.loading = false; store.state.list.loading = false;
store.state.list.errors = []; store.state.list.errors = [];
mountComponent(); mountComponent({
stubs: {
GlTable: false,
GlDropdown: false,
GlDropdownItem: false,
},
});
}); });
it('shows empty table', () => { it('shows empty table', () => {
...@@ -187,7 +188,7 @@ describe('ErrorTrackingList', () => { ...@@ -187,7 +188,7 @@ describe('ErrorTrackingList', () => {
}); });
it('restarts polling', () => { it('restarts polling', () => {
findRefreshLink().trigger('click'); findRefreshLink().vm.$emit('click');
expect(actions.restartPolling).toHaveBeenCalled(); expect(actions.restartPolling).toHaveBeenCalled();
}); });
}); });
...@@ -211,8 +212,8 @@ describe('ErrorTrackingList', () => { ...@@ -211,8 +212,8 @@ describe('ErrorTrackingList', () => {
errorTrackingEnabled: false, errorTrackingEnabled: false,
userCanEnableErrorTracking: false, userCanEnableErrorTracking: false,
stubs: { stubs: {
'gl-link': GlLink, GlLink: false,
'gl-empty-state': GlEmptyState, GlEmptyState: false,
}, },
}); });
}); });
...@@ -226,7 +227,12 @@ describe('ErrorTrackingList', () => { ...@@ -226,7 +227,12 @@ describe('ErrorTrackingList', () => {
describe('recent searches', () => { describe('recent searches', () => {
beforeEach(() => { beforeEach(() => {
mountComponent(); mountComponent({
stubs: {
GlDropdown: false,
GlDropdownItem: false,
},
});
}); });
it('shows empty message', () => { it('shows empty message', () => {
...@@ -238,12 +244,13 @@ describe('ErrorTrackingList', () => { ...@@ -238,12 +244,13 @@ describe('ErrorTrackingList', () => {
it('shows items', () => { it('shows items', () => {
store.state.list.recentSearches = ['great', 'search']; store.state.list.recentSearches = ['great', 'search'];
return wrapper.vm.$nextTick().then(() => {
const dropdownItems = wrapper.findAll('.filtered-search-box li'); const dropdownItems = wrapper.findAll('.filtered-search-box li');
expect(dropdownItems.length).toBe(3); expect(dropdownItems.length).toBe(3);
expect(dropdownItems.at(0).text()).toBe('great'); expect(dropdownItems.at(0).text()).toBe('great');
expect(dropdownItems.at(1).text()).toBe('search'); expect(dropdownItems.at(1).text()).toBe('search');
}); });
});
describe('clear', () => { describe('clear', () => {
const clearRecentButton = () => wrapper.find({ ref: 'clearRecentSearches' }); const clearRecentButton = () => wrapper.find({ ref: 'clearRecentSearches' });
...@@ -257,19 +264,23 @@ describe('ErrorTrackingList', () => { ...@@ -257,19 +264,23 @@ describe('ErrorTrackingList', () => {
it('is visible when list has items', () => { it('is visible when list has items', () => {
store.state.list.recentSearches = ['some', 'searches']; store.state.list.recentSearches = ['some', 'searches'];
return wrapper.vm.$nextTick().then(() => {
expect(clearRecentButton().exists()).toBe(true); expect(clearRecentButton().exists()).toBe(true);
expect(clearRecentButton().text()).toBe('Clear recent searches'); expect(clearRecentButton().text()).toBe('Clear recent searches');
}); });
});
it('clears items on click', () => { it('clears items on click', () => {
store.state.list.recentSearches = ['some', 'searches']; store.state.list.recentSearches = ['some', 'searches'];
return wrapper.vm.$nextTick().then(() => {
clearRecentButton().vm.$emit('click'); clearRecentButton().vm.$emit('click');
expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1); expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1);
}); });
}); });
}); });
});
describe('When pagination is not required', () => { describe('When pagination is not required', () => {
beforeEach(() => { beforeEach(() => {
...@@ -287,7 +298,11 @@ describe('ErrorTrackingList', () => { ...@@ -287,7 +298,11 @@ describe('ErrorTrackingList', () => {
describe('and the user is on the first page', () => { describe('and the user is on the first page', () => {
beforeEach(() => { beforeEach(() => {
store.state.list.loading = false; store.state.list.loading = false;
mountComponent({ sync: false }); mountComponent({
stubs: {
GlPagination: false,
},
});
}); });
it('shows a disabled Prev button', () => { it('shows a disabled Prev button', () => {
...@@ -299,8 +314,14 @@ describe('ErrorTrackingList', () => { ...@@ -299,8 +314,14 @@ describe('ErrorTrackingList', () => {
describe('and the previous button is clicked', () => { describe('and the previous button is clicked', () => {
beforeEach(() => { beforeEach(() => {
store.state.list.loading = false; store.state.list.loading = false;
mountComponent({ sync: false }); mountComponent({
stubs: {
GlTable: false,
GlPagination: false,
},
});
wrapper.setData({ pageValue: 2 }); wrapper.setData({ pageValue: 2 });
return wrapper.vm.$nextTick();
}); });
it('fetches the previous page of results', () => { it('fetches the previous page of results', () => {
...@@ -318,7 +339,7 @@ describe('ErrorTrackingList', () => { ...@@ -318,7 +339,7 @@ describe('ErrorTrackingList', () => {
describe('and the next page button is clicked', () => { describe('and the next page button is clicked', () => {
beforeEach(() => { beforeEach(() => {
store.state.list.loading = false; store.state.list.loading = false;
mountComponent({ sync: false }); mountComponent();
}); });
it('fetches the next page of results', () => { it('fetches the next page of results', () => {
......
...@@ -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,
); );
......
export default function stubChildren(Component) {
return Object.fromEntries(Object.keys(Component.components).map(c => [c, true]));
}
import { shallowMount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import UserModalManager from '~/pages/admin/users/components/user_modal_manager.vue'; import UserModalManager from '~/pages/admin/users/components/user_modal_manager.vue';
import ModalStub from './stubs/modal_stub'; import ModalStub from './stubs/modal_stub';
...@@ -22,17 +22,13 @@ describe('Users admin page Modal Manager', () => { ...@@ -22,17 +22,13 @@ describe('Users admin page Modal Manager', () => {
let wrapper; let wrapper;
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(UserModalManager, { wrapper = mount(UserModalManager, {
propsData: { propsData: {
actionModals, actionModals,
modalConfiguration, modalConfiguration,
csrfToken: 'dummyCSRF', csrfToken: 'dummyCSRF',
...props, ...props,
}, },
stubs: {
dummyComponent1: true,
dummyComponent2: true,
},
sync: false, sync: false,
}); });
}; };
......
...@@ -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'));
......
...@@ -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');
......
...@@ -8,6 +8,7 @@ issues: ...@@ -8,6 +8,7 @@ issues:
- milestone - milestone
- notes - notes
- resource_label_events - resource_label_events
- resource_weight_events
- sentry_issue - sentry_issue
- label_links - label_links
- labels - labels
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ResourceWeightEvent, type: :model do
let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
let_it_be(:issue1) { create(:issue, author: user1) }
let_it_be(:issue2) { create(:issue, author: user1) }
let_it_be(:issue3) { create(:issue, author: user2) }
describe 'validations' do
it { is_expected.not_to allow_value(nil).for(:user) }
it { is_expected.not_to allow_value(nil).for(:issue) }
it { is_expected.to allow_value(nil).for(:weight) }
end
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:issue) }
end
describe '.by_issue' do
let_it_be(:event1) { create(:resource_weight_event, issue: issue1) }
let_it_be(:event2) { create(:resource_weight_event, issue: issue2) }
let_it_be(:event3) { create(:resource_weight_event, issue: issue1) }
it 'returns the expected records for an issue with events' do
events = ResourceWeightEvent.by_issue(issue1)
expect(events).to contain_exactly(event1, event3)
end
it 'returns the expected records for an issue with no events' do
events = ResourceWeightEvent.by_issue(issue3)
expect(events).to be_empty
end
end
describe '.created_after' do
let!(:created_at1) { 1.day.ago }
let!(:created_at2) { 2.days.ago }
let!(:created_at3) { 3.days.ago }
let!(:event1) { create(:resource_weight_event, issue: issue1, created_at: created_at1) }
let!(:event2) { create(:resource_weight_event, issue: issue2, created_at: created_at2) }
let!(:event3) { create(:resource_weight_event, issue: issue2, created_at: created_at3) }
it 'returns the expected events' do
events = ResourceWeightEvent.created_after(created_at3)
expect(events).to contain_exactly(event1, event2)
end
it 'returns no events if time is after last record time' do
events = ResourceWeightEvent.created_after(1.minute.ago)
expect(events).to be_empty
end
end
describe '#discussion_id' do
let_it_be(:event) { create(:resource_weight_event, issue: issue1, created_at: Time.utc(2019, 12, 30)) }
it 'returns the expected id' do
allow(Digest::SHA1).to receive(:hexdigest)
.with("ResourceWeightEvent-2019-12-30 00:00:00 UTC-#{user1.id}")
.and_return('73d167c478')
expect(event.discussion_id).to eq('73d167c478')
end
end
end
...@@ -183,6 +183,57 @@ describe Ci::BuildRunnerPresenter do ...@@ -183,6 +183,57 @@ describe Ci::BuildRunnerPresenter do
let(:pipeline) { merge_request.all_pipelines.first } let(:pipeline) { merge_request.all_pipelines.first }
let(:build) { create(:ci_build, ref: pipeline.ref, pipeline: pipeline) } let(:build) { create(:ci_build, ref: pipeline.ref, pipeline: pipeline) }
context 'when depend_on_persistent_pipeline_ref feature flag is enabled' do
before do
stub_feature_flags(ci_force_exposing_merge_request_refs: false)
pipeline.persistent_ref.create
end
it 'returns the correct refspecs' do
is_expected
.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}")
end
context 'when ci_force_exposing_merge_request_refs feature flag is enabled' do
before do
stub_feature_flags(ci_force_exposing_merge_request_refs: true)
end
it 'returns the correct refspecs' do
is_expected
.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
'+refs/merge-requests/1/head:refs/merge-requests/1/head')
end
end
context 'when GIT_DEPTH is zero' do
before do
create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline)
end
it 'returns the correct refspecs' do
is_expected
.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
'+refs/heads/*:refs/remotes/origin/*',
'+refs/tags/*:refs/tags/*')
end
end
context 'when pipeline is legacy detached merge request pipeline' do
let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) }
it 'returns the correct refspecs' do
is_expected.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}",
"+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}")
end
end
end
context 'when depend_on_persistent_pipeline_ref feature flag is disabled' do
before do
stub_feature_flags(depend_on_persistent_pipeline_ref: false)
end
it 'returns the correct refspecs' do it 'returns the correct refspecs' do
is_expected is_expected
.to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head') .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head')
...@@ -209,6 +260,7 @@ describe Ci::BuildRunnerPresenter do ...@@ -209,6 +260,7 @@ describe Ci::BuildRunnerPresenter do
end end
end end
end end
end
context 'when persistent pipeline ref exists' do context 'when persistent pipeline ref exists' do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
......
...@@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::AddColumnWithDefault do ...@@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::AddColumnWithDefault do
it 'does not register any offenses' do it 'does not register any offenses' do
expect_no_offenses(<<~RUBY) expect_no_offenses(<<~RUBY)
def up def up
add_reference(:projects, :users) add_column_with_default(:ci_build_needs, :artifacts, :boolean, default: true, allow_null: false)
end end
RUBY RUBY
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe ResourceEvents::SyntheticLabelNotesBuilderService do
describe '#execute' do
let!(:user) { create(:user) }
let!(:issue) { create(:issue, author: user) }
let!(:event1) { create(:resource_label_event, issue: issue) }
let!(:event2) { create(:resource_label_event, issue: issue) }
let!(:event3) { create(:resource_label_event, issue: issue) }
it 'returns the expected synthetic notes' do
notes = ResourceEvents::SyntheticLabelNotesBuilderService.new(issue, user).execute
expect(notes.size).to eq(3)
end
end
end
...@@ -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)
......
File mode changed from 100755 to 100644
File mode changed from 100755 to 100644
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