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:
- 'notify_on_job_failure schedule:package-and-qa qa-master "${NOTIFICATION_MESSAGE}" ci_failing'
needs: ["schedule:package-and-qa"]
allow_failure: true
when: always
when: manual # TODO: remove notify job if not necessary
......@@ -101,6 +101,11 @@ class DropDown {
render(data) {
const children = data ? data.map(this.renderChildren.bind(this)) : [];
if (this.list.querySelector('.filter-dropdown-loading')) {
return;
}
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join('');
......
......@@ -2,6 +2,7 @@ import { __ } from '~/locale';
export default IssuableTokenKeys => {
const wipToken = {
formattedKey: __('WIP'),
key: 'wip',
type: 'string',
param: '',
......@@ -17,6 +18,7 @@ export default IssuableTokenKeys => {
IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
const targetBranchToken = {
formattedKey: __('Target-Branch'),
key: 'target-branch',
type: 'string',
param: '',
......
import { __ } from '~/locale';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
const tokenKeys = [
{
formattedKey: __('Status'),
key: 'status',
type: 'string',
param: 'status',
......@@ -10,6 +12,7 @@ const tokenKeys = [
tag: 'status',
},
{
formattedKey: __('Type'),
key: 'type',
type: 'string',
param: 'type',
......@@ -18,6 +21,7 @@ const tokenKeys = [
tag: 'type',
},
{
formattedKey: __('Tag'),
key: 'tag',
type: 'array',
param: 'name[]',
......
......@@ -4,6 +4,7 @@ import DropdownNonUser from './dropdown_non_user';
import DropdownEmoji from './dropdown_emoji';
import NullDropdown from './null_dropdown';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownOperator from './dropdown_operator';
import DropdownUtils from './dropdown_utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
......@@ -40,6 +41,11 @@ export default class AvailableDropdownMappings {
gl: DropdownHint,
element: this.container.querySelector('#js-dropdown-hint'),
},
operator: {
reference: null,
gl: DropdownOperator,
element: this.container.querySelector('#js-dropdown-operator'),
},
};
supportedTokens.forEach(type => {
......
......@@ -29,6 +29,7 @@ export default {
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
operator: token.operator,
suffix: `${token.symbol}${token.value}`,
}));
......@@ -75,6 +76,7 @@ export default {
class="filtered-search-history-dropdown-token"
>
<span class="name">{{ token.prefix }}</span>
<span class="name">{{ token.operator }}</span>
<span class="value">{{ token.suffix }}</span>
</span>
</span>
......
/* eslint-disable import/prefer-default-export */
export const USER_TOKEN_TYPES = ['author', 'assignee'];
export const DROPDOWN_TYPE = {
hint: 'hint',
operator: 'operator',
};
......@@ -45,7 +45,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown {
getSearchInput() {
const query = DropdownUtils.getSearchInput(this.input);
const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.getKeys());
let value = lastToken || '';
......
......@@ -3,6 +3,7 @@ import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import { __ } from '~/locale';
export default class DropdownHint extends FilteredSearchDropdown {
constructor(options = {}) {
......@@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown {
this.dismissDropdown();
this.dispatchFormSubmitEvent();
} else {
const token = selected.querySelector('.js-filter-hint').innerText.trim();
const tag = selected.querySelector('.js-filter-tag').innerText.trim();
const filterItemEl = selected.closest('.filter-dropdown-item');
const { hint: token, tag } = filterItemEl.dataset;
if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens
......@@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown {
const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
FilteredSearchDropdownManager.addWordToInput(key, '', false, {
FilteredSearchDropdownManager.addWordToInput({
tokenName: key,
clicked: false,
options: {
uppercaseTokenName,
},
});
}
this.dismissDropdown();
......@@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown {
}
renderContent() {
const dropdownData = this.tokenKeys.get().map(tokenKey => ({
const searchItem = [
{
hint: 'search',
tag: 'search',
formattedKey: __('Search for this text'),
icon: `${gon.sprite_icons}#search`,
},
];
const dropdownData = this.tokenKeys
.get()
.map(tokenKey => ({
icon: `${gon.sprite_icons}#${tokenKey.icon}`,
hint: tokenKey.key,
tag: `:${tokenKey.tag}`,
type: tokenKey.type,
}));
formattedKey: tokenKey.formattedKey,
}))
.concat(searchItem);
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
super.renderContent();
}
init() {
......
import Filter from '~/droplab/plugins/filter';
import { __ } from '~/locale';
import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class DropdownOperator extends FilteredSearchDropdown {
constructor(options = {}) {
const { input, tokenKeys } = options;
super(options);
this.config = {
Filter: {
filterFunction: DropdownUtils.filterWithSymbol.bind(null, '', input),
template: 'title',
},
};
this.tokenKeys = tokenKeys;
}
itemClicked(e) {
const { selected } = e.detail;
if (selected.tagName === 'LI') {
if (selected.hasAttribute('data-value')) {
const operator = selected.dataset.value;
FilteredSearchVisualTokens.removeLastTokenPartial();
FilteredSearchDropdownManager.addWordToInput({
tokenName: this.filter,
tokenOperator: operator,
clicked: false,
});
}
}
this.dismissDropdown();
this.dispatchInputEvent();
}
renderContent(forceShowList = false) {
this.filter = FilteredSearchVisualTokens.getLastTokenPartial();
const dropdownData = [
{
tag: 'equal',
type: 'string',
title: '=',
help: __('Is'),
},
{
tag: 'not-equal',
type: 'string',
title: '!=',
help: __('Is not'),
},
];
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
super.renderContent(forceShowList);
}
init() {
this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
}
}
......@@ -62,28 +62,42 @@ export default class DropdownUtils {
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
const isSearchItem = updatedItem.hint === 'search';
if (isSearchItem) {
updatedItem.droplab_hidden = true;
}
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
} else if (!lastKey || _.last(searchInput.split('')) === ' ') {
} else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
const tokenName = _.last(split[0].split(' '));
const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
const match = isSearchItem
? allowedKeys.some(key => key.startsWith(tokenName.toLowerCase()))
: updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
updatedItem.droplab_hidden = tokenName ? match : false;
}
return updatedItem;
}
static setDataValueIfSelected(filter, selected) {
static setDataValueIfSelected(filter, operator, selected) {
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, {
FilteredSearchDropdownManager.addWordToInput({
tokenName: filter,
tokenOperator: operator,
tokenValue: dataValue,
clicked: true,
options: {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
},
});
}
......@@ -101,7 +115,11 @@ export default class DropdownUtils {
// remove leading symbol and wrapping quotes
tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, '');
}
return { tokenName, tokenValue };
const operatorEl = visualToken && visualToken.querySelector('.operator');
const tokenOperator = operatorEl && operatorEl.textContent.trim();
return { tokenName, tokenOperator, tokenValue };
}
// Determines the full search query (visual tokens + input)
......@@ -119,10 +137,16 @@ export default class DropdownUtils {
tokens.forEach(token => {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
const operatorContainer = token.querySelector('.operator');
const value = token.querySelector('.value');
const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
let operator = '';
if (operatorContainer) {
operator = operatorContainer.textContent.trim();
}
if (valueContainer && valueContainer.dataset.originalValue) {
valueText = valueContainer.dataset.originalValue;
......@@ -131,7 +155,7 @@ export default class DropdownUtils {
}
if (token.className.indexOf('filtered-search-token') !== -1) {
values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`);
values.push(`${name.innerText.toLowerCase()}:${operator}${symbol}${valueText}`);
} else {
values.push(name.innerText);
}
......
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
......@@ -31,13 +32,26 @@ export default class FilteredSearchDropdown {
itemClicked(e, getValueFunction) {
const { selected } = e.detail;
if (selected.tagName === 'LI' && selected.innerHTML) {
const dataValueSet = DropdownUtils.setDataValueIfSelected(this.filter, selected);
const {
lastVisualToken: visualToken,
} = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const { tokenOperator } = DropdownUtils.getVisualTokenValues(visualToken);
const dataValueSet = DropdownUtils.setDataValueIfSelected(
this.filter,
tokenOperator,
selected,
);
if (!dataValueSet) {
const value = getValueFunction(selected);
FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
FilteredSearchDropdownManager.addWordToInput({
tokenName: this.filter,
tokenOperator,
tokenValue: value,
clicked: true,
});
}
this.resetFilters();
......
......@@ -5,6 +5,7 @@ import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import { DROPDOWN_TYPE } from './constants';
export default class FilteredSearchDropdownManager {
constructor({
......@@ -67,10 +68,16 @@ export default class FilteredSearchDropdownManager {
this.mapping = availableMappings.getAllowedMappings(supportedTokens);
}
static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
static addWordToInput({
tokenName,
tokenOperator = '',
tokenValue = '',
clicked = false,
options = {},
}) {
const { uppercaseTokenName = false, capitalizeTokenValue = false } = options;
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, {
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenOperator, tokenValue, {
uppercaseTokenName,
capitalizeTokenValue,
});
......@@ -129,7 +136,10 @@ export default class FilteredSearchDropdownManager {
mappingKey.reference.init();
}
if (this.currentDropdown === 'hint') {
if (
this.currentDropdown === DROPDOWN_TYPE.hint ||
this.currentDropdown === DROPDOWN_TYPE.operator
) {
// Force the dropdown to show if it was clicked from the hint dropdown
forceShowList = true;
}
......@@ -148,13 +158,19 @@ export default class FilteredSearchDropdownManager {
this.droplab = new DropLab();
}
if (dropdownName === DROPDOWN_TYPE.operator) {
this.load(dropdownName, firstLoad);
return;
}
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown =
match && this.currentDropdown !== match.key && this.mapping[match.key];
const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint';
const shouldOpenHintDropdown = !match && this.currentDropdown !== DROPDOWN_TYPE.hint;
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
const key = match && match.key ? match.key : 'hint';
const key = match && match.key ? match.key : DROPDOWN_TYPE.hint;
this.load(key, firstLoad);
}
}
......@@ -169,19 +185,32 @@ export default class FilteredSearchDropdownManager {
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
}
if (lastToken === searchToken && lastToken !== null) {
// Token is not fully initialized yet because it has no value
// Eg. token = 'label:'
const split = lastToken.split(':');
const dropdownName = _.last(split[0].split(' '));
this.loadDropdown(split.length > 1 ? dropdownName : '');
const possibleOperatorToken = _.last(split[1]);
const hasOperator = FilteredSearchVisualTokens.permissibleOperatorValues.includes(
possibleOperatorToken && possibleOperatorToken.trim(),
);
let dropdownToOpen = '';
if (split.length > 1) {
const lastOperatorToken = FilteredSearchVisualTokens.getLastTokenOperator();
dropdownToOpen = hasOperator && lastOperatorToken ? dropdownName : DROPDOWN_TYPE.operator;
}
this.loadDropdown(dropdownToOpen);
} else if (lastToken) {
const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator();
// Token has been initialized into an object because it has a value
this.loadDropdown(lastToken.key);
this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator);
} else {
this.loadDropdown('hint');
this.loadDropdown(DROPDOWN_TYPE.hint);
}
}
......
......@@ -14,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import DropdownUtils from './dropdown_utils';
import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes';
import { __ } from '~/locale';
export default class FilteredSearchManager {
......@@ -58,6 +59,8 @@ export default class FilteredSearchManager {
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
static notTransformableQueryParams = ['scope', 'utf8', 'state', 'search'];
setup() {
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService
......@@ -84,6 +87,7 @@ export default class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = FilteredSearchTokenizer;
this.dropdownManager = new FilteredSearchDropdownManager({
runnerTagsEndpoint:
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
......@@ -172,7 +176,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.addEventListener('click', this.tokenChange);
......@@ -194,7 +198,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
......@@ -228,7 +232,7 @@ export default class FilteredSearchManager {
if (backspaceCount === 2) {
backspaceCount = 0;
this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial();
this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(true);
FilteredSearchVisualTokens.removeLastTokenPartial();
}
}
......@@ -407,7 +411,12 @@ export default class FilteredSearchManager {
}
}
handleInputVisualToken() {
handleInputVisualToken(e) {
// If the keyCode was 8 then do not form new tokens
if (e.keyCode === BACKSPACE_KEY_CODE) {
return;
}
const input = this.filteredSearchInput;
const { tokens, searchToken } = this.tokenizer.processTokens(
input.value,
......@@ -417,14 +426,21 @@ export default class FilteredSearchManager {
if (isLastVisualTokenValid) {
tokens.forEach(t => {
input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, {
input.value = input.value.replace(`${t.key}:${t.operator}${t.symbol}${t.value}`, '');
FilteredSearchVisualTokens.addFilterVisualToken(
t.key,
t.operator,
`${t.symbol}${t.value}`,
{
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
});
},
);
});
const fragments = searchToken.split(':');
if (fragments.length > 1) {
const inputValues = fragments[0].split(' ');
const tokenKey = _.last(inputValues);
......@@ -437,19 +453,58 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
}
FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, {
FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, null, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
input.value = input.value.replace(`${tokenKey}:`, '');
}
const splitSearchToken = searchToken && searchToken.split(' ');
let lastSearchToken = _.last(splitSearchToken);
lastSearchToken = lastSearchToken?.toLowerCase();
/**
* If user writes "milestone", a known token, in the input, we should not
* wait for leading colon to flush it as a filter token.
*/
if (this.filteredSearchTokenKeys.getKeys().includes(lastSearchToken)) {
if (splitSearchToken.length > 1) {
splitSearchToken.pop();
const searchVisualTokens = splitSearchToken.join(' ');
input.value = input.value.replace(searchVisualTokens, '');
FilteredSearchVisualTokens.addSearchVisualToken(searchVisualTokens);
}
FilteredSearchVisualTokens.addFilterVisualToken(lastSearchToken, null, null, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(
lastSearchToken,
),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(
lastSearchToken,
),
});
input.value = input.value.replace(lastSearchToken, '');
}
} else if (!isLastVisualTokenValid && !FilteredSearchVisualTokens.getLastTokenOperator()) {
const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
const tokenOperator = searchToken && searchToken.trim();
// Tokenize operator only if the operator token is valid
if (FilteredSearchVisualTokens.permissibleOperatorValues.includes(tokenOperator)) {
FilteredSearchVisualTokens.removeLastTokenPartial();
FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, tokenOperator, null, {
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
input.value = input.value.replace(searchToken, '').trim();
}
} else {
// Keep listening to token until we determine that the user is done typing the token value
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, {
FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, null, {
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
......@@ -484,9 +539,52 @@ export default class FilteredSearchManager {
return this.modifyUrlParams ? this.modifyUrlParams(urlParams) : urlParams;
}
transformParams(params) {
/**
* Extract key, value pair from the `not` query param:
* Query param looks like not[key]=value
*
* Eg. not[foo]=%bar
* key = foo; value = %bar
*/
const notKeyValueRegex = new RegExp(/not\[(\w+)\]\[?\]?=(.*)/);
return params.map(query => {
// Check if there are matches for `not` operator
const matches = query.match(notKeyValueRegex);
if (matches && matches.length === 3) {
const keyParam = matches[1];
if (
FilteredSearchManager.notTransformableQueryParams.includes(keyParam) ||
this.filteredSearchTokenKeys.searchByConditionUrl(query)
) {
return query;
}
const valueParam = matches[2];
// Not operator
const operator = encodeURIComponent('!=');
return `${keyParam}=${operator}${valueParam}`;
}
const [keyParam, valueParam] = query.split('=');
if (
FilteredSearchManager.notTransformableQueryParams.includes(keyParam) ||
this.filteredSearchTokenKeys.searchByConditionUrl(query)
) {
return query;
}
const operator = encodeURIComponent('=');
return `${keyParam}=${operator}${valueParam}`;
});
}
loadSearchParamsFromURL() {
const urlParams = getUrlParamsArray();
const params = this.getAllParams(urlParams);
const withOperatorParams = this.transformParams(urlParams);
const params = this.getAllParams(withOperatorParams);
const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false;
......@@ -501,9 +599,14 @@ export default class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value, {
FilteredSearchVisualTokens.addFilterVisualToken(
condition.tokenKey,
condition.operator,
condition.value,
{
canEdit,
});
},
);
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
......@@ -522,9 +625,12 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(key, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
const operator = FilteredSearchVisualTokens.getOperatorToken(sanitizedValue);
const sanitizedToken = FilteredSearchVisualTokens.getValueToken(sanitizedValue);
FilteredSearchVisualTokens.addFilterVisualToken(
key,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
operator,
`${symbol}${quotationsToUse}${sanitizedToken}${quotationsToUse}`,
{
canEdit,
uppercaseTokenName,
......@@ -537,7 +643,10 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'assignee';
const canEdit = this.canEdit && this.canEdit(tokenName);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, {
const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]);
const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, {
canEdit,
});
}
......@@ -547,7 +656,10 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, {
const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]);
const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]);
FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, {
canEdit,
});
}
......@@ -582,7 +694,6 @@ export default class FilteredSearchManager {
search(state = null) {
const paths = [];
const searchQuery = DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const tokenKeys = this.filteredSearchTokenKeys.getKeys();
......@@ -593,6 +704,7 @@ export default class FilteredSearchManager {
tokens.forEach(token => {
const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue(
token.key,
token.operator,
token.value,
);
const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
......@@ -620,8 +732,17 @@ export default class FilteredSearchManager {
tokenValue = tokenValue.slice(1, tokenValue.length - 1);
}
if (token.operator === '!=') {
const isArrayParam = keyParam.endsWith('[]');
tokenPath = `not[${isArrayParam ? keyParam.slice(0, -2) : keyParam}]${
isArrayParam ? '[]' : ''
}=${encodeURIComponent(tokenValue)}`;
} else {
// Default operator is `=`
tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
}
}
paths.push(tokenPath);
});
......
......@@ -65,17 +65,20 @@ export default class FilteredSearchTokenKeys {
return this.conditions.find(condition => condition.url === url) || null;
}
searchByConditionKeyValue(key, value) {
searchByConditionKeyValue(key, operator, value) {
return (
this.conditions.find(
condition =>
condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(),
condition.tokenKey === key &&
condition.operator === operator &&
condition.value.toLowerCase() === value.toLowerCase(),
) || null
);
}
addExtraTokensForIssues() {
const confidentialToken = {
formattedKey: __('Confidential'),
key: 'confidential',
type: 'string',
param: '',
......
......@@ -2,10 +2,11 @@ import './filtered_search_token_keys';
export default class FilteredSearchTokenizer {
static processTokens(input, allowedKeys) {
// Regex extracts `(token):(symbol)(value)`
// Regex extracts `(token):(operator)(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(
`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
`(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
'g',
);
const tokens = [];
......@@ -13,16 +14,22 @@ export default class FilteredSearchTokenizer {
let lastToken = null;
const searchToken =
input
.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => {
.replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => {
let tokenValue = v1 || v2 || v3;
let tokenSymbol = symbol;
let tokenIndex = '';
let tokenOperator = operator;
if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
tokenSymbol = tokenValue;
tokenValue = '';
}
if (tokenValue === '!=' || tokenValue === '=') {
tokenOperator = tokenValue;
tokenValue = '';
}
tokenIndex = `${key}:${tokenValue}`;
// Prevent adding duplicates
......@@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer {
key,
value: tokenValue || '',
symbol: tokenSymbol || '',
operator: tokenOperator || '',
});
}
......@@ -43,13 +51,12 @@ export default class FilteredSearchTokenizer {
if (tokens.length > 0) {
const last = tokens[tokens.length - 1];
const lastString = `${last.key}:${last.symbol}${last.value}`;
const lastString = `${last.key}:${last.operator}${last.symbol}${last.value}`;
lastToken =
input.lastIndexOf(lastString) === input.length - lastString.length ? last : searchToken;
} else {
lastToken = searchToken;
}
return {
tokens,
lastToken,
......
......@@ -3,6 +3,32 @@ import { objectToQueryString } from '~/lib/utils/common_utils';
import FilteredSearchContainer from './container';
export default class FilteredSearchVisualTokens {
static permissibleOperatorValues = ['=', '!='];
static getOperatorToken(value) {
let token = null;
FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => {
if (value.startsWith(operatorToken)) {
token = operatorToken;
}
});
return token;
}
static getValueToken(value) {
let newValue = value;
FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => {
if (value.startsWith(operatorToken)) {
newValue = value.slice(operatorToken.length);
}
});
return newValue;
}
static getLastVisualTokenBeforeInput() {
const inputLi = FilteredSearchContainer.container.querySelector('.input-token');
const lastVisualToken = inputLi && inputLi.previousElementSibling;
......@@ -12,7 +38,9 @@ export default class FilteredSearchVisualTokens {
isLastVisualTokenValid:
lastVisualToken === null ||
lastVisualToken.className.indexOf('filtered-search-term') !== -1 ||
(lastVisualToken && lastVisualToken.querySelector('.value') !== null),
(lastVisualToken &&
lastVisualToken.querySelector('.operator') !== null &&
lastVisualToken.querySelector('.value') !== null),
};
}
......@@ -42,11 +70,17 @@ export default class FilteredSearchVisualTokens {
}
static createVisualTokenElementHTML(options = {}) {
const { canEdit = true, uppercaseTokenName = false, capitalizeTokenValue = false } = options;
const {
canEdit = true,
hasOperator = false,
uppercaseTokenName = false,
capitalizeTokenValue = false,
} = options;
return `
<div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>
${hasOperator ? '<div class="operator"></div>' : ''}
<div class="value-container">
<div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div>
<div class="remove-token" role="button">
......@@ -57,18 +91,18 @@ export default class FilteredSearchVisualTokens {
`;
}
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
static renderVisualTokenValue(parentElement, tokenName, tokenValue, tokenOperator) {
const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue;
const visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
const visualTokenValue = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
visualTokenValue.render(tokenValueContainer, tokenValueElement);
}
static addVisualTokenElement(name, value, options = {}) {
static addVisualTokenElement({ name, operator, value, options = {} }) {
const {
isSearchTerm = false,
canEdit,
......@@ -84,17 +118,32 @@ export default class FilteredSearchVisualTokens {
li.classList.add(tokenClass);
}
const hasOperator = Boolean(operator);
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
operator,
hasOperator,
capitalizeTokenValue,
});
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value, operator);
} else {
li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
const nameHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
let operatorHTML = '';
if (hasOperator) {
operatorHTML = '<div class="operator"></div>';
}
li.innerHTML = nameHTML + operatorHTML;
}
li.querySelector('.name').innerText = name;
if (hasOperator) {
li.querySelector('.operator').innerText = operator;
}
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
......@@ -109,14 +158,19 @@ export default class FilteredSearchVisualTokens {
if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
const operator = FilteredSearchVisualTokens.getLastTokenOperator();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
hasOperator: Boolean(operator),
});
lastVisualToken.querySelector('.name').innerText = name;
FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value);
lastVisualToken.querySelector('.operator').innerText = operator;
FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value, operator);
}
}
static addFilterVisualToken(
tokenName,
tokenOperator,
tokenValue,
{ canEdit, uppercaseTokenName = false, capitalizeTokenValue = false } = {},
) {
......@@ -127,21 +181,51 @@ export default class FilteredSearchVisualTokens {
const { addVisualTokenElement } = FilteredSearchVisualTokens;
if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue, {
addVisualTokenElement({
name: tokenName,
operator: tokenOperator,
value: tokenValue,
options: {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
});
} else if (
!isLastVisualTokenValid &&
(lastVisualToken && !lastVisualToken.querySelector('.operator'))
) {
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
addVisualTokenElement({
name: tokenName,
operator: tokenOperator,
value: tokenValue,
options: {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
});
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const previousTokenOperator = lastVisualToken.querySelector('.operator').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
addVisualTokenElement(previousTokenName, value, {
let value = tokenValue;
if (!value && !tokenOperator) {
value = tokenName;
}
addVisualTokenElement({
name: previousTokenName,
operator: previousTokenOperator,
value,
options: {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
});
}
}
......@@ -152,13 +236,18 @@ export default class FilteredSearchVisualTokens {
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
} else {
FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, {
FilteredSearchVisualTokens.addVisualTokenElement({
name: searchTerm,
operator: null,
value: null,
options: {
isSearchTerm: true,
},
});
}
}
static getLastTokenPartial() {
static getLastTokenPartial(includeOperator = false) {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!lastVisualToken) return '';
......@@ -175,20 +264,36 @@ export default class FilteredSearchVisualTokens {
const valueText = value ? value.innerText : '';
const nameText = name ? name.innerText : '';
if (includeOperator) {
const operator = lastVisualToken.querySelector('.operator');
const operatorText = operator ? operator.innerText : '';
return valueText || operatorText || nameText;
}
return valueText || nameText;
}
static getLastTokenOperator() {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const operator = lastVisualToken && lastVisualToken.querySelector('.operator');
return operator?.innerText;
}
static removeLastTokenPartial() {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (lastVisualToken) {
const value = lastVisualToken.querySelector('.value');
const operator = lastVisualToken.querySelector('.operator');
if (value) {
const button = lastVisualToken.querySelector('.selectable');
const valueContainer = lastVisualToken.querySelector('.value-container');
button.removeChild(valueContainer);
lastVisualToken.innerHTML = button.innerHTML;
} else if (operator) {
lastVisualToken.removeChild(operator);
} else {
lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken);
}
......@@ -236,12 +341,18 @@ export default class FilteredSearchVisualTokens {
tokenContainer.replaceChild(inputLi, token);
const nameElement = token.querySelector('.name');
const operatorElement = token.querySelector('.operator');
let value;
if (token.classList.contains('filtered-search-token')) {
FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, {
FilteredSearchVisualTokens.addFilterVisualToken(
nameElement.innerText,
operatorElement.innerText,
null,
{
uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
});
},
);
const valueContainerElement = token.querySelector('.value-container');
value = valueContainerElement.dataset.originalValue;
......
import { flatten } from 'underscore';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale';
export const tokenKeys = [
{
formattedKey: __('Author'),
key: 'author',
type: 'string',
param: 'username',
......@@ -11,6 +13,7 @@ export const tokenKeys = [
tag: '@author',
},
{
formattedKey: __('Assignee'),
key: 'assignee',
type: 'string',
param: 'username',
......@@ -19,6 +22,7 @@ export const tokenKeys = [
tag: '@assignee',
},
{
formattedKey: __('Milestone'),
key: 'milestone',
type: 'string',
param: 'title',
......@@ -27,6 +31,7 @@ export const tokenKeys = [
tag: '%milestone',
},
{
formattedKey: __('Release'),
key: 'release',
type: 'string',
param: 'tag',
......@@ -35,6 +40,7 @@ export const tokenKeys = [
tag: __('tag name'),
},
{
formattedKey: __('Label'),
key: 'label',
type: 'array',
param: 'name[]',
......@@ -47,6 +53,7 @@ export const tokenKeys = [
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
formattedKey: __('My-Reaction'),
key: 'my-reaction',
type: 'string',
param: 'emoji',
......@@ -58,6 +65,7 @@ if (gon.current_user_id) {
export const alternativeTokenKeys = [
{
formattedKey: __('Label'),
key: 'label',
type: 'string',
param: 'name',
......@@ -65,7 +73,8 @@ export const alternativeTokenKeys = [
},
];
export const conditions = [
export const conditions = flatten(
[
{
url: 'assignee_id=None',
tokenKey: 'assignee',
......@@ -126,7 +135,26 @@ export const conditions = [
tokenKey: 'my-reaction',
value: __('Any'),
},
];
].map(condition => {
const [keyPart, valuePart] = condition.url.split('=');
const hasBrackets = keyPart.includes('[]');
const notEqualUrl = `not[${hasBrackets ? keyPart.slice(0, -2) : keyPart}]${
hasBrackets ? '[]' : ''
}=${valuePart}`;
return [
{
...condition,
operator: '=',
},
{
...condition,
operator: '!=',
url: notEqualUrl,
},
];
}),
);
const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys(
tokenKeys,
......
......@@ -9,9 +9,10 @@ import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
export default class VisualTokenValue {
constructor(tokenValue, tokenType) {
constructor(tokenValue, tokenType, tokenOperator) {
this.tokenValue = tokenValue;
this.tokenType = tokenType;
this.tokenOperator = tokenOperator;
}
render(tokenValueContainer, tokenValueElement) {
......
......@@ -2,3 +2,4 @@ export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
export const BACKSPACE_KEY_CODE = 8;
......@@ -88,6 +88,7 @@
}
.name,
.operator,
.value {
display: inline-block;
padding: 2px 7px;
......@@ -101,6 +102,12 @@
text-transform: capitalize;
}
.operator {
background-color: $white-normal;
color: $filter-value-text-color;
margin-right: 1px;
}
.value-container {
display: flex;
align-items: center;
......@@ -147,6 +154,10 @@
background-color: $filter-name-selected-color;
}
.operator {
box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
}
.value-container {
box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
}
......@@ -260,6 +271,11 @@
max-width: none;
min-width: 100%;
}
.btn-helptext {
margin-left: auto;
color: var(--gray);
}
}
.filtered-search-history-dropdown-wrapper {
......
......@@ -90,7 +90,7 @@ module Boards
end
def filter_params
params.merge(board_id: params[:board_id], id: params[:list_id])
params.permit(*Boards::Issues::ListService.valid_params).merge(board_id: params[:board_id], id: params[:list_id])
.reject { |_, value| value.nil? }
end
......
......@@ -87,7 +87,7 @@ class IssuableFinder
end
def valid_params
@valid_params ||= scalar_params + [array_params] + [{ not: [] }]
@valid_params ||= scalar_params + [array_params.merge(not: {})]
end
end
......
# 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
def refspecs
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?
if git_depth > 0
......@@ -50,6 +50,19 @@ module Ci
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)
return unless artifacts[:untracked] || artifacts[:paths]
......
......@@ -5,6 +5,10 @@ module Boards
class ListService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
def self.valid_params
IssuesFinder.valid_params
end
def execute
fetch_issues.order_by_position_and_priority
end
......
# 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
# 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 and merges them with classic notes and sorts them by
# creation time.
# 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 and weight changes
# as classic system notes in UI. This service merges synthetic label and weight notes
# with classic notes and sorts them by creation time.
module ResourceEvents
class MergeIntoNotesService
......@@ -19,39 +18,15 @@ module ResourceEvents
end
def execute(notes = [])
(notes + label_notes).sort_by { |n| n.created_at }
(notes + synthetic_notes).sort_by { |n| n.created_at }
end
private
def label_notes
label_events_by_discussion_id.map do |discussion_id, events|
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
def synthetic_notes
SyntheticLabelNotesBuilderService.new(resource, current_user, params).execute
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 @@
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
= button_tag class: %w[btn btn-link] do
= sprite_icon('search')
%span
= _('Press Enter or click to search')
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[btn btn-link] do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{hint}}
%span.js-filter-tag.dropdown-light-content
{{tag}}
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
......
......@@ -30,23 +30,22 @@
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options(type) }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
%button.btn.btn-link{ type: 'button' }
= sprite_icon('search')
%span
= _('Press Enter or click to search')
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
%button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
{{hint}}
%span.js-filter-tag.dropdown-light-content
{{tag}}
{{formattedKey}}
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
%button.btn.btn-link{ type: 'button' }
{{ title }}
%span.btn-helptext
{{ help }}
#js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
- if current_user
%ul{ data: { dropdown: true } }
......
---
title: Add support for operator in filter bar
merge_request: 19011
author:
type: added
---
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 @@
#
# 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
enable_extension "pg_trgm"
......@@ -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", "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"], name: "index_ci_pipelines_on_project_id"
t.index ["status"], name: "index_ci_pipelines_on_status"
t.index ["user_id"], name: "index_ci_pipelines_on_user_id"
end
......@@ -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"
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|
t.integer "author_id"
t.integer "merge_request_id", null: false
......@@ -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", "merge_requests", on_delete: :cascade
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", "projects", on_delete: :cascade
add_foreign_key "reviews", "users", column: "author_id", on_delete: :nullify
......
......@@ -2032,6 +2032,9 @@ msgstr ""
msgid "Approved the current merge request."
msgstr ""
msgid "Approver"
msgstr ""
msgid "Apr"
msgstr ""
......@@ -9948,6 +9951,12 @@ msgstr ""
msgid "Invocations"
msgstr ""
msgid "Is"
msgstr ""
msgid "Is not"
msgstr ""
msgid "Is using license seat:"
msgstr ""
......@@ -11678,6 +11687,9 @@ msgstr ""
msgid "Multiple uploaders found: %{uploader_types}"
msgstr ""
msgid "My-Reaction"
msgstr ""
msgid "Name"
msgstr ""
......@@ -13305,9 +13317,6 @@ msgstr ""
msgid "Press %{key}-C to copy"
msgstr ""
msgid "Press Enter or click to search"
msgstr ""
msgid "Prevent adding new members to project membership within this group"
msgstr ""
......@@ -15712,6 +15721,9 @@ msgstr ""
msgid "Search for projects, issues, etc."
msgstr ""
msgid "Search for this text"
msgstr ""
msgid "Search forks"
msgstr ""
......@@ -17722,6 +17734,9 @@ msgstr ""
msgid "Target branch"
msgstr ""
msgid "Target-Branch"
msgstr ""
msgid "Team"
msgstr ""
......@@ -20291,6 +20306,9 @@ msgstr ""
msgid "Vulnerability|Severity"
msgstr ""
msgid "WIP"
msgstr ""
msgid "Wait for the file to load to copy its contents"
msgstr ""
......
......@@ -490,6 +490,7 @@ module QA
autoload :Dates, 'qa/support/dates'
autoload :Waiter, 'qa/support/waiter'
autoload :Retrier, 'qa/support/retrier'
autoload :WaitForRequests, 'qa/support/wait_for_requests'
end
end
......
......@@ -8,6 +8,7 @@ module QA
prepend Support::Page::Logging if Runtime::Env.debug?
include Capybara::DSL
include Scenario::Actable
include Support::WaitForRequests
extend Validatable
extend SingleForwardable
......@@ -21,6 +22,8 @@ module QA
def refresh
page.refresh
wait_for_requests
end
def wait(max: 60, interval: 0.1, reload: true)
......@@ -42,6 +45,8 @@ module QA
end
def scroll_to(selector, text: nil)
wait_for_requests
page.execute_script <<~JS
var elements = Array.from(document.querySelectorAll('#{selector}'));
var text = '#{text}';
......@@ -74,6 +79,8 @@ module QA
end
def find_element(name, **kwargs)
wait_for_requests
find(element_selector_css(name), kwargs)
end
......@@ -82,6 +89,8 @@ module QA
end
def all_elements(name, **kwargs)
wait_for_requests
all(element_selector_css(name), **kwargs)
end
......@@ -120,6 +129,8 @@ module QA
end
def has_element?(name, **kwargs)
wait_for_requests
wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time
text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil
......@@ -127,6 +138,8 @@ module QA
end
def has_no_element?(name, **kwargs)
wait_for_requests
wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time
text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil
......@@ -134,18 +147,24 @@ module QA
end
def has_text?(text, wait: Capybara.default_max_wait_time)
wait_for_requests
page.has_text?(text, wait: wait)
end
def has_no_text?(text)
wait_for_requests
page.has_no_text? text
end
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
def finished_loading?
wait_for_requests
# The number of selectors should be able to be reduced after
# migration to the new spinner is complete.
# https://gitlab.com/groups/gitlab-org/-/epics/956
......@@ -153,6 +172,8 @@ module QA
end
def finished_loading_block?
wait_for_requests
has_no_css?('.fa-spinner.block-loading', wait: Capybara.default_max_wait_time)
end
......@@ -220,10 +241,14 @@ module QA
end
def click_link_with_text(text)
wait_for_requests
click_link text
end
def click_body
wait_for_requests
find('body').click
end
......
......@@ -66,10 +66,16 @@ module QA
def visit!
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
visit(web_url)
wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) }
end
# Wait until the new page is ready for us to interact with it
Support::WaitForRequests.wait_for_requests
end
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
'TRIGGER_SOURCE' => ENV['CI_JOB_URL'],
'TOP_UPSTREAM_SOURCE_PROJECT' => ENV['CI_PROJECT_PATH'],
'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
......
# 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
expect(page).to have_content 'runner-active'
expect(page).to have_content 'runner-paused'
input_filtered_search_keys('status:active')
input_filtered_search_keys('status=active')
expect(page).to have_content 'runner-active'
expect(page).not_to have_content 'runner-paused'
end
......@@ -68,7 +68,7 @@ describe "Admin Runners" do
visit admin_runners_path
input_filtered_search_keys('status:offline')
input_filtered_search_keys('status=offline')
expect(page).not_to have_content 'runner-active'
expect(page).not_to have_content 'runner-paused'
......@@ -83,12 +83,12 @@ describe "Admin Runners" do
visit admin_runners_path
input_filtered_search_keys('status:active')
input_filtered_search_keys('status=active')
expect(page).to have_content 'runner-a-1'
expect(page).to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
input_filtered_search_keys('status:active runner-a')
input_filtered_search_keys('status=active runner-a')
expect(page).to have_content 'runner-a-1'
expect(page).not_to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
......@@ -105,7 +105,7 @@ describe "Admin Runners" do
expect(page).to have_content 'runner-project'
expect(page).to have_content 'runner-group'
input_filtered_search_keys('type:project_type')
input_filtered_search_keys('type=project_type')
expect(page).to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
end
......@@ -116,7 +116,7 @@ describe "Admin Runners" do
visit admin_runners_path
input_filtered_search_keys('type:instance_type')
input_filtered_search_keys('type=instance_type')
expect(page).not_to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
......@@ -131,12 +131,12 @@ describe "Admin Runners" do
visit admin_runners_path
input_filtered_search_keys('type:project_type')
input_filtered_search_keys('type=project_type')
expect(page).to have_content 'runner-a-1'
expect(page).to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
input_filtered_search_keys('type:project_type runner-a')
input_filtered_search_keys('type=project_type runner-a')
expect(page).to have_content 'runner-a-1'
expect(page).not_to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
......@@ -153,7 +153,7 @@ describe "Admin Runners" do
expect(page).to have_content 'runner-blue'
expect(page).to have_content 'runner-red'
input_filtered_search_keys('tag:blue')
input_filtered_search_keys('tag=blue')
expect(page).to have_content 'runner-blue'
expect(page).not_to have_content 'runner-red'
......@@ -165,7 +165,7 @@ describe "Admin Runners" do
visit admin_runners_path
input_filtered_search_keys('tag:red')
input_filtered_search_keys('tag=red')
expect(page).not_to have_content 'runner-blue'
expect(page).not_to have_content 'runner-blue'
......@@ -179,13 +179,13 @@ describe "Admin Runners" do
visit admin_runners_path
input_filtered_search_keys('tag:blue')
input_filtered_search_keys('tag=blue')
expect(page).to have_content 'runner-a-1'
expect(page).to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
input_filtered_search_keys('tag:blue runner-a')
input_filtered_search_keys('tag=blue runner-a')
expect(page).to have_content 'runner-a-1'
expect(page).not_to have_content 'runner-b-1'
......
......@@ -628,7 +628,7 @@ describe 'Issue Boards', :js do
end
def set_filter(type, text)
find('.filtered-search').native.send_keys("#{type}:#{text}")
find('.filtered-search').native.send_keys("#{type}=#{text}")
end
def submit_filter
......
......@@ -211,7 +211,7 @@ describe 'Issue Boards add issue modal filtering', :js do
end
def set_filter(type, text = '')
find('.add-issues-modal .filtered-search').native.send_keys("#{type}:#{text}")
find('.add-issues-modal .filtered-search').native.send_keys("#{type}=#{text}")
end
def submit_filter
......
......@@ -28,14 +28,14 @@ describe 'Dashboard Issues filtering', :js do
context 'filtering by milestone' do
it 'shows all issues with no milestone' do
input_filtered_search("milestone:none")
input_filtered_search("milestone=none")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_selector('.issue', count: 1)
end
it 'shows all issues with the selected milestone' do
input_filtered_search("milestone:%\"#{milestone.title}\"")
input_filtered_search("milestone=%\"#{milestone.title}\"")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_selector('.issue', count: 1)
......@@ -63,7 +63,7 @@ describe 'Dashboard Issues filtering', :js do
let!(:label_link) { create(:label_link, label: label, target: issue) }
it 'shows all issues with the selected label' do
input_filtered_search("label:~#{label.title}")
input_filtered_search("label=~#{label.title}")
page.within 'ul.content-list' do
expect(page).to have_content issue.title
......
......@@ -30,7 +30,7 @@ RSpec.describe 'Dashboard Issues' do
it 'shows issues when current user is author', :js do
reset_filters
input_filtered_search("author:#{current_user.to_reference}")
input_filtered_search("author=#{current_user.to_reference}")
expect(page).to have_content(authored_issue.title)
expect(page).to have_content(authored_issue_on_public_project.title)
......
......@@ -107,7 +107,7 @@ describe 'Dashboard Merge Requests' do
it 'shows authored merge requests', :js do
reset_filters
input_filtered_search("author:#{current_user.to_reference}")
input_filtered_search("author=#{current_user.to_reference}")
expect(page).to have_content(authored_merge_request.title)
expect(page).to have_content(authored_merge_request_from_fork.title)
......@@ -120,7 +120,7 @@ describe 'Dashboard Merge Requests' do
it 'shows labeled merge requests', :js do
reset_filters
input_filtered_search("label:#{label.name}")
input_filtered_search("label=#{label.name}")
expect(page).to have_content(labeled_merge_request.title)
......
......@@ -48,7 +48,7 @@ describe 'Group issues page' do
let(:user2) { user_outside_group }
it 'filters by only group users' do
filtered_search.set('assignee:')
filtered_search.set('assignee=')
expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name)
expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name)
......
......@@ -52,7 +52,7 @@ describe 'Group merge requests page' do
let(:user2) { user_outside_group }
it 'filters by assignee only group users' do
filtered_search.set('assignee:')
filtered_search.set('assignee=')
expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name)
expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name)
......
......@@ -20,13 +20,13 @@ describe 'Dropdown assignee', :js do
describe 'behavior' do
it 'loads all the assignees when opened' do
input_filtered_search('assignee:', submit: false, extra_space: false)
input_filtered_search('assignee=', submit: false, extra_space: false)
expect_filtered_search_dropdown_results(filter_dropdown, 2)
end
it 'shows current user at top of dropdown' do
input_filtered_search('assignee:', submit: false, extra_space: false)
input_filtered_search('assignee=', submit: false, extra_space: false)
expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name)
end
......@@ -35,7 +35,7 @@ describe 'Dropdown assignee', :js do
describe 'selecting from dropdown without Ajax call' do
before do
Gitlab::Testing::RequestBlockerMiddleware.block_requests!
input_filtered_search('assignee:', submit: false, extra_space: false)
input_filtered_search('assignee=', submit: false, extra_space: false)
end
after do
......
......@@ -20,13 +20,13 @@ describe 'Dropdown author', :js do
describe 'behavior' do
it 'loads all the authors when opened' do
input_filtered_search('author:', submit: false, extra_space: false)
input_filtered_search('author=', submit: false, extra_space: false)
expect_filtered_search_dropdown_results(filter_dropdown, 2)
end
it 'shows current user at top of dropdown' do
input_filtered_search('author:', submit: false, extra_space: false)
input_filtered_search('author=', submit: false, extra_space: false)
expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name)
end
......@@ -35,7 +35,7 @@ describe 'Dropdown author', :js do
describe 'selecting from dropdown without Ajax call' do
before do
Gitlab::Testing::RequestBlockerMiddleware.block_requests!
input_filtered_search('author:', submit: false, extra_space: false)
input_filtered_search('author=', submit: false, extra_space: false)
end
after do
......
......@@ -27,14 +27,14 @@ describe 'Dropdown base', :js do
it 'shows loading indicator when opened' do
slow_requests do
# We aren't using `input_filtered_search` because we want to see the loading indicator
filtered_search.set('assignee:')
filtered_search.set('assignee=')
expect(page).to have_css("#{js_dropdown_assignee} .filter-dropdown-loading", visible: true)
end
end
it 'hides loading indicator when loaded' do
input_filtered_search('assignee:', submit: false, extra_space: false)
input_filtered_search('assignee=', submit: false, extra_space: false)
expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading')
end
......@@ -42,7 +42,7 @@ describe 'Dropdown base', :js do
describe 'caching requests' do
it 'caches requests after the first load' do
input_filtered_search('assignee:', submit: false, extra_space: false)
input_filtered_search('assignee=', submit: false, extra_space: false)
initial_size = dropdown_assignee_size
expect(initial_size).to be > 0
......@@ -50,7 +50,7 @@ describe 'Dropdown base', :js do
new_user = create(:user)
project.add_maintainer(new_user)
find('.filtered-search-box .clear-search').click
input_filtered_search('assignee:', submit: false, extra_space: false)
input_filtered_search('assignee=', submit: false, extra_space: false)
expect(dropdown_assignee_size).to eq(initial_size)
end
......
......@@ -26,8 +26,8 @@ describe 'Dropdown emoji', :js do
end
describe 'behavior' do
it 'does not open when the search bar has my-reaction:' do
filtered_search.set('my-reaction:')
it 'does not open when the search bar has my-reaction=' do
filtered_search.set('my-reaction=')
expect(page).not_to have_css(js_dropdown_emoji)
end
......@@ -42,20 +42,20 @@ describe 'Dropdown emoji', :js do
end
describe 'behavior' do
it 'opens when the search bar has my-reaction:' do
filtered_search.set('my-reaction:')
it 'opens when the search bar has my-reaction=' do
filtered_search.set('my-reaction=')
expect(page).to have_css(js_dropdown_emoji, visible: true)
end
it 'loads all the emojis when opened' do
input_filtered_search('my-reaction:', submit: false, extra_space: false)
input_filtered_search('my-reaction=', submit: false, extra_space: false)
expect_filtered_search_dropdown_results(filter_dropdown, 3)
end
it 'shows the most populated emoji at top of dropdown' do
input_filtered_search('my-reaction:', submit: false, extra_space: false)
input_filtered_search('my-reaction=', submit: false, extra_space: false)
expect(first("#{js_dropdown_emoji} .filter-dropdown li")).to have_content(award_emoji_star.name)
end
......
......@@ -9,11 +9,16 @@ describe 'Dropdown hint', :js do
let!(:user) { create(:user) }
let(:filtered_search) { find('.filtered-search') }
let(:js_dropdown_hint) { '#js-dropdown-hint' }
let(:js_dropdown_operator) { '#js-dropdown-operator' }
def click_hint(text)
find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click
end
def click_operator(op)
find("#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value='#{op}']").click
end
before do
project.add_maintainer(user)
create(:issue, project: project)
......@@ -27,7 +32,7 @@ describe 'Dropdown hint', :js do
it 'does not exist my-reaction dropdown item' do
expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).not_to have_content('my-reaction')
expect(page).not_to have_content('My-reaction')
end
end
......@@ -54,15 +59,6 @@ describe 'Dropdown hint', :js do
end
describe 'filtering' do
it 'does not filter `Press Enter or click to search`' do
filtered_search.set('randomtext')
hint_dropdown = find(js_dropdown_hint)
expect(hint_dropdown).to have_content('Press Enter or click to search')
expect(hint_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 0)
end
it 'filters with text' do
filtered_search.set('a')
......@@ -76,21 +72,27 @@ describe 'Dropdown hint', :js do
end
it 'opens the token dropdown when you click on it' do
click_hint('author')
click_hint('Author')
expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).to have_css(js_dropdown_operator, visible: true)
click_operator('=')
expect(page).to have_css(js_dropdown_hint, visible: false)
expect(page).to have_css(js_dropdown_operator, visible: false)
expect(page).to have_css('#js-dropdown-author', visible: true)
expect_tokens([{ name: 'Author' }])
expect_tokens([{ name: 'Author', operator: '=' }])
expect_filtered_search_input_empty
end
end
describe 'reselecting from dropdown' do
it 'reuses existing token text' do
filtered_search.send_keys('author:')
filtered_search.send_keys('author')
filtered_search.send_keys(:backspace)
filtered_search.send_keys(:backspace)
click_hint('author')
click_hint('Author')
expect_tokens([{ name: 'Author' }])
expect_filtered_search_input_empty
......
......@@ -21,7 +21,7 @@ describe 'Dropdown label', :js do
describe 'behavior' do
it 'loads all the labels when opened' do
create(:label, project: project, title: 'bug-label')
filtered_search.set('label:')
filtered_search.set('label=')
expect_filtered_search_dropdown_results(filter_dropdown, 1)
end
......
......@@ -23,7 +23,7 @@ describe 'Dropdown milestone', :js do
describe 'behavior' do
before do
filtered_search.set('milestone:')
filtered_search.set('milestone=')
end
it 'loads all the milestones when opened' do
......
......@@ -23,7 +23,7 @@ describe 'Dropdown release', :js do
describe 'behavior' do
before do
filtered_search.set('release:')
filtered_search.set('release=')
end
it 'loads all the releases when opened' do
......
......@@ -67,7 +67,7 @@ describe 'Filter issues', :js do
it 'filters by all available tokens' do
search_term = 'issue'
input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}")
input_filtered_search("assignee=@#{user.username} author=@#{user.username} label=~#{caps_sensitive_label.title} milestone=%#{milestone.title} #{search_term}")
wait_for_requests
......@@ -84,7 +84,7 @@ describe 'Filter issues', :js do
describe 'filter issues by author' do
context 'only author' do
it 'filters issues by searched author' do
input_filtered_search("author:@#{user.username}")
input_filtered_search("author=@#{user.username}")
wait_for_requests
......@@ -98,7 +98,7 @@ describe 'Filter issues', :js do
describe 'filter issues by assignee' do
context 'only assignee' do
it 'filters issues by searched assignee' do
input_filtered_search("assignee:@#{user.username}")
input_filtered_search("assignee=@#{user.username}")
wait_for_requests
......@@ -108,7 +108,7 @@ describe 'Filter issues', :js do
end
it 'filters issues by no assignee' do
input_filtered_search('assignee:none')
input_filtered_search('assignee=none')
expect_tokens([assignee_token('None')])
expect_issues_list_count(3)
......@@ -122,7 +122,7 @@ describe 'Filter issues', :js do
it 'filters issues by multiple assignees' do
create(:issue, project: project, author: user, assignees: [user2, user])
input_filtered_search("assignee:@#{user.username} assignee:@#{user2.username}")
input_filtered_search("assignee=@#{user.username} assignee=@#{user2.username}")
expect_tokens([
assignee_token(user.name),
......@@ -138,15 +138,31 @@ describe 'Filter issues', :js do
describe 'filter issues by label' do
context 'only label' do
it 'filters issues by searched label' do
input_filtered_search("label:~#{bug_label.title}")
input_filtered_search("label=~#{bug_label.title}")
expect_tokens([label_token(bug_label.title)])
expect_issues_list_count(2)
expect_filtered_search_input_empty
end
it 'filters issues not containing searched label' do
input_filtered_search("label!=~#{bug_label.title}")
expect_tokens([label_token(bug_label.title)])
expect_issues_list_count(6)
expect_filtered_search_input_empty
end
it 'filters issues by no label' do
input_filtered_search('label=none')
expect_tokens([label_token('None', false)])
expect_issues_list_count(4)
expect_filtered_search_input_empty
end
it 'filters issues by no label' do
input_filtered_search('label:none')
input_filtered_search('label!=none')
expect_tokens([label_token('None', false)])
expect_issues_list_count(4)
......@@ -154,7 +170,18 @@ describe 'Filter issues', :js do
end
it 'filters issues by multiple labels' do
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title}")
input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title}")
expect_tokens([
label_token(bug_label.title),
label_token(caps_sensitive_label.title)
])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
it 'filters issues by multiple labels with not operator' do
input_filtered_search("label!=~#{bug_label.title} label=~#{caps_sensitive_label.title}")
expect_tokens([
label_token(bug_label.title),
......@@ -169,22 +196,42 @@ describe 'Filter issues', :js do
special_issue = create(:issue, title: "Issue with special character label", project: project)
special_issue.labels << special_label
input_filtered_search("label:~#{special_label.title}")
input_filtered_search("label=~#{special_label.title}")
expect_tokens([label_token(special_label.title)])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
it 'filters issues by label not containing special characters' do
special_label = create(:label, project: project, title: '!@#{$%^&*()-+[]<>?/:{}|\}')
special_issue = create(:issue, title: "Issue with special character label", project: project)
special_issue.labels << special_label
input_filtered_search("label!=~#{special_label.title}")
expect_tokens([label_token(special_label.title)])
expect_issues_list_count(8)
expect_filtered_search_input_empty
end
it 'does not show issues for unused labels' do
new_label = create(:label, project: project, title: 'new_label')
input_filtered_search("label:~#{new_label.title}")
input_filtered_search("label=~#{new_label.title}")
expect_tokens([label_token(new_label.title)])
expect_no_issues_list
expect_filtered_search_input_empty
end
it 'does show issues for bug label' do
input_filtered_search("label!=~#{bug_label.title}")
expect_tokens([label_token(bug_label.title)])
expect_issues_list_count(6)
expect_filtered_search_input_empty
end
end
context 'label with multiple words' do
......@@ -193,7 +240,7 @@ describe 'Filter issues', :js do
special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project)
special_multiple_issue.labels << special_multiple_label
input_filtered_search("label:~'#{special_multiple_label.title}'")
input_filtered_search("label=~'#{special_multiple_label.title}'")
# Check for search results (which makes sure that the page has changed)
expect_issues_list_count(1)
......@@ -205,7 +252,7 @@ describe 'Filter issues', :js do
end
it 'single quotes' do
input_filtered_search("label:~'#{multiple_words_label.title}'")
input_filtered_search("label=~'#{multiple_words_label.title}'")
expect_issues_list_count(1)
expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
......@@ -213,7 +260,7 @@ describe 'Filter issues', :js do
end
it 'double quotes' do
input_filtered_search("label:~\"#{multiple_words_label.title}\"")
input_filtered_search("label=~\"#{multiple_words_label.title}\"")
expect_tokens([label_token("\"#{multiple_words_label.title}\"")])
expect_issues_list_count(1)
......@@ -225,7 +272,7 @@ describe 'Filter issues', :js do
double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project)
double_quotes_label_issue.labels << double_quotes_label
input_filtered_search("label:~'#{double_quotes_label.title}'")
input_filtered_search("label=~'#{double_quotes_label.title}'")
expect_tokens([label_token("'#{double_quotes_label.title}'")])
expect_issues_list_count(1)
......@@ -237,7 +284,7 @@ describe 'Filter issues', :js do
single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project)
single_quotes_label_issue.labels << single_quotes_label
input_filtered_search("label:~\"#{single_quotes_label.title}\"")
input_filtered_search("label=~\"#{single_quotes_label.title}\"")
expect_tokens([label_token("\"#{single_quotes_label.title}\"")])
expect_issues_list_count(1)
......@@ -249,7 +296,7 @@ describe 'Filter issues', :js do
it 'filters issues by searched label, label2, author, assignee, milestone and text' do
search_term = 'bug'
input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}")
input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title} author=@#{user.username} assignee=@#{user.username} milestone=%#{milestone.title} #{search_term}")
wait_for_requests
......@@ -263,6 +310,24 @@ describe 'Filter issues', :js do
expect_issues_list_count(1)
expect_filtered_search_input(search_term)
end
it 'filters issues by searched label, label2, author, assignee, not included in a milestone' do
search_term = 'bug'
input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title} author=@#{user.username} assignee=@#{user.username} milestone!=%#{milestone.title} #{search_term}")
wait_for_requests
expect_tokens([
label_token(bug_label.title),
label_token(caps_sensitive_label.title),
author_token(user.name),
assignee_token(user.name),
milestone_token(milestone.title, false, '!=')
])
expect_issues_list_count(0)
expect_filtered_search_input(search_term)
end
end
context 'issue label clicked' do
......@@ -279,7 +344,7 @@ describe 'Filter issues', :js do
describe 'filter issues by milestone' do
context 'only milestone' do
it 'filters issues by searched milestone' do
input_filtered_search("milestone:%#{milestone.title}")
input_filtered_search("milestone=%#{milestone.title}")
expect_tokens([milestone_token(milestone.title)])
expect_issues_list_count(5)
......@@ -287,53 +352,102 @@ describe 'Filter issues', :js do
end
it 'filters issues by no milestone' do
input_filtered_search("milestone:none")
input_filtered_search("milestone=none")
expect_tokens([milestone_token('None', false)])
expect_issues_list_count(3)
expect_filtered_search_input_empty
end
it 'filters issues by negation of no milestone' do
input_filtered_search("milestone!=none ")
expect_tokens([milestone_token('None', false, '!=')])
expect_issues_list_count(5)
expect_filtered_search_input_empty
end
it 'filters issues by upcoming milestones' do
create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone|
create(:issue, project: project, milestone: future_milestone, author: user)
end
input_filtered_search("milestone:upcoming")
input_filtered_search("milestone=upcoming")
expect_tokens([milestone_token('Upcoming', false)])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
it 'filters issues by negation of upcoming milestones' do
create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone|
create(:issue, project: project, milestone: future_milestone, author: user)
end
input_filtered_search("milestone!=upcoming")
expect_tokens([milestone_token('Upcoming', false, '!=')])
expect_issues_list_count(8)
expect_filtered_search_input_empty
end
it 'filters issues by started milestones' do
input_filtered_search("milestone:started")
input_filtered_search("milestone=started")
expect_tokens([milestone_token('Started', false)])
expect_issues_list_count(5)
expect_filtered_search_input_empty
end
it 'filters issues by negation of started milestones' do
input_filtered_search("milestone!=started")
expect_tokens([milestone_token('Started', false, '!=')])
expect_issues_list_count(3)
expect_filtered_search_input_empty
end
it 'filters issues by milestone containing special characters' do
special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
create(:issue, project: project, milestone: special_milestone)
input_filtered_search("milestone:%#{special_milestone.title}")
input_filtered_search("milestone=%#{special_milestone.title}")
expect_tokens([milestone_token(special_milestone.title)])
expect_issues_list_count(1)
expect_filtered_search_input_empty
end
it 'filters issues by milestone not containing special characters' do
special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
create(:issue, project: project, milestone: special_milestone)
input_filtered_search("milestone!=%#{special_milestone.title}")
expect_tokens([milestone_token(special_milestone.title, false, '!=')])
expect_issues_list_count(8)
expect_filtered_search_input_empty
end
it 'does not show issues for unused milestones' do
new_milestone = create(:milestone, title: 'new', project: project)
input_filtered_search("milestone:%#{new_milestone.title}")
input_filtered_search("milestone=%#{new_milestone.title}")
expect_tokens([milestone_token(new_milestone.title)])
expect_no_issues_list
expect_filtered_search_input_empty
end
it 'show issues for unused milestones' do
new_milestone = create(:milestone, title: 'new', project: project)
input_filtered_search("milestone!=%#{new_milestone.title}")
expect_tokens([milestone_token(new_milestone.title, false, '!=')])
expect_issues_list_count(8)
expect_filtered_search_input_empty
end
end
end
......@@ -407,7 +521,7 @@ describe 'Filter issues', :js do
context 'searched text with other filters' do
it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do
input_filtered_search("bug author:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo")
input_filtered_search("bug author=@#{user.username} report label=~#{bug_label.title} label=~#{caps_sensitive_label.title} milestone=%#{milestone.title} foo")
expect_issues_list_count(1)
expect_filtered_search_input('bug report foo')
......@@ -481,7 +595,7 @@ describe 'Filter issues', :js do
end
it 'milestone dropdown loads milestones' do
input_filtered_search("milestone:", submit: false)
input_filtered_search("milestone=", submit: false)
within('#js-dropdown-milestone') do
expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
......@@ -489,7 +603,7 @@ describe 'Filter issues', :js do
end
it 'label dropdown load labels' do
input_filtered_search("label:", submit: false)
input_filtered_search("label=", submit: false)
within('#js-dropdown-label') do
expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3)
......
......@@ -41,8 +41,8 @@ describe 'Recent searches', :js do
items = all('.filtered-search-history-dropdown-item', visible: false, count: 2)
expect(items[0].text).to eq('label: ~qux garply')
expect(items[1].text).to eq('label: ~foo bar')
expect(items[0].text).to eq('label: = ~qux garply')
expect(items[1].text).to eq('label: = ~foo bar')
end
it 'saved recent searches are restored last on the list' do
......
......@@ -34,7 +34,7 @@ describe 'Search bar', :js do
it 'selects item' do
filtered_search.native.send_keys(:down, :down, :enter)
expect_tokens([author_token])
expect_tokens([{ name: 'Assignee' }])
expect_filtered_search_input_empty
end
end
......@@ -78,7 +78,7 @@ describe 'Search bar', :js do
filtered_search.click
original_size = page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size
filtered_search.set('author')
filtered_search.set('autho')
expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1)
......
......@@ -36,8 +36,9 @@ describe 'Visual tokens', :js do
describe 'editing a single token' do
before do
input_filtered_search('author:@root assignee:none', submit: false)
input_filtered_search('author=@root assignee=none', submit: false)
first('.tokens-container .filtered-search-token').click
wait_for_requests
end
it 'opens author dropdown' do
......@@ -76,8 +77,8 @@ describe 'Visual tokens', :js do
describe 'editing multiple tokens' do
before do
input_filtered_search('author:@root assignee:none', submit: false)
first('.tokens-container .filtered-search-token').double_click
input_filtered_search('author=@root assignee=none', submit: false)
first('.tokens-container .filtered-search-token').click
end
it 'opens author dropdown' do
......@@ -85,27 +86,33 @@ describe 'Visual tokens', :js do
end
it 'opens assignee dropdown' do
find('.tokens-container .filtered-search-token', text: 'Assignee').double_click
find('.tokens-container .filtered-search-token', text: 'Assignee').click
expect(page).to have_css('#js-dropdown-assignee', visible: true)
end
end
describe 'editing a search term while editing another filter token' do
before do
input_filtered_search('author assignee:', submit: false)
first('.tokens-container .filtered-search-term').double_click
input_filtered_search('foo assignee=', submit: false)
first('.tokens-container .filtered-search-term').click
end
it 'opens author dropdown' do
find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'author').click
find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'Author').click
expect(page).to have_css('#js-dropdown-operator', visible: true)
expect(page).to have_css('#js-dropdown-author', visible: false)
find('#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value="="]').click
expect(page).to have_css('#js-dropdown-operator', visible: false)
expect(page).to have_css('#js-dropdown-author', visible: true)
end
end
describe 'add new token after editing existing token' do
before do
input_filtered_search('author:@root assignee:none', submit: false)
input_filtered_search('author=@root assignee=none', submit: false)
first('.tokens-container .filtered-search-token').double_click
filtered_search.send_keys(' ')
end
......@@ -116,7 +123,7 @@ describe 'Visual tokens', :js do
end
it 'opens token dropdown' do
filtered_search.send_keys('author:')
filtered_search.send_keys('author=')
expect(page).to have_css('#js-dropdown-author', visible: true)
end
......@@ -124,7 +131,7 @@ describe 'Visual tokens', :js do
describe 'visual tokens' do
it 'creates visual token' do
filtered_search.send_keys('author:@thomas ')
filtered_search.send_keys('author=@thomas ')
token = page.all('.tokens-container .filtered-search-token')[1]
expect(token.find('.name').text).to eq('Author')
......@@ -133,7 +140,7 @@ describe 'Visual tokens', :js do
end
it 'does not tokenize incomplete token' do
filtered_search.send_keys('author:')
filtered_search.send_keys('author=')
find('body').click
token = page.all('.tokens-container .js-visual-token')[1]
......@@ -145,7 +152,7 @@ describe 'Visual tokens', :js do
describe 'search using incomplete visual tokens' do
before do
input_filtered_search('author:@root assignee:none', extra_space: false)
input_filtered_search('author=@root assignee=none', extra_space: false)
end
it 'tokenizes the search term to complete visual token' do
......
......@@ -70,7 +70,7 @@ describe 'Labels Hierarchy', :js do
end
it 'does not filter by descendant group labels' do
filtered_search.set("label:")
filtered_search.set("label=")
wait_for_requests
......@@ -134,7 +134,7 @@ describe 'Labels Hierarchy', :js do
end
it 'does not filter by descendant group project labels' do
filtered_search.set("label:")
filtered_search.set("label=")
wait_for_requests
......@@ -227,7 +227,7 @@ describe 'Labels Hierarchy', :js do
it_behaves_like 'filtering by ancestor labels for projects'
it 'does not filter by descendant group labels' do
filtered_search.set("label:")
filtered_search.set("label=")
wait_for_requests
......
......@@ -23,7 +23,7 @@ describe 'Merge Requests > Filters generic behavior', :js do
context 'when filtered by a label' do
before do
input_filtered_search('label:~bug')
input_filtered_search('label=~bug')
end
describe 'state tabs' do
......
......@@ -18,7 +18,7 @@ describe 'Merge Requests > User filters by assignees', :js do
context 'filtering by assignee:none' do
it 'applies the filter' do
input_filtered_search('assignee:none')
input_filtered_search('assignee=none')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).not_to have_content 'Bugfix1'
......@@ -26,9 +26,9 @@ describe 'Merge Requests > User filters by assignees', :js do
end
end
context 'filtering by assignee:@username' do
context 'filtering by assignee=@username' do
it 'applies the filter' do
input_filtered_search("assignee:@#{user.username}")
input_filtered_search("assignee=@#{user.username}")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix1'
......
......@@ -22,7 +22,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:none' do
it 'applies the filter' do
input_filtered_search('label:none')
input_filtered_search('label=none')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).not_to have_content 'Bugfix1'
......@@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:~enhancement' do
it 'applies the filter' do
input_filtered_search('label:~enhancement')
input_filtered_search('label=~enhancement')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2'
......@@ -42,7 +42,7 @@ describe 'Merge Requests > User filters by labels', :js do
context 'filtering by label:~enhancement and label:~bug' do
it 'applies the filters' do
input_filtered_search('label:~bug label:~enhancement')
input_filtered_search('label=~bug label=~enhancement')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2'
......
......@@ -18,14 +18,14 @@ describe 'Merge Requests > User filters by milestones', :js do
end
it 'filters by no milestone' do
input_filtered_search('milestone:none')
input_filtered_search('milestone=none')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_css('.merge-request', count: 1)
end
it 'filters by a specific milestone' do
input_filtered_search("milestone:%'#{milestone.title}'")
input_filtered_search("milestone=%'#{milestone.title}'")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_css('.merge-request', count: 1)
......@@ -33,7 +33,7 @@ describe 'Merge Requests > User filters by milestones', :js do
describe 'filters by upcoming milestone' do
it 'does not show merge requests with no expiry' do
input_filtered_search('milestone:upcoming')
input_filtered_search('milestone=upcoming')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).to have_css('.merge-request', count: 0)
......@@ -43,7 +43,7 @@ describe 'Merge Requests > User filters by milestones', :js do
let(:milestone) { create(:milestone, project: project, due_date: Date.tomorrow) }
it 'shows merge requests' do
input_filtered_search('milestone:upcoming')
input_filtered_search('milestone=upcoming')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_css('.merge-request', count: 1)
......@@ -54,7 +54,7 @@ describe 'Merge Requests > User filters by milestones', :js do
let(:milestone) { create(:milestone, project: project, due_date: Date.yesterday) }
it 'does not show any merge requests' do
input_filtered_search('milestone:upcoming')
input_filtered_search('milestone=upcoming')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).to have_css('.merge-request', count: 0)
......
......@@ -20,7 +20,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do
describe 'filtering by label:~"Won\'t fix" and assignee:~bug' do
it 'applies the filters' do
input_filtered_search("label:~\"Won't fix\" assignee:@#{user.username}")
input_filtered_search("label=~\"Won't fix\" assignee=@#{user.username}")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2'
......@@ -30,7 +30,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do
describe 'filtering by text, author, assignee, milestone, and label' do
it 'filters by text, author, assignee, milestone, and label' do
input_filtered_search_keys("author:@#{user.username} assignee:@#{user.username} milestone:%\"v1.1\" label:~\"Won't fix\" Bug")
input_filtered_search_keys("author=@#{user.username} assignee=@#{user.username} milestone=%\"v1.1\" label=~\"Won't fix\" Bug")
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content 'Bugfix2'
......
......@@ -17,7 +17,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:master' do
it 'applies the filter' do
input_filtered_search('target-branch:master')
input_filtered_search('target-branch=master')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).to have_content mr1.title
......@@ -27,7 +27,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:merged-target' do
it 'applies the filter' do
input_filtered_search('target-branch:merged-target')
input_filtered_search('target-branch=merged-target')
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
expect(page).not_to have_content mr1.title
......@@ -37,7 +37,7 @@ describe 'Merge Requests > User filters by target branch', :js do
context 'filtering by target-branch:feature' do
it 'applies the filter' do
input_filtered_search('target-branch:feature')
input_filtered_search('target-branch=feature')
expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0)
expect(page).not_to have_content mr1.title
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import {
GlEmptyState,
GlLoadingIcon,
GlTable,
GlLink,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlPagination,
} from '@gitlab/ui';
import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination } from '@gitlab/ui';
import stubChildren from 'helpers/stub_children';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
import errorsList from './list_mock.json';
......@@ -32,19 +24,12 @@ describe('ErrorTrackingList', () => {
function mountComponent({
errorTrackingEnabled = true,
userCanEnableErrorTracking = true,
sync = true,
stubs = {
'gl-link': GlLink,
'gl-table': GlTable,
'gl-pagination': GlPagination,
'gl-dropdown': GlDropdown,
'gl-dropdown-item': GlDropdownItem,
},
stubs = {},
} = {}) {
wrapper = shallowMount(ErrorTrackingList, {
wrapper = mount(ErrorTrackingList, {
localVue,
store,
sync,
sync: false,
propsData: {
indexPath: '/path',
enableErrorTrackingLink: '/link',
......@@ -52,7 +37,10 @@ describe('ErrorTrackingList', () => {
errorTrackingEnabled,
illustrationPath: 'illustration/path',
},
stubs,
stubs: {
...stubChildren(ErrorTrackingList),
...stubs,
},
data() {
return { errorSearchQuery: 'search' };
},
......@@ -122,7 +110,14 @@ describe('ErrorTrackingList', () => {
beforeEach(() => {
store.state.list.loading = false;
store.state.list.errors = errorsList;
mountComponent();
mountComponent({
stubs: {
GlTable: false,
GlDropdown: false,
GlDropdownItem: false,
GlLink: false,
},
});
});
it('shows table', () => {
......@@ -173,7 +168,13 @@ describe('ErrorTrackingList', () => {
store.state.list.loading = false;
store.state.list.errors = [];
mountComponent();
mountComponent({
stubs: {
GlTable: false,
GlDropdown: false,
GlDropdownItem: false,
},
});
});
it('shows empty table', () => {
......@@ -187,7 +188,7 @@ describe('ErrorTrackingList', () => {
});
it('restarts polling', () => {
findRefreshLink().trigger('click');
findRefreshLink().vm.$emit('click');
expect(actions.restartPolling).toHaveBeenCalled();
});
});
......@@ -211,8 +212,8 @@ describe('ErrorTrackingList', () => {
errorTrackingEnabled: false,
userCanEnableErrorTracking: false,
stubs: {
'gl-link': GlLink,
'gl-empty-state': GlEmptyState,
GlLink: false,
GlEmptyState: false,
},
});
});
......@@ -226,7 +227,12 @@ describe('ErrorTrackingList', () => {
describe('recent searches', () => {
beforeEach(() => {
mountComponent();
mountComponent({
stubs: {
GlDropdown: false,
GlDropdownItem: false,
},
});
});
it('shows empty message', () => {
......@@ -238,12 +244,13 @@ describe('ErrorTrackingList', () => {
it('shows items', () => {
store.state.list.recentSearches = ['great', 'search'];
return wrapper.vm.$nextTick().then(() => {
const dropdownItems = wrapper.findAll('.filtered-search-box li');
expect(dropdownItems.length).toBe(3);
expect(dropdownItems.at(0).text()).toBe('great');
expect(dropdownItems.at(1).text()).toBe('search');
});
});
describe('clear', () => {
const clearRecentButton = () => wrapper.find({ ref: 'clearRecentSearches' });
......@@ -257,19 +264,23 @@ describe('ErrorTrackingList', () => {
it('is visible when list has items', () => {
store.state.list.recentSearches = ['some', 'searches'];
return wrapper.vm.$nextTick().then(() => {
expect(clearRecentButton().exists()).toBe(true);
expect(clearRecentButton().text()).toBe('Clear recent searches');
});
});
it('clears items on click', () => {
store.state.list.recentSearches = ['some', 'searches'];
return wrapper.vm.$nextTick().then(() => {
clearRecentButton().vm.$emit('click');
expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1);
});
});
});
});
describe('When pagination is not required', () => {
beforeEach(() => {
......@@ -287,7 +298,11 @@ describe('ErrorTrackingList', () => {
describe('and the user is on the first page', () => {
beforeEach(() => {
store.state.list.loading = false;
mountComponent({ sync: false });
mountComponent({
stubs: {
GlPagination: false,
},
});
});
it('shows a disabled Prev button', () => {
......@@ -299,8 +314,14 @@ describe('ErrorTrackingList', () => {
describe('and the previous button is clicked', () => {
beforeEach(() => {
store.state.list.loading = false;
mountComponent({ sync: false });
mountComponent({
stubs: {
GlTable: false,
GlPagination: false,
},
});
wrapper.setData({ pageValue: 2 });
return wrapper.vm.$nextTick();
});
it('fetches the previous page of results', () => {
......@@ -318,7 +339,7 @@ describe('ErrorTrackingList', () => {
describe('and the next page button is clicked', () => {
beforeEach(() => {
store.state.list.loading = false;
mountComponent({ sync: false });
mountComponent();
});
it('fetches the next page of results', () => {
......
......@@ -124,6 +124,7 @@ describe('Filtered Search Token Keys', () => {
const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue(
null,
null,
null,
);
expect(condition).toBeNull();
......@@ -132,6 +133,7 @@ describe('Filtered Search Token Keys', () => {
it('should return condition when found by tokenKey and value', () => {
const result = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue(
conditions[0].tokenKey,
conditions[0].operator,
conditions[0].value,
);
......
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 ModalStub from './stubs/modal_stub';
......@@ -22,17 +22,13 @@ describe('Users admin page Modal Manager', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(UserModalManager, {
wrapper = mount(UserModalManager, {
propsData: {
actionModals,
modalConfiguration,
csrfToken: 'dummyCSRF',
...props,
},
stubs: {
dummyComponent1: true,
dummyComponent2: true,
},
sync: false,
});
};
......
......@@ -398,14 +398,21 @@ describe('DropLab DropDown', function() {
describe('render', function() {
beforeEach(function() {
this.list = { querySelector: () => {}, dispatchEvent: () => {} };
this.dropdown = { renderChildren: () => {}, list: this.list };
this.renderableList = {};
this.list = {
querySelector: q => {
if (q === '.filter-dropdown-loading') {
return false;
}
return this.renderableList;
},
dispatchEvent: () => {},
};
this.dropdown = { renderChildren: () => {}, list: this.list };
this.data = [0, 1];
this.customEvent = {};
spyOn(this.dropdown, 'renderChildren').and.callFake(data => data);
spyOn(this.list, 'querySelector').and.returnValue(this.renderableList);
spyOn(this.list, 'dispatchEvent');
spyOn(this.data, 'map').and.callThrough();
spyOn(window, 'CustomEvent').and.returnValue(this.customEvent);
......
......@@ -222,7 +222,7 @@ describe('Dropdown Utils', () => {
hasAttribute: () => false,
};
DropdownUtils.setDataValueIfSelected(null, selected);
DropdownUtils.setDataValueIfSelected(null, '=', selected);
expect(FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
});
......@@ -233,9 +233,11 @@ describe('Dropdown Utils', () => {
hasAttribute: () => false,
};
const result = DropdownUtils.setDataValueIfSelected(null, selected);
const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
expect(result).toBe(true);
expect(result2).toBe(true);
});
it('returns false when dataValue does not exist', () => {
......@@ -243,9 +245,11 @@ describe('Dropdown Utils', () => {
getAttribute: () => null,
};
const result = DropdownUtils.setDataValueIfSelected(null, selected);
const result = DropdownUtils.setDataValueIfSelected(null, '=', selected);
const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected);
expect(result).toBe(false);
expect(result2).toBe(false);
});
});
......@@ -349,7 +353,7 @@ describe('Dropdown Utils', () => {
beforeEach(() => {
loadFixtures(issueListFixture);
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
const tokensContainer = document.querySelector('.tokens-container');
......@@ -364,7 +368,7 @@ describe('Dropdown Utils', () => {
const searchQuery = DropdownUtils.getSearchQuery();
expect(searchQuery).toBe(' search term author:original dance');
expect(searchQuery).toBe(' search term author:=original dance');
});
});
});
......@@ -27,7 +27,7 @@ describe('Filtered Search Dropdown Manager', () => {
describe('input has no existing value', () => {
it('should add just tokenName', () => {
FilteredSearchDropdownManager.addWordToInput('milestone');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'milestone' });
const token = document.querySelector('.tokens-container .js-visual-token');
......@@ -36,8 +36,8 @@ describe('Filtered Search Dropdown Manager', () => {
expect(getInputValue()).toBe('');
});
it('should add tokenName and tokenValue', () => {
FilteredSearchDropdownManager.addWordToInput('label');
it('should add tokenName, tokenOperator, and tokenValue', () => {
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' });
let token = document.querySelector('.tokens-container .js-visual-token');
......@@ -45,13 +45,27 @@ describe('Filtered Search Dropdown Manager', () => {
expect(token.querySelector('.name').innerText).toBe('label');
expect(getInputValue()).toBe('');
FilteredSearchDropdownManager.addWordToInput('label', 'none');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label', tokenOperator: '=' });
token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(getInputValue()).toBe('');
FilteredSearchDropdownManager.addWordToInput({
tokenName: 'label',
tokenOperator: '=',
tokenValue: 'none',
});
// We have to get that reference again
// Because FilteredSearchDropdownManager deletes the previous token
token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(token.querySelector('.value').innerText).toBe('none');
expect(getInputValue()).toBe('');
});
......@@ -60,7 +74,7 @@ describe('Filtered Search Dropdown Manager', () => {
describe('input has existing value', () => {
it('should be able to just add tokenName', () => {
setInputValue('a');
FilteredSearchDropdownManager.addWordToInput('author');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' });
const token = document.querySelector('.tokens-container .js-visual-token');
......@@ -70,29 +84,40 @@ describe('Filtered Search Dropdown Manager', () => {
});
it('should replace tokenValue', () => {
FilteredSearchDropdownManager.addWordToInput('author');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' });
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author', tokenOperator: '=' });
setInputValue('roo');
FilteredSearchDropdownManager.addWordToInput(null, '@root');
FilteredSearchDropdownManager.addWordToInput({
tokenName: null,
tokenOperator: '=',
tokenValue: '@root',
});
const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('author');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(token.querySelector('.value').innerText).toBe('@root');
expect(getInputValue()).toBe('');
});
it('should add tokenValues containing spaces', () => {
FilteredSearchDropdownManager.addWordToInput('label');
FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' });
setInputValue('"test ');
FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\'');
FilteredSearchDropdownManager.addWordToInput({
tokenName: 'label',
tokenOperator: '=',
tokenValue: '~\'"test me"\'',
});
const token = document.querySelector('.tokens-container .js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toBe('label');
expect(token.querySelector('.operator').innerText).toBe('=');
expect(token.querySelector('.value').innerText).toBe('~\'"test me"\'');
expect(getInputValue()).toBe('');
});
......
......@@ -201,8 +201,8 @@ describe('Filtered Search Manager', function() {
it('removes duplicated tokens', done => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
`);
spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => {
......@@ -234,7 +234,7 @@ describe('Filtered Search Manager', function() {
it('should not render placeholder when there are tokens and no input', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
);
const event = new Event('input');
......@@ -252,7 +252,7 @@ describe('Filtered Search Manager', function() {
describe('tokens and no input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
);
});
......@@ -306,7 +306,7 @@ describe('Filtered Search Manager', function() {
it('removes token even when it is already selected', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
);
tokensContainer.querySelector('.js-visual-token .remove-token').click();
......@@ -319,7 +319,7 @@ describe('Filtered Search Manager', function() {
spyOn(FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'),
);
tokensContainer.querySelector('.js-visual-token .remove-token').click();
});
......@@ -338,7 +338,7 @@ describe('Filtered Search Manager', function() {
beforeEach(() => {
initializeManager();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
);
});
......@@ -424,7 +424,7 @@ describe('Filtered Search Manager', function() {
});
it('Clicking the "x" clear button, clears the input', () => {
const inputValue = 'label:~bug ';
const inputValue = 'label:=~bug';
manager.filteredSearchInput.value = inputValue;
manager.filteredSearchInput.dispatchEvent(new Event('input'));
......
......@@ -6,9 +6,10 @@ describe('Filtered Search Visual Tokens', () => {
const findElements = tokenElement => {
const tokenNameElement = tokenElement.querySelector('.name');
const tokenOperatorElement = tokenElement.querySelector('.operator');
const tokenValueContainer = tokenElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
return { tokenNameElement, tokenValueContainer, tokenValueElement };
return { tokenNameElement, tokenOperatorElement, tokenValueContainer, tokenValueElement };
};
let tokensContainer;
......@@ -23,8 +24,8 @@ describe('Filtered Search Visual Tokens', () => {
`);
tokensContainer = document.querySelector('.tokens-container');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
});
describe('getLastVisualTokenBeforeInput', () => {
......@@ -62,7 +63,7 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
`);
const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput();
......@@ -92,7 +93,7 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createInputHTML()}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
`);
const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput();
......@@ -105,7 +106,7 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')}
${FilteredSearchSpecHelper.createInputHTML()}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
`);
const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput();
......@@ -150,8 +151,8 @@ describe('Filtered Search Visual Tokens', () => {
it('removes the selected class from buttons', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@author')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '%123', true)}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@author')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '%123', true)}
`);
const selected = tokensContainer.querySelector('.js-visual-token .selected');
......@@ -169,7 +170,7 @@ describe('Filtered Search Visual Tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${bugLabelToken.outerHTML}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~awesome')}
`);
});
......@@ -206,7 +207,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('removeSelectedToken', () => {
it('does not remove when there are no selected tokens', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'),
);
expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null);
......@@ -218,7 +219,7 @@ describe('Filtered Search Visual Tokens', () => {
it('removes selected token', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true),
);
expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null);
......@@ -281,16 +282,22 @@ describe('Filtered Search Visual Tokens', () => {
describe('addVisualTokenElement', () => {
it('renders search visual tokens', () => {
subject.addVisualTokenElement('search term', null, { isSearchTerm: true });
subject.addVisualTokenElement({
name: 'search term',
operator: '=',
value: null,
options: { isSearchTerm: true },
});
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-term')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('search term');
expect(token.querySelector('.operator').innerText).toEqual('=');
expect(token.querySelector('.value')).toEqual(null);
});
it('renders filter visual token name', () => {
subject.addVisualTokenElement('milestone');
subject.addVisualTokenElement({ name: 'milestone' });
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('search-token-milestone')).toEqual(true);
......@@ -299,22 +306,23 @@ describe('Filtered Search Visual Tokens', () => {
expect(token.querySelector('.value')).toEqual(null);
});
it('renders filter visual token name and value', () => {
subject.addVisualTokenElement('label', 'Frontend');
it('renders filter visual token name, operator, and value', () => {
subject.addVisualTokenElement({ name: 'label', operator: '!=', value: 'Frontend' });
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('search-token-label')).toEqual(true);
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('label');
expect(token.querySelector('.operator').innerText).toEqual('!=');
expect(token.querySelector('.value').innerText).toEqual('Frontend');
});
it('inserts visual token before input', () => {
tokensContainer.appendChild(
FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root'),
FilteredSearchSpecHelper.createFilterVisualToken('assignee', '=', '@root'),
);
subject.addVisualTokenElement('label', 'Frontend');
subject.addVisualTokenElement({ name: 'label', operator: '!=', value: 'Frontend' });
const tokens = tokensContainer.querySelectorAll('.js-visual-token');
const labelToken = tokens[0];
const assigneeToken = tokens[1];
......@@ -323,18 +331,20 @@ describe('Filtered Search Visual Tokens', () => {
expect(labelToken.classList.contains('filtered-search-token')).toEqual(true);
expect(labelToken.querySelector('.name').innerText).toEqual('label');
expect(labelToken.querySelector('.value').innerText).toEqual('Frontend');
expect(labelToken.querySelector('.operator').innerText).toEqual('!=');
expect(assigneeToken.classList.contains('search-token-assignee')).toEqual(true);
expect(assigneeToken.classList.contains('filtered-search-token')).toEqual(true);
expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee');
expect(assigneeToken.querySelector('.value').innerText).toEqual('@root');
expect(assigneeToken.querySelector('.operator').innerText).toEqual('=');
});
});
describe('addValueToPreviousVisualTokenElement', () => {
it('does not add when previous visual token element has no value', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root'),
);
const original = tokensContainer.innerHTML;
......@@ -345,7 +355,7 @@ describe('Filtered Search Visual Tokens', () => {
it('does not add when previous visual token element is a search', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
`);
......@@ -357,7 +367,7 @@ describe('Filtered Search Visual Tokens', () => {
it('adds value to previous visual filter token', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label'),
FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label', '='),
);
const original = tokensContainer.innerHTML;
......@@ -377,25 +387,28 @@ describe('Filtered Search Visual Tokens', () => {
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('milestone');
expect(token.querySelector('.operator')).toEqual(null);
expect(token.querySelector('.value')).toEqual(null);
});
it('creates visual token with just tokenValue', () => {
subject.addFilterVisualToken('milestone');
subject.addFilterVisualToken('milestone', '=');
subject.addFilterVisualToken('%8.17');
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('milestone');
expect(token.querySelector('.operator').innerText).toEqual('=');
expect(token.querySelector('.value').innerText).toEqual('%8.17');
});
it('creates full visual token', () => {
subject.addFilterVisualToken('assignee', '@john');
subject.addFilterVisualToken('assignee', '=', '@john');
const token = tokensContainer.querySelector('.js-visual-token');
expect(token.classList.contains('filtered-search-token')).toEqual(true);
expect(token.querySelector('.name').innerText).toEqual('assignee');
expect(token.querySelector('.operator').innerText).toEqual('=');
expect(token.querySelector('.value').innerText).toEqual('@john');
});
});
......@@ -412,7 +425,7 @@ describe('Filtered Search Visual Tokens', () => {
it('appends to previous search visual token if previous token was a search token', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')}
`);
......@@ -467,7 +480,11 @@ describe('Filtered Search Visual Tokens', () => {
describe('removeLastTokenPartial', () => {
it('should remove the last token value if it exists', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~"Community Contribution"'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML(
'label',
'=',
'~"Community Contribution"',
),
);
expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null);
......@@ -507,7 +524,7 @@ describe('Filtered Search Visual Tokens', () => {
it('adds search visual token if previous visual token is valid', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', 'none'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', '=', 'none'),
);
const input = document.querySelector('.filtered-search');
......@@ -523,7 +540,7 @@ describe('Filtered Search Visual Tokens', () => {
it('adds value to previous visual token element if previous visual token is invalid', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee'),
FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('assignee', '='),
);
const input = document.querySelector('.filtered-search');
......@@ -534,6 +551,7 @@ describe('Filtered Search Visual Tokens', () => {
expect(input.value).toEqual('');
expect(updatedToken.querySelector('.name').innerText).toEqual('assignee');
expect(updatedToken.querySelector('.operator').innerText).toEqual('=');
expect(updatedToken.querySelector('.value').innerText).toEqual('@john');
});
});
......@@ -544,9 +562,9 @@ describe('Filtered Search Visual Tokens', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')}
${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'upcoming')}
`);
input = document.querySelector('.filtered-search');
......@@ -614,7 +632,7 @@ describe('Filtered Search Visual Tokens', () => {
describe('moveInputTotheRight', () => {
it('does nothing if the input is already the right most element', () => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'),
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none'),
);
spyOn(subject, 'tokenizeInput').and.callFake(() => {});
......@@ -628,12 +646,12 @@ describe('Filtered Search Visual Tokens', () => {
it("tokenize's input", () => {
tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')}
${FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label', '=')}
${FilteredSearchSpecHelper.createInputHTML()}
${bugLabelToken.outerHTML}
`;
document.querySelector('.filtered-search').value = 'none';
tokensContainer.querySelector('.filtered-search').value = 'none';
subject.moveInputToTheRight();
const value = tokensContainer.querySelector('.js-visual-token .value');
......@@ -643,7 +661,7 @@ describe('Filtered Search Visual Tokens', () => {
it('converts input into search term token if last token is valid', () => {
tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')}
${FilteredSearchSpecHelper.createInputHTML()}
${bugLabelToken.outerHTML}
`;
......@@ -658,7 +676,7 @@ describe('Filtered Search Visual Tokens', () => {
it('moves the input to the right most element', () => {
tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')}
${FilteredSearchSpecHelper.createInputHTML()}
${bugLabelToken.outerHTML}
`;
......@@ -670,8 +688,8 @@ describe('Filtered Search Visual Tokens', () => {
it('tokenizes input even if input is the right most element', () => {
tokensContainer.innerHTML = `
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')}
${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')}
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')}
${FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label')}
${FilteredSearchSpecHelper.createInputHTML('', '~bug')}
`;
......
......@@ -138,6 +138,7 @@ describe('Issues Filtered Search Token Keys', () => {
const conditions = IssuableFilteredSearchTokenKeys.getConditions();
const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(
conditions[0].tokenKey,
conditions[0].operator,
conditions[0].value,
);
......
......@@ -10,9 +10,11 @@ describe('Filtered Search Visual Tokens', () => {
const tokenNameElement = tokenElement.querySelector('.name');
const tokenValueContainer = tokenElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
const tokenOperatorElement = tokenElement.querySelector('.operator');
const tokenType = tokenNameElement.innerText.toLowerCase();
const tokenValue = tokenValueElement.innerText;
const subject = new VisualTokenValue(tokenValue, tokenType);
const tokenOperator = tokenOperatorElement.innerText;
const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
return { subject, tokenValueContainer, tokenValueElement };
};
......@@ -28,8 +30,8 @@ describe('Filtered Search Visual Tokens', () => {
`);
tokensContainer = document.querySelector('.tokens-container');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug');
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user');
bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug');
});
describe('updateUserTokenAppearance', () => {
......@@ -140,10 +142,12 @@ describe('Filtered Search Visual Tokens', () => {
const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label',
'=',
'~doesnotexist',
);
const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken(
'label',
'=',
'~"some space"',
);
......
export default class FilteredSearchSpecHelper {
static createFilterVisualTokenHTML(name, value, isSelected) {
return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML;
static createFilterVisualTokenHTML(name, operator, value, isSelected) {
return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected)
.outerHTML;
}
static createFilterVisualToken(name, value, isSelected = false) {
static createFilterVisualToken(name, operator, value, isSelected = false) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`);
li.innerHTML = `
<div class="selectable ${isSelected ? 'selected' : ''}" role="button">
<div class="name">${name}</div>
<div class="operator">${operator}</div>
<div class="value-container">
<div class="value">${value}</div>
<div class="remove-token" role="button">
......@@ -30,6 +32,15 @@ export default class FilteredSearchSpecHelper {
`;
}
static createNameOperatorFilterVisualTokenHTML(name, operator) {
return `
<li class="js-visual-token filtered-search-token">
<div class="name">${name}</div>
<div class="operator">${operator}</div>
</li>
`;
}
static createSearchVisualToken(name) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-term');
......
......@@ -8,6 +8,7 @@ issues:
- milestone
- notes
- resource_label_events
- resource_weight_events
- sentry_issue
- label_links
- 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
let(:pipeline) { merge_request.all_pipelines.first }
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
is_expected
.to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head')
......@@ -209,6 +260,7 @@ describe Ci::BuildRunnerPresenter do
end
end
end
end
context 'when persistent pipeline ref exists' do
let(:project) { create(:project, :repository) }
......
......@@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::AddColumnWithDefault do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
def up
add_reference(:projects, :users)
add_column_with_default(:ci_build_needs, :artifacts, :boolean, default: true, allow_null: false)
end
RUBY
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
# Select a label clicking in the search dropdown instead
# of entering label names on the input.
def select_label_on_dropdown(label_title)
input_filtered_search("label:", submit: false)
input_filtered_search("label=", submit: false)
within('#js-dropdown-label') do
wait_for_requests
......@@ -71,7 +71,7 @@ module FilteredSearchHelpers
end
def init_label_search
filtered_search.set('label:')
filtered_search.set('label=')
# This ensures the dropdown is shown
expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading')
end
......@@ -90,6 +90,7 @@ module FilteredSearchHelpers
el = token_elements[index]
expect(el.find('.name')).to have_content(token[:name])
expect(el.find('.operator')).to have_content(token[:operator]) if token[:operator].present?
expect(el.find('.value')).to have_content(token[:value]) if token[:value].present?
# gl-emoji content is blank when the emoji unicode is not supported
......@@ -101,8 +102,8 @@ module FilteredSearchHelpers
end
end
def create_token(token_name, token_value = nil, symbol = nil)
{ name: token_name, value: "#{symbol}#{token_value}" }
def create_token(token_name, token_value = nil, symbol = nil, token_operator = '=')
{ name: token_name, operator: token_operator, value: "#{symbol}#{token_value}" }
end
def author_token(author_name = nil)
......@@ -113,9 +114,9 @@ module FilteredSearchHelpers
create_token('Assignee', assignee_name)
end
def milestone_token(milestone_name = nil, has_symbol = true)
def milestone_token(milestone_name = nil, has_symbol = true, operator = '=')
symbol = has_symbol ? '%' : nil
create_token('Milestone', milestone_name, symbol)
create_token('Milestone', milestone_name, symbol, operator)
end
def release_token(release_tag = nil)
......
......@@ -13,7 +13,7 @@ shared_examples 'issuable user dropdown behaviors' do
it 'only includes members of the project/group' do
visit issuables_path
filtered_search.set("#{dropdown}:")
filtered_search.set("#{dropdown}=")
expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).to have_content(user_in_dropdown.name)
expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).not_to have_content(user_not_in_dropdown.name)
......
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