Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-01-02 16:03:23 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-01-02 16:03:23 +0300
commita72a9af092c1bfcf9f8024d59c11cf222f07e1e7 (patch)
tree44b60265c1d476d026b2862d2c1244748f558d4f /app
parentb085478c4c2bed74fdc6eb2c33bfc62e791baf03 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/droplab/drop_down.js5
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js2
-rw-r--r--app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js4
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js6
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue2
-rw-r--r--app/assets/javascripts/filtered_search/constants.js6
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js41
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js65
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js38
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js20
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js47
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js161
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js7
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js17
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js161
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js152
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js3
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js1
-rw-r--r--app/assets/stylesheets/framework/filters.scss16
-rw-r--r--app/controllers/boards/issues_controller.rb2
-rw-r--r--app/finders/issuable_finder.rb2
-rw-r--r--app/models/resource_weight_event.rb26
-rw-r--r--app/presenters/ci/build_runner_presenter.rb15
-rw-r--r--app/services/boards/issues/list_service.rb4
-rw-r--r--app/services/resource_events/base_synthetic_notes_builder_service.rb39
-rw-r--r--app/services/resource_events/merge_into_notes_service.rb43
-rw-r--r--app/services/resource_events/synthetic_label_notes_builder_service.rb27
-rw-r--r--app/views/admin/runners/index.html.haml20
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml19
30 files changed, 749 insertions, 204 deletions
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index ccb3d56ed8c..31d32fb5060 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -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('');
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index e020628a473..9440015b32e 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -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: '',
diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
index 691d165c585..42d0fbacca0 100644
--- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
@@ -1,7 +1,9 @@
+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[]',
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 5fa07045d5e..5450abf4cbd 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -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 => {
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
index 4757c4b1e43..fa2609a3176 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -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>
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index b11111f1081..d7264e96b13 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -1,2 +1,6 @@
-/* eslint-disable import/prefer-default-export */
export const USER_TOKEN_TYPES = ['author', 'assignee'];
+
+export const DROPDOWN_TYPE = {
+ hint: 'hint',
+ operator: 'operator',
+};
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index b27bb63c220..92a64ab60db 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -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 || '';
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 1a1135ae929..4f10b6ba9c3 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -3,6 +3,7 @@ import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import { __ } from '~/locale';
export default class DropdownHint extends FilteredSearchDropdown {
constructor(options = {}) {
@@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown {
this.dismissDropdown();
this.dispatchFormSubmitEvent();
} else {
- const token = selected.querySelector('.js-filter-hint').innerText.trim();
- const tag = selected.querySelector('.js-filter-tag').innerText.trim();
+ const filterItemEl = selected.closest('.filter-dropdown-item');
+ const { hint: token, tag } = filterItemEl.dataset;
if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens
@@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown {
const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
- FilteredSearchDropdownManager.addWordToInput(key, '', false, {
- uppercaseTokenName,
+
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: key,
+ clicked: false,
+ options: {
+ uppercaseTokenName,
+ },
});
}
this.dismissDropdown();
@@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown {
}
renderContent() {
- const dropdownData = this.tokenKeys.get().map(tokenKey => ({
- icon: `${gon.sprite_icons}#${tokenKey.icon}`,
- hint: tokenKey.key,
- tag: `:${tokenKey.tag}`,
- type: tokenKey.type,
- }));
+ const searchItem = [
+ {
+ hint: 'search',
+ tag: 'search',
+ formattedKey: __('Search for this text'),
+ icon: `${gon.sprite_icons}#search`,
+ },
+ ];
+
+ const dropdownData = this.tokenKeys
+ .get()
+ .map(tokenKey => ({
+ icon: `${gon.sprite_icons}#${tokenKey.icon}`,
+ hint: tokenKey.key,
+ tag: `:${tokenKey.tag}`,
+ type: tokenKey.type,
+ formattedKey: tokenKey.formattedKey,
+ }))
+ .concat(searchItem);
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
+
+ super.renderContent();
}
init() {
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
new file mode 100644
index 00000000000..bd4fda29609
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -0,0 +1,65 @@
+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();
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index 8d92af2cf7e..274c08e6955 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -62,28 +62,42 @@ export default class DropdownUtils {
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
+ const isSearchItem = updatedItem.hint === 'search';
+
+ if (isSearchItem) {
+ updatedItem.droplab_hidden = true;
+ }
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
- } else if (!lastKey || _.last(searchInput.split('')) === ' ') {
+ } else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
const tokenName = _.last(split[0].split(' '));
- const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
+ const match = isSearchItem
+ ? allowedKeys.some(key => key.startsWith(tokenName.toLowerCase()))
+ : updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
+
updatedItem.droplab_hidden = tokenName ? match : false;
}
return updatedItem;
}
- static setDataValueIfSelected(filter, selected) {
+ static setDataValueIfSelected(filter, operator, selected) {
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
- FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, {
- capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: filter,
+ tokenOperator: operator,
+ tokenValue: dataValue,
+ clicked: true,
+ options: {
+ capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
+ },
});
}
@@ -101,7 +115,11 @@ export default class DropdownUtils {
// remove leading symbol and wrapping quotes
tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, '');
}
- return { tokenName, tokenValue };
+
+ const operatorEl = visualToken && visualToken.querySelector('.operator');
+ const tokenOperator = operatorEl && operatorEl.textContent.trim();
+
+ return { tokenName, tokenOperator, tokenValue };
}
// Determines the full search query (visual tokens + input)
@@ -119,10 +137,16 @@ export default class DropdownUtils {
tokens.forEach(token => {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
+ const operatorContainer = token.querySelector('.operator');
const value = token.querySelector('.value');
const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
+ let operator = '';
+
+ if (operatorContainer) {
+ operator = operatorContainer.textContent.trim();
+ }
if (valueContainer && valueContainer.dataset.originalValue) {
valueText = valueContainer.dataset.originalValue;
@@ -131,7 +155,7 @@ export default class DropdownUtils {
}
if (token.className.indexOf('filtered-search-token') !== -1) {
- values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`);
+ values.push(`${name.innerText.toLowerCase()}:${operator}${symbol}${valueText}`);
} else {
values.push(name.innerText);
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index 146d3ba963c..72565c2ca13 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,5 +1,6 @@
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();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 5ff95f45be4..566fb295588 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -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);
}
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index a4edc5fd52d..0b4f9457c54 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -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}`, {
- 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);
@@ -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, {
- 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 +
@@ -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,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);
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 0a9579bf491..89fc8047b65 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -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: '',
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index b5c4cb15aac..963e8fe5df5 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -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,
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 7f6457242ef..d41d5a543b0 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -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, {
- 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,
+ },
});
}
}
@@ -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 '';
@@ -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, {
- 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;
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index eb518eb1f52..8722fc64b62 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -1,8 +1,10 @@
+import { flatten } from 'underscore';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale';
export const tokenKeys = [
{
+ formattedKey: __('Author'),
key: 'author',
type: 'string',
param: 'username',
@@ -11,6 +13,7 @@ export const tokenKeys = [
tag: '@author',
},
{
+ formattedKey: __('Assignee'),
key: 'assignee',
type: 'string',
param: 'username',
@@ -19,6 +22,7 @@ export const tokenKeys = [
tag: '@assignee',
},
{
+ formattedKey: __('Milestone'),
key: 'milestone',
type: 'string',
param: 'title',
@@ -27,6 +31,7 @@ export const tokenKeys = [
tag: '%milestone',
},
{
+ formattedKey: __('Release'),
key: 'release',
type: 'string',
param: 'tag',
@@ -35,6 +40,7 @@ export const tokenKeys = [
tag: __('tag name'),
},
{
+ formattedKey: __('Label'),
key: 'label',
type: 'array',
param: 'name[]',
@@ -47,6 +53,7 @@ export const tokenKeys = [
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
+ formattedKey: __('My-Reaction'),
key: 'my-reaction',
type: 'string',
param: 'emoji',
@@ -58,6 +65,7 @@ if (gon.current_user_id) {
export const alternativeTokenKeys = [
{
+ formattedKey: __('Label'),
key: 'label',
type: 'string',
param: 'name',
@@ -65,68 +73,88 @@ export const alternativeTokenKeys = [
},
];
-export const conditions = [
- {
- url: 'assignee_id=None',
- tokenKey: 'assignee',
- value: __('None'),
- },
- {
- url: 'assignee_id=Any',
- tokenKey: 'assignee',
- value: __('Any'),
- },
- {
- url: 'milestone_title=None',
- tokenKey: 'milestone',
- value: __('None'),
- },
- {
- url: 'milestone_title=Any',
- tokenKey: 'milestone',
- value: __('Any'),
- },
- {
- url: 'milestone_title=%23upcoming',
- tokenKey: 'milestone',
- value: __('Upcoming'),
- },
- {
- url: 'milestone_title=%23started',
- tokenKey: 'milestone',
- value: __('Started'),
- },
- {
- url: 'release_tag=None',
- tokenKey: 'release',
- value: __('None'),
- },
- {
- url: 'release_tag=Any',
- tokenKey: 'release',
- value: __('Any'),
- },
- {
- url: 'label_name[]=None',
- tokenKey: 'label',
- value: __('None'),
- },
- {
- url: 'label_name[]=Any',
- tokenKey: 'label',
- value: __('Any'),
- },
- {
- url: 'my_reaction_emoji=None',
- tokenKey: 'my-reaction',
- value: __('None'),
- },
- {
- url: 'my_reaction_emoji=Any',
- tokenKey: 'my-reaction',
- value: __('Any'),
- },
-];
+export const conditions = flatten(
+ [
+ {
+ url: 'assignee_id=None',
+ tokenKey: 'assignee',
+ value: __('None'),
+ },
+ {
+ url: 'assignee_id=Any',
+ tokenKey: 'assignee',
+ value: __('Any'),
+ },
+ {
+ url: 'milestone_title=None',
+ tokenKey: 'milestone',
+ value: __('None'),
+ },
+ {
+ url: 'milestone_title=Any',
+ tokenKey: 'milestone',
+ value: __('Any'),
+ },
+ {
+ url: 'milestone_title=%23upcoming',
+ tokenKey: 'milestone',
+ value: __('Upcoming'),
+ },
+ {
+ url: 'milestone_title=%23started',
+ tokenKey: 'milestone',
+ value: __('Started'),
+ },
+ {
+ url: 'release_tag=None',
+ tokenKey: 'release',
+ value: __('None'),
+ },
+ {
+ url: 'release_tag=Any',
+ tokenKey: 'release',
+ value: __('Any'),
+ },
+ {
+ url: 'label_name[]=None',
+ tokenKey: 'label',
+ value: __('None'),
+ },
+ {
+ url: 'label_name[]=Any',
+ tokenKey: 'label',
+ value: __('Any'),
+ },
+ {
+ url: 'my_reaction_emoji=None',
+ tokenKey: 'my-reaction',
+ value: __('None'),
+ },
+ {
+ url: 'my_reaction_emoji=Any',
+ tokenKey: 'my-reaction',
+ value: __('Any'),
+ },
+ ].map(condition => {
+ const [keyPart, valuePart] = condition.url.split('=');
+ const hasBrackets = keyPart.includes('[]');
+
+ const notEqualUrl = `not[${hasBrackets ? keyPart.slice(0, -2) : keyPart}]${
+ hasBrackets ? '[]' : ''
+ }=${valuePart}`;
+ return [
+ {
+ ...condition,
+ operator: '=',
+ },
+ {
+ ...condition,
+ operator: '!=',
+ url: notEqualUrl,
+ },
+ ];
+ }),
+);
const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys(
tokenKeys,
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 1343ccd6468..9f3cf881af4 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -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) {
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
index 5e0f9b612a2..2270d329c24 100644
--- a/app/assets/javascripts/lib/utils/keycodes.js
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -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;
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 1c252584047..b5d1c3f6732 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -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 {
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 1298b33471b..99c48186fba 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -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
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index e3ea81d5564..194d7da1cab 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -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
diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb
new file mode 100644
index 00000000000..ab288798aed
--- /dev/null
+++ b/app/models/resource_weight_event.rb
@@ -0,0 +1,26 @@
+# 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
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 8e469795581..33b7899f912 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -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]
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 37a74cd1b00..a9240e1d8a0 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -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
diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb
new file mode 100644
index 00000000000..1b85ca811a1
--- /dev/null
+++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb
@@ -0,0 +1,39 @@
+# 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
diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb
index 7504773a002..47948fcff6e 100644
--- a/app/services/resource_events/merge_into_notes_service.rb
+++ b/app/services/resource_events/merge_into_notes_service.rb
@@ -1,10 +1,9 @@
# 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')
diff --git a/app/services/resource_events/synthetic_label_notes_builder_service.rb b/app/services/resource_events/synthetic_label_notes_builder_service.rb
new file mode 100644
index 00000000000..fd128101b49
--- /dev/null
+++ b/app/services/resource_events/synthetic_label_notes_builder_service.rb
@@ -0,0 +1,27 @@
+# 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
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index f8ef7a45f7f..818d265c767 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -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|
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 5da86195243..50530498f52 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -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 } }