Skip to content
Snippets Groups Projects
Commit a72a9af0 authored by GitLab Bot's avatar GitLab Bot
Browse files

Add latest changes from gitlab-org/gitlab@master

parent b085478c
No related branches found
No related tags found
No related merge requests found
Showing
with 595 additions and 147 deletions
Loading
Loading
@@ -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
Loading
Loading
@@ -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('');
Loading
Loading
Loading
Loading
@@ -2,6 +2,7 @@ import { __ } from '~/locale';
 
export default IssuableTokenKeys => {
const wipToken = {
formattedKey: __('WIP'),
key: 'wip',
type: 'string',
param: '',
Loading
Loading
@@ -17,6 +18,7 @@ export default IssuableTokenKeys => {
IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
 
const targetBranchToken = {
formattedKey: __('Target-Branch'),
key: 'target-branch',
type: 'string',
param: '',
Loading
Loading
import { __ } from '~/locale';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
 
const tokenKeys = [
{
formattedKey: __('Status'),
key: 'status',
type: 'string',
param: 'status',
Loading
Loading
@@ -10,6 +12,7 @@ const tokenKeys = [
tag: 'status',
},
{
formattedKey: __('Type'),
key: 'type',
type: 'string',
param: 'type',
Loading
Loading
@@ -18,6 +21,7 @@ const tokenKeys = [
tag: 'type',
},
{
formattedKey: __('Tag'),
key: 'tag',
type: 'array',
param: 'name[]',
Loading
Loading
Loading
Loading
@@ -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';
 
Loading
Loading
@@ -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 => {
Loading
Loading
Loading
Loading
@@ -29,6 +29,7 @@ export default {
 
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
operator: token.operator,
suffix: `${token.symbol}${token.value}`,
}));
 
Loading
Loading
@@ -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>
Loading
Loading
/* eslint-disable import/prefer-default-export */
export const USER_TOKEN_TYPES = ['author', 'assignee'];
export const DROPDOWN_TYPE = {
hint: 'hint',
operator: 'operator',
};
Loading
Loading
@@ -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 || '';
 
Loading
Loading
Loading
Loading
@@ -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 = {}) {
Loading
Loading
@@ -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
Loading
Loading
@@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown {
 
const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
FilteredSearchDropdownManager.addWordToInput(key, '', false, {
uppercaseTokenName,
FilteredSearchDropdownManager.addWordToInput({
tokenName: key,
clicked: false,
options: {
uppercaseTokenName,
},
});
}
this.dismissDropdown();
Loading
Loading
@@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown {
}
 
renderContent() {
const dropdownData = this.tokenKeys.get().map(tokenKey => ({
icon: `${gon.sprite_icons}#${tokenKey.icon}`,
hint: tokenKey.key,
tag: `:${tokenKey.tag}`,
type: tokenKey.type,
}));
const searchItem = [
{
hint: 'search',
tag: 'search',
formattedKey: __('Search for this text'),
icon: `${gon.sprite_icons}#search`,
},
];
const dropdownData = this.tokenKeys
.get()
.map(tokenKey => ({
icon: `${gon.sprite_icons}#${tokenKey.icon}`,
hint: tokenKey.key,
tag: `:${tokenKey.tag}`,
type: tokenKey.type,
formattedKey: tokenKey.formattedKey,
}))
.concat(searchItem);
 
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
super.renderContent();
}
 
init() {
Loading
Loading
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();
}
}
Loading
Loading
@@ -62,28 +62,42 @@ export default class DropdownUtils {
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
const isSearchItem = updatedItem.hint === 'search';
if (isSearchItem) {
updatedItem.droplab_hidden = true;
}
 
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
} else if (!lastKey || _.last(searchInput.split('')) === ' ') {
} else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
const tokenName = _.last(split[0].split(' '));
 
const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
const match = isSearchItem
? allowedKeys.some(key => key.startsWith(tokenName.toLowerCase()))
: updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
updatedItem.droplab_hidden = tokenName ? match : false;
}
 
return updatedItem;
}
 
static setDataValueIfSelected(filter, selected) {
static setDataValueIfSelected(filter, operator, selected) {
const dataValue = selected.getAttribute('data-value');
 
if (dataValue) {
FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
FilteredSearchDropdownManager.addWordToInput({
tokenName: filter,
tokenOperator: operator,
tokenValue: dataValue,
clicked: true,
options: {
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
},
});
}
 
Loading
Loading
@@ -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)
Loading
Loading
@@ -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;
Loading
Loading
@@ -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);
}
Loading
Loading
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';
 
Loading
Loading
@@ -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();
Loading
Loading
Loading
Loading
@@ -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({
Loading
Loading
@@ -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,
});
Loading
Loading
@@ -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;
}
Loading
Loading
@@ -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);
}
}
Loading
Loading
@@ -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);
}
}
 
