diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-02 13:03:23 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-02 13:03:23 +0000 |
commit | a72a9af092c1bfcf9f8024d59c11cf222f07e1e7 (patch) | |
tree | 44b60265c1d476d026b2862d2c1244748f558d4f /app | |
parent | b085478c4c2bed74fdc6eb2c33bfc62e791baf03 (diff) | |
download | gitlab-ce-a72a9af092c1bfcf9f8024d59c11cf222f07e1e7.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
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 } } |