Loading
Loading
Loading
Loading
@@ -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 {
Loading
Loading
@@ -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
Loading
Loading
@@ -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') || '',
Loading
Loading
@@ -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);
Loading
Loading
@@ -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);
Loading
Loading
@@ -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();
}
}
Loading
Loading
@@ -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,
Loading
Loading
@@ -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}`, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
});
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);
Loading
Loading
@@ -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),
});
 
Loading
Loading
@@ -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;
 
Loading
Loading
@@ -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, {
canEdit,
});
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 +
Loading
Loading
@@ -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,
Loading
Loading
@@ -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,
});
}
Loading
Loading
@@ -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,
});
}
Loading
Loading
@@ -582,7 +694,6 @@ export default class FilteredSearchManager {
search(state = null) {
const paths = [];
const searchQuery = DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
 
const tokenKeys = this.filteredSearchTokenKeys.getKeys();
Loading
Loading
@@ -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) || {};
Loading
Loading
@@ -620,7 +732,16 @@ export default class FilteredSearchManager {
tokenValue = tokenValue.slice(1, tokenValue.length - 1);
}
 
tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
if (token.operator === '!=') {
const isArrayParam = keyParam.endsWith('[]');
tokenPath = `not[${isArrayParam ? keyParam.slice(0, -2) : keyParam}]${
isArrayParam ? '[]' : ''
}=${encodeURIComponent(tokenValue)}`;
} else {
// Default operator is `=`
tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
}
}
 
paths.push(tokenPath);
Loading
Loading
Loading
Loading
@@ -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: '',
Loading
Loading
Loading
Loading
@@ -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 = [];
Loading
Loading
@@ -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
Loading
Loading
@@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer {
key,
value: tokenValue || '',
symbol: tokenSymbol || '',
operator: tokenOperator || '',
});
}
 
Loading
Loading
@@ -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,
Loading
Loading
Loading
Loading
@@ -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;
Loading
Loading
@@ -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),
};
}
 
Loading
Loading
@@ -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">
Loading
Loading
@@ -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,
Loading
Loading
@@ -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');
Loading
Loading
@@ -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 } = {},
) {
Loading
Loading
@@ -127,21 +181,51 @@ export default class FilteredSearchVisualTokens {
const { addVisualTokenElement } = FilteredSearchVisualTokens;
 
if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
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, {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
let value = tokenValue;
if (!value && !tokenOperator) {
value = tokenName;
}
addVisualTokenElement({
name: previousTokenName,
operator: previousTokenOperator,
value,
options: {
canEdit,
uppercaseTokenName,
capitalizeTokenValue,
},
});
}
}
Loading
Loading
@@ -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, {
isSearchTerm: true,
FilteredSearchVisualTokens.addVisualTokenElement({
name: searchTerm,
operator: null,
value: null,
options: {
isSearchTerm: true,
},
});
}
}
 
static getLastTokenPartial() {
static getLastTokenPartial(includeOperator = false) {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
 
if (!lastVisualToken) return '';
Loading
Loading
@@ -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);
}
Loading
Loading
@@ -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, {
uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
});
FilteredSearchVisualTokens.addFilterVisualToken(
nameElement.innerText,
operatorElement.innerText,
null,
{
uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
},
);
 
const valueContainerElement = token.querySelector('.value-container');
value = valueContainerElement.dataset.originalValue;
Loading
Loading
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',
Loading
Loading
@@ -11,6 +13,7 @@ export const tokenKeys = [
tag: '@author',
},
{
formattedKey: __('Assignee'),
key: 'assignee',
type: 'string',
param: 'username',
Loading
Loading
@@ -19,6 +22,7 @@ export const tokenKeys = [
tag: '@assignee',
},
{
formattedKey: __('Milestone'),
key: 'milestone',
type: 'string',
param: 'title',
Loading
Loading
@@ -27,6 +31,7 @@ export const tokenKeys = [
tag: '%milestone',
},
{
formattedKey: __('Release'),
key: 'release',
type: 'string',
param: 'tag',
Loading
Loading
@@ -35,6 +40,7 @@ export const tokenKeys = [
tag: __('tag name'),
},
{
formattedKey: __('Label'),
key: 'label',
type: 'array',
param: 'name[]',
Loading
Loading
@@ -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',
Loading
Loading
@@ -58,6 +65,7 @@ if (gon.current_user_id) {
 
export const alternativeTokenKeys = [
{
formattedKey: __('Label'),
key: 'label',
type: 'string',
param: 'name',
Loading
Loading
@@ -65,68 +73,88 @@ export const alternativeTokenKeys = [
},
];
 
export const conditions = [
{
url: 'assignee_id=None',
tokenKey: 'assignee',
value: __('None'),
},
{
url: 'assignee_id=Any',
tokenKey: 'assignee',
value: __('Any'),
},
{
url: 'milestone_title=None',
tokenKey: 'milestone',
value: __('None'),
},
{
url: 'milestone_title=Any',
tokenKey: 'milestone',
value: __('Any'),
},
{
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: __('Upcoming'),
},
{
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: __('Started'),
},
{
url: 'release_tag=None',
tokenKey: 'release',
value: __('None'),
},
{
url: 'release_tag=Any',
tokenKey: 'release',
value: __('Any'),
},
{
url: 'label_name[]=None',
tokenKey: 'label',
value: __('None'),
},
{
url: 'label_name[]=Any',
tokenKey: 'label',
value: __('Any'),
},
{
url: 'my_reaction_emoji=None',
tokenKey: 'my-reaction',
value: __('None'),
},
{
url: 'my_reaction_emoji=Any',
tokenKey: 'my-reaction',
value: __('Any'),
},
];
export const conditions = flatten(
[
{
url: 'assignee_id=None',
tokenKey: 'assignee',
value: __('None'),
},
{
url: 'assignee_id=Any',
tokenKey: 'assignee',
value: __('Any'),
},
{
url: 'milestone_title=None',
tokenKey: 'milestone',
value: __('None'),
},
{
url: 'milestone_title=Any',
tokenKey: 'milestone',
value: __('Any'),
},
{
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: __('Upcoming'),
},
{
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: __('Started'),
},
{
url: 'release_tag=None',
tokenKey: 'release',
value: __('None'),
},
{
url: 'release_tag=Any',
tokenKey: 'release',
value: __('Any'),
},
{
url: 'label_name[]=None',
tokenKey: 'label',
value: __('None'),
},
{
url: 'label_name[]=Any',
tokenKey: 'label',
value: __('Any'),
},
{
url: 'my_reaction_emoji=None',
tokenKey: 'my-reaction',
value: __('None'),
},
{
url: 'my_reaction_emoji=Any',
tokenKey: 'my-reaction',
value: __('Any'),
},
].map(condition => {
const [keyPart, valuePart] = condition.url.split('=');
const hasBrackets = keyPart.includes('[]');
const notEqualUrl = `not[${hasBrackets ? keyPart.slice(0, -2) : keyPart}]${
hasBrackets ? '[]' : ''
}=${valuePart}`;
return [
{
...condition,
operator: '=',
},
{
...condition,
operator: '!=',
url: notEqualUrl,
},
];
}),
);
 
const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys(
tokenKeys,
Loading
Loading
Loading
Loading
@@ -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) {
Loading
Loading
Loading
Loading
@@ -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;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment