From a72a9af092c1bfcf9f8024d59c11cf222f07e1e7 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 2 Jan 2020 13:03:23 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- .gitlab/ci/notifications.gitlab-ci.yml | 2 +- app/assets/javascripts/droplab/drop_down.js | 5 + .../add_extra_tokens_for_merge_requests.js | 2 + .../admin_runners_filtered_search_token_keys.js | 4 + .../filtered_search/available_dropdown_mappings.js | 6 + .../recent_searches_dropdown_content.vue | 2 + .../javascripts/filtered_search/constants.js | 6 +- .../filtered_search/dropdown_ajax_filter.js | 2 +- .../javascripts/filtered_search/dropdown_hint.js | 41 ++++-- .../filtered_search/dropdown_operator.js | 65 ++++++++ .../javascripts/filtered_search/dropdown_utils.js | 38 ++++- .../filtered_search/filtered_search_dropdown.js | 20 ++- .../filtered_search_dropdown_manager.js | 47 ++++-- .../filtered_search/filtered_search_manager.js | 161 +++++++++++++++++--- .../filtered_search/filtered_search_token_keys.js | 7 +- .../filtered_search/filtered_search_tokenizer.js | 17 ++- .../filtered_search_visual_tokens.js | 161 ++++++++++++++++---- .../issuable_filtered_search_token_keys.js | 152 +++++++++++-------- .../filtered_search/visual_token_value.js | 3 +- app/assets/javascripts/lib/utils/keycodes.js | 1 + app/assets/stylesheets/framework/filters.scss | 16 ++ app/controllers/boards/issues_controller.rb | 2 +- app/finders/issuable_finder.rb | 2 +- app/models/resource_weight_event.rb | 26 ++++ app/presenters/ci/build_runner_presenter.rb | 15 +- app/services/boards/issues/list_service.rb | 4 + .../base_synthetic_notes_builder_service.rb | 39 +++++ .../resource_events/merge_into_notes_service.rb | 43 ++---- .../synthetic_label_notes_builder_service.rb | 27 ++++ app/views/admin/runners/index.html.haml | 20 ++- app/views/shared/issuable/_search_bar.html.haml | 19 ++- .../unreleased/19011-add-operator-dropdown.yml | 5 + .../sh-drop-ci-pipelines-redundant-index.yml | 5 + ...xposing-mr-refs-in-favor-of-persistent-refs.yml | 5 + .../20191210211253_create_resource_weight_event.rb | 18 +++ ...140154_drop_index_ci_pipelines_on_project_id.rb | 17 +++ db/schema.rb | 14 +- locale/gitlab.pot | 24 ++- qa/qa.rb | 1 + qa/qa/page/base.rb | 27 +++- qa/qa/resource/base.rb | 6 + qa/qa/support/wait_for_requests.rb | 25 ++++ scripts/trigger-build | 3 +- spec/factories/resource_weight_events.rb | 8 + spec/features/admin/admin_runners_spec.rb | 24 +-- spec/features/boards/boards_spec.rb | 2 +- spec/features/boards/modal_filter_spec.rb | 2 +- spec/features/dashboard/issues_filter_spec.rb | 6 +- spec/features/dashboard/issues_spec.rb | 2 +- spec/features/dashboard/merge_requests_spec.rb | 4 +- spec/features/groups/issues_spec.rb | 2 +- spec/features/groups/merge_requests_spec.rb | 2 +- .../filtered_search/dropdown_assignee_spec.rb | 6 +- .../issues/filtered_search/dropdown_author_spec.rb | 6 +- .../issues/filtered_search/dropdown_base_spec.rb | 8 +- .../issues/filtered_search/dropdown_emoji_spec.rb | 12 +- .../issues/filtered_search/dropdown_hint_spec.rb | 30 ++-- .../issues/filtered_search/dropdown_label_spec.rb | 2 +- .../filtered_search/dropdown_milestone_spec.rb | 2 +- .../filtered_search/dropdown_release_spec.rb | 2 +- .../issues/filtered_search/filter_issues_spec.rb | 164 +++++++++++++++++---- .../issues/filtered_search/recent_searches_spec.rb | 4 +- .../issues/filtered_search/search_bar_spec.rb | 4 +- .../issues/filtered_search/visual_tokens_spec.rb | 31 ++-- spec/features/labels_hierarchy_spec.rb | 6 +- .../filters_generic_behavior_spec.rb | 2 +- .../user_filters_by_assignees_spec.rb | 6 +- .../merge_requests/user_filters_by_labels_spec.rb | 6 +- .../user_filters_by_milestones_spec.rb | 10 +- .../user_filters_by_multiple_criteria_spec.rb | 4 +- .../user_filters_by_target_branch_spec.rb | 6 +- .../components/error_tracking_list_spec.js | 101 ++++++++----- .../filtered_search_token_keys_spec.js | 2 + spec/frontend/helpers/stub_children.js | 3 + .../users/components/user_modal_manager_spec.js | 8 +- spec/javascripts/droplab/drop_down_spec.js | 13 +- .../filtered_search/dropdown_utils_spec.js | 14 +- .../filtered_search_dropdown_manager_spec.js | 43 ++++-- .../filtered_search_manager_spec.js | 16 +- .../filtered_search_visual_tokens_spec.js | 88 ++++++----- .../issues_filtered_search_token_keys_spec.js | 1 + .../filtered_search/visual_token_value_spec.js | 10 +- .../helpers/filtered_search_spec_helper.js | 17 ++- spec/lib/gitlab/import_export/all_models.yml | 1 + spec/models/resource_weight_event_spec.rb | 75 ++++++++++ spec/presenters/ci/build_runner_presenter_spec.rb | 78 ++++++++-- .../cop/migration/add_column_with_default_spec.rb | 2 +- .../synthetic_label_notes_builder_service_spec.rb | 21 +++ spec/support/helpers/filtered_search_helpers.rb | 13 +- ...bles_user_dropdown_behaviors_shared_examples.rb | 2 +- vendor/gitignore/C++.gitignore | 0 vendor/gitignore/Java.gitignore | 0 92 files changed, 1515 insertions(+), 461 deletions(-) create mode 100644 app/assets/javascripts/filtered_search/dropdown_operator.js create mode 100644 app/models/resource_weight_event.rb create mode 100644 app/services/resource_events/base_synthetic_notes_builder_service.rb create mode 100644 app/services/resource_events/synthetic_label_notes_builder_service.rb create mode 100644 changelogs/unreleased/19011-add-operator-dropdown.yml create mode 100644 changelogs/unreleased/sh-drop-ci-pipelines-redundant-index.yml create mode 100644 changelogs/unreleased/stop-exposing-mr-refs-in-favor-of-persistent-refs.yml create mode 100644 db/migrate/20191210211253_create_resource_weight_event.rb create mode 100644 db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb create mode 100644 qa/qa/support/wait_for_requests.rb create mode 100644 spec/factories/resource_weight_events.rb create mode 100644 spec/frontend/helpers/stub_children.js create mode 100644 spec/models/resource_weight_event_spec.rb create mode 100644 spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb mode change 100755 => 100644 vendor/gitignore/C++.gitignore mode change 100755 => 100644 vendor/gitignore/Java.gitignore diff --git a/.gitlab/ci/notifications.gitlab-ci.yml b/.gitlab/ci/notifications.gitlab-ci.yml index 4271e709f45..0cf38d2258a 100644 --- a/.gitlab/ci/notifications.gitlab-ci.yml +++ b/.gitlab/ci/notifications.gitlab-ci.yml @@ -20,4 +20,4 @@ schedule:package-and-qa:notify-failure: - 'notify_on_job_failure schedule:package-and-qa qa-master "${NOTIFICATION_MESSAGE}" ci_failing' needs: ["schedule:package-and-qa"] allow_failure: true - when: always + when: manual # TODO: remove notify job if not necessary 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" > {{ token.prefix }} + {{ token.operator }} {{ token.suffix }} 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 `
+ ${hasOperator ? '
' : ''}
@@ -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 = `
`; + const nameHTML = `
`; + let operatorHTML = ''; + + if (hasOperator) { + operatorHTML = '
'; + } + + 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 } } diff --git a/changelogs/unreleased/19011-add-operator-dropdown.yml b/changelogs/unreleased/19011-add-operator-dropdown.yml new file mode 100644 index 00000000000..526ede68c30 --- /dev/null +++ b/changelogs/unreleased/19011-add-operator-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Add support for operator in filter bar +merge_request: 19011 +author: +type: added diff --git a/changelogs/unreleased/sh-drop-ci-pipelines-redundant-index.yml b/changelogs/unreleased/sh-drop-ci-pipelines-redundant-index.yml new file mode 100644 index 00000000000..c9525f38ffe --- /dev/null +++ b/changelogs/unreleased/sh-drop-ci-pipelines-redundant-index.yml @@ -0,0 +1,5 @@ +--- +title: Drop redundant index on ci_pipelines.project_id +merge_request: 22325 +author: +type: other diff --git a/changelogs/unreleased/stop-exposing-mr-refs-in-favor-of-persistent-refs.yml b/changelogs/unreleased/stop-exposing-mr-refs-in-favor-of-persistent-refs.yml new file mode 100644 index 00000000000..c98ca1f1e95 --- /dev/null +++ b/changelogs/unreleased/stop-exposing-mr-refs-in-favor-of-persistent-refs.yml @@ -0,0 +1,5 @@ +--- +title: Stop exposing MR refs in favor of persistent pipeline refs +merge_request: 22198 +author: +type: fixed diff --git a/db/migrate/20191210211253_create_resource_weight_event.rb b/db/migrate/20191210211253_create_resource_weight_event.rb new file mode 100644 index 00000000000..b458c5f169f --- /dev/null +++ b/db/migrate/20191210211253_create_resource_weight_event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateResourceWeightEvent < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :resource_weight_events do |t| + t.references :user, null: false, foreign_key: { on_delete: :nullify }, + index: { name: 'index_resource_weight_events_on_user_id' } + t.references :issue, null: false, foreign_key: { on_delete: :cascade }, + index: false + t.integer :weight + t.datetime_with_timezone :created_at, null: false + + t.index [:issue_id, :weight], name: 'index_resource_weight_events_on_issue_id_and_weight' + end + end +end diff --git a/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb b/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb new file mode 100644 index 00000000000..dbfe3758cda --- /dev/null +++ b/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class DropIndexCiPipelinesOnProjectId < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + remove_concurrent_index :ci_pipelines, :project_id + end + + def down + add_concurrent_index :ci_pipelines, :project_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 219ac8c5263..2f9c5fa8f5a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_12_18_225624) do +ActiveRecord::Schema.define(version: 2019_12_29_140154) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" @@ -871,7 +871,6 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do t.index ["project_id", "source"], name: "index_ci_pipelines_on_project_id_and_source" t.index ["project_id", "status", "config_source"], name: "index_ci_pipelines_on_project_id_and_status_and_config_source" t.index ["project_id", "status", "updated_at"], name: "index_ci_pipelines_on_project_id_and_status_and_updated_at" - t.index ["project_id"], name: "index_ci_pipelines_on_project_id" t.index ["status"], name: "index_ci_pipelines_on_status" t.index ["user_id"], name: "index_ci_pipelines_on_user_id" end @@ -3605,6 +3604,15 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do t.index ["user_id"], name: "index_resource_label_events_on_user_id" end + create_table "resource_weight_events", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "issue_id", null: false + t.integer "weight" + t.datetime_with_timezone "created_at", null: false + t.index ["issue_id", "weight"], name: "index_resource_weight_events_on_issue_id_and_weight" + t.index ["user_id"], name: "index_resource_weight_events_on_user_id" + end + create_table "reviews", force: :cascade do |t| t.integer "author_id" t.integer "merge_request_id", null: false @@ -4745,6 +4753,8 @@ ActiveRecord::Schema.define(version: 2019_12_18_225624) do add_foreign_key "resource_label_events", "labels", on_delete: :nullify add_foreign_key "resource_label_events", "merge_requests", on_delete: :cascade add_foreign_key "resource_label_events", "users", on_delete: :nullify + add_foreign_key "resource_weight_events", "issues", on_delete: :cascade + add_foreign_key "resource_weight_events", "users", on_delete: :nullify add_foreign_key "reviews", "merge_requests", on_delete: :cascade add_foreign_key "reviews", "projects", on_delete: :cascade add_foreign_key "reviews", "users", column: "author_id", on_delete: :nullify diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1c8740853f2..fa5560755a9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2032,6 +2032,9 @@ msgstr "" msgid "Approved the current merge request." msgstr "" +msgid "Approver" +msgstr "" + msgid "Apr" msgstr "" @@ -9948,6 +9951,12 @@ msgstr "" msgid "Invocations" msgstr "" +msgid "Is" +msgstr "" + +msgid "Is not" +msgstr "" + msgid "Is using license seat:" msgstr "" @@ -11678,6 +11687,9 @@ msgstr "" msgid "Multiple uploaders found: %{uploader_types}" msgstr "" +msgid "My-Reaction" +msgstr "" + msgid "Name" msgstr "" @@ -13305,9 +13317,6 @@ msgstr "" msgid "Press %{key}-C to copy" msgstr "" -msgid "Press Enter or click to search" -msgstr "" - msgid "Prevent adding new members to project membership within this group" msgstr "" @@ -15712,6 +15721,9 @@ msgstr "" msgid "Search for projects, issues, etc." msgstr "" +msgid "Search for this text" +msgstr "" + msgid "Search forks" msgstr "" @@ -17722,6 +17734,9 @@ msgstr "" msgid "Target branch" msgstr "" +msgid "Target-Branch" +msgstr "" + msgid "Team" msgstr "" @@ -20291,6 +20306,9 @@ msgstr "" msgid "Vulnerability|Severity" msgstr "" +msgid "WIP" +msgstr "" + msgid "Wait for the file to load to copy its contents" msgstr "" diff --git a/qa/qa.rb b/qa/qa.rb index 1dcaa7f568e..ce0488fdc81 100644 --- a/qa/qa.rb +++ b/qa/qa.rb @@ -490,6 +490,7 @@ module QA autoload :Dates, 'qa/support/dates' autoload :Waiter, 'qa/support/waiter' autoload :Retrier, 'qa/support/retrier' + autoload :WaitForRequests, 'qa/support/wait_for_requests' end end diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index dcba4fc8544..2c04fb53440 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -8,6 +8,7 @@ module QA prepend Support::Page::Logging if Runtime::Env.debug? include Capybara::DSL include Scenario::Actable + include Support::WaitForRequests extend Validatable extend SingleForwardable @@ -21,6 +22,8 @@ module QA def refresh page.refresh + + wait_for_requests end def wait(max: 60, interval: 0.1, reload: true) @@ -42,6 +45,8 @@ module QA end def scroll_to(selector, text: nil) + wait_for_requests + page.execute_script <<~JS var elements = Array.from(document.querySelectorAll('#{selector}')); var text = '#{text}'; @@ -74,6 +79,8 @@ module QA end def find_element(name, **kwargs) + wait_for_requests + find(element_selector_css(name), kwargs) end @@ -82,6 +89,8 @@ module QA end def all_elements(name, **kwargs) + wait_for_requests + all(element_selector_css(name), **kwargs) end @@ -120,6 +129,8 @@ module QA end def has_element?(name, **kwargs) + wait_for_requests + wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil @@ -127,6 +138,8 @@ module QA end def has_no_element?(name, **kwargs) + wait_for_requests + wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil @@ -134,18 +147,24 @@ module QA end def has_text?(text, wait: Capybara.default_max_wait_time) + wait_for_requests + page.has_text?(text, wait: wait) end def has_no_text?(text) + wait_for_requests + page.has_no_text? text end def has_normalized_ws_text?(text, wait: Capybara.default_max_wait_time) - page.has_text?(text.gsub(/\s+/, " "), wait: wait) + has_text?(text.gsub(/\s+/, " "), wait: wait) end def finished_loading? + wait_for_requests + # The number of selectors should be able to be reduced after # migration to the new spinner is complete. # https://gitlab.com/groups/gitlab-org/-/epics/956 @@ -153,6 +172,8 @@ module QA end def finished_loading_block? + wait_for_requests + has_no_css?('.fa-spinner.block-loading', wait: Capybara.default_max_wait_time) end @@ -220,10 +241,14 @@ module QA end def click_link_with_text(text) + wait_for_requests + click_link text end def click_body + wait_for_requests + find('body').click end diff --git a/qa/qa/resource/base.rb b/qa/qa/resource/base.rb index 3bb62703290..1a6c2e70860 100644 --- a/qa/qa/resource/base.rb +++ b/qa/qa/resource/base.rb @@ -66,10 +66,16 @@ module QA def visit! Runtime::Logger.debug(%Q[Visiting #{self.class.name} at "#{web_url}"]) + # Just in case an async action is not yet complete + Support::WaitForRequests.wait_for_requests + Support::Retrier.retry_until do visit(web_url) wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) } end + + # Wait until the new page is ready for us to interact with it + Support::WaitForRequests.wait_for_requests end def populate(*attributes) diff --git a/qa/qa/support/wait_for_requests.rb b/qa/qa/support/wait_for_requests.rb new file mode 100644 index 00000000000..c573fc1f8e1 --- /dev/null +++ b/qa/qa/support/wait_for_requests.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module QA + module Support + module WaitForRequests + module_function + + def wait_for_requests + Waiter.wait do + finished_all_ajax_requests? && finished_all_axios_requests? + end + end + + def finished_all_axios_requests? + Capybara.page.evaluate_script('window.pendingRequests || 0').zero? + end + + def finished_all_ajax_requests? + return true if Capybara.page.evaluate_script('typeof jQuery === "undefined"') + + Capybara.page.evaluate_script('jQuery.active').zero? + end + end + end +end diff --git a/scripts/trigger-build b/scripts/trigger-build index 537b2692b27..b7b805b826f 100755 --- a/scripts/trigger-build +++ b/scripts/trigger-build @@ -85,7 +85,8 @@ module Trigger 'TRIGGER_SOURCE' => ENV['CI_JOB_URL'], 'TOP_UPSTREAM_SOURCE_PROJECT' => ENV['CI_PROJECT_PATH'], 'TOP_UPSTREAM_SOURCE_JOB' => ENV['CI_JOB_URL'], - 'TOP_UPSTREAM_SOURCE_SHA' => ENV['CI_COMMIT_SHA'] + 'TOP_UPSTREAM_SOURCE_SHA' => ENV['CI_COMMIT_SHA'], + 'TOP_UPSTREAM_SOURCE_REF' => ENV['CI_COMMIT_REF_NAME'] } end diff --git a/spec/factories/resource_weight_events.rb b/spec/factories/resource_weight_events.rb new file mode 100644 index 00000000000..cb9a34df332 --- /dev/null +++ b/spec/factories/resource_weight_events.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :resource_weight_event do + issue { create(:issue) } + user { issue&.author || create(:user) } + end +end diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 0d5f5df71b6..6bcadda6523 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -57,7 +57,7 @@ describe "Admin Runners" do expect(page).to have_content 'runner-active' expect(page).to have_content 'runner-paused' - input_filtered_search_keys('status:active') + input_filtered_search_keys('status=active') expect(page).to have_content 'runner-active' expect(page).not_to have_content 'runner-paused' end @@ -68,7 +68,7 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('status:offline') + input_filtered_search_keys('status=offline') expect(page).not_to have_content 'runner-active' expect(page).not_to have_content 'runner-paused' @@ -83,12 +83,12 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('status:active') + input_filtered_search_keys('status=active') expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' - input_filtered_search_keys('status:active runner-a') + input_filtered_search_keys('status=active runner-a') expect(page).to have_content 'runner-a-1' expect(page).not_to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' @@ -105,7 +105,7 @@ describe "Admin Runners" do expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-group' - input_filtered_search_keys('type:project_type') + input_filtered_search_keys('type=project_type') expect(page).to have_content 'runner-project' expect(page).not_to have_content 'runner-group' end @@ -116,7 +116,7 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('type:instance_type') + input_filtered_search_keys('type=instance_type') expect(page).not_to have_content 'runner-project' expect(page).not_to have_content 'runner-group' @@ -131,12 +131,12 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('type:project_type') + input_filtered_search_keys('type=project_type') expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' - input_filtered_search_keys('type:project_type runner-a') + input_filtered_search_keys('type=project_type runner-a') expect(page).to have_content 'runner-a-1' expect(page).not_to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' @@ -153,7 +153,7 @@ describe "Admin Runners" do expect(page).to have_content 'runner-blue' expect(page).to have_content 'runner-red' - input_filtered_search_keys('tag:blue') + input_filtered_search_keys('tag=blue') expect(page).to have_content 'runner-blue' expect(page).not_to have_content 'runner-red' @@ -165,7 +165,7 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('tag:red') + input_filtered_search_keys('tag=red') expect(page).not_to have_content 'runner-blue' expect(page).not_to have_content 'runner-blue' @@ -179,13 +179,13 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('tag:blue') + input_filtered_search_keys('tag=blue') expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' - input_filtered_search_keys('tag:blue runner-a') + input_filtered_search_keys('tag=blue runner-a') expect(page).to have_content 'runner-a-1' expect(page).not_to have_content 'runner-b-1' diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index c740e4e26d9..a5f98e82c33 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -628,7 +628,7 @@ describe 'Issue Boards', :js do end def set_filter(type, text) - find('.filtered-search').native.send_keys("#{type}:#{text}") + find('.filtered-search').native.send_keys("#{type}=#{text}") end def submit_filter diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index 70bc067f79d..d14041ecf3f 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -211,7 +211,7 @@ describe 'Issue Boards add issue modal filtering', :js do end def set_filter(type, text = '') - find('.add-issues-modal .filtered-search').native.send_keys("#{type}:#{text}") + find('.add-issues-modal .filtered-search').native.send_keys("#{type}=#{text}") end def submit_filter diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb index 1352e1bd8fc..8e7fd1f500f 100644 --- a/spec/features/dashboard/issues_filter_spec.rb +++ b/spec/features/dashboard/issues_filter_spec.rb @@ -28,14 +28,14 @@ describe 'Dashboard Issues filtering', :js do context 'filtering by milestone' do it 'shows all issues with no milestone' do - input_filtered_search("milestone:none") + input_filtered_search("milestone=none") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_selector('.issue', count: 1) end it 'shows all issues with the selected milestone' do - input_filtered_search("milestone:%\"#{milestone.title}\"") + input_filtered_search("milestone=%\"#{milestone.title}\"") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_selector('.issue', count: 1) @@ -63,7 +63,7 @@ describe 'Dashboard Issues filtering', :js do let!(:label_link) { create(:label_link, label: label, target: issue) } it 'shows all issues with the selected label' do - input_filtered_search("label:~#{label.title}") + input_filtered_search("label=~#{label.title}") page.within 'ul.content-list' do expect(page).to have_content issue.title diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index cb055ff8416..a2ead1b5d33 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -30,7 +30,7 @@ RSpec.describe 'Dashboard Issues' do it 'shows issues when current user is author', :js do reset_filters - input_filtered_search("author:#{current_user.to_reference}") + input_filtered_search("author=#{current_user.to_reference}") expect(page).to have_content(authored_issue.title) expect(page).to have_content(authored_issue_on_public_project.title) diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 0c1e1d5910b..bb515cfae82 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -107,7 +107,7 @@ describe 'Dashboard Merge Requests' do it 'shows authored merge requests', :js do reset_filters - input_filtered_search("author:#{current_user.to_reference}") + input_filtered_search("author=#{current_user.to_reference}") expect(page).to have_content(authored_merge_request.title) expect(page).to have_content(authored_merge_request_from_fork.title) @@ -120,7 +120,7 @@ describe 'Dashboard Merge Requests' do it 'shows labeled merge requests', :js do reset_filters - input_filtered_search("label:#{label.name}") + input_filtered_search("label=#{label.name}") expect(page).to have_content(labeled_merge_request.title) diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index b9b233026fd..a3fa87e3242 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -48,7 +48,7 @@ describe 'Group issues page' do let(:user2) { user_outside_group } it 'filters by only group users' do - filtered_search.set('assignee:') + filtered_search.set('assignee=') expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name) expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name) diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb index 59230d6891a..0038a8e4892 100644 --- a/spec/features/groups/merge_requests_spec.rb +++ b/spec/features/groups/merge_requests_spec.rb @@ -52,7 +52,7 @@ describe 'Group merge requests page' do let(:user2) { user_outside_group } it 'filters by assignee only group users' do - filtered_search.set('assignee:') + filtered_search.set('assignee=') expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name) expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name) diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 2d7f5822996..8aa29cddd5f 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -20,13 +20,13 @@ describe 'Dropdown assignee', :js do describe 'behavior' do it 'loads all the assignees when opened' do - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) expect_filtered_search_dropdown_results(filter_dropdown, 2) end it 'shows current user at top of dropdown' do - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name) end @@ -35,7 +35,7 @@ describe 'Dropdown assignee', :js do describe 'selecting from dropdown without Ajax call' do before do Gitlab::Testing::RequestBlockerMiddleware.block_requests! - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) end after do diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index 6567bbcf8a2..c95bd7071b3 100644 --- a/spec/features/issues/filtered_search/dropdown_author_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -20,13 +20,13 @@ describe 'Dropdown author', :js do describe 'behavior' do it 'loads all the authors when opened' do - input_filtered_search('author:', submit: false, extra_space: false) + input_filtered_search('author=', submit: false, extra_space: false) expect_filtered_search_dropdown_results(filter_dropdown, 2) end it 'shows current user at top of dropdown' do - input_filtered_search('author:', submit: false, extra_space: false) + input_filtered_search('author=', submit: false, extra_space: false) expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name) end @@ -35,7 +35,7 @@ describe 'Dropdown author', :js do describe 'selecting from dropdown without Ajax call' do before do Gitlab::Testing::RequestBlockerMiddleware.block_requests! - input_filtered_search('author:', submit: false, extra_space: false) + input_filtered_search('author=', submit: false, extra_space: false) end after do diff --git a/spec/features/issues/filtered_search/dropdown_base_spec.rb b/spec/features/issues/filtered_search/dropdown_base_spec.rb index 0a8d768fe49..2a800f054a0 100644 --- a/spec/features/issues/filtered_search/dropdown_base_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_base_spec.rb @@ -27,14 +27,14 @@ describe 'Dropdown base', :js do it 'shows loading indicator when opened' do slow_requests do # We aren't using `input_filtered_search` because we want to see the loading indicator - filtered_search.set('assignee:') + filtered_search.set('assignee=') expect(page).to have_css("#{js_dropdown_assignee} .filter-dropdown-loading", visible: true) end end it 'hides loading indicator when loaded' do - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading') end @@ -42,7 +42,7 @@ describe 'Dropdown base', :js do describe 'caching requests' do it 'caches requests after the first load' do - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) initial_size = dropdown_assignee_size expect(initial_size).to be > 0 @@ -50,7 +50,7 @@ describe 'Dropdown base', :js do new_user = create(:user) project.add_maintainer(new_user) find('.filtered-search-box .clear-search').click - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) expect(dropdown_assignee_size).to eq(initial_size) end diff --git a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb index 324f39cbd2c..4c11f83318b 100644 --- a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb @@ -26,8 +26,8 @@ describe 'Dropdown emoji', :js do end describe 'behavior' do - it 'does not open when the search bar has my-reaction:' do - filtered_search.set('my-reaction:') + it 'does not open when the search bar has my-reaction=' do + filtered_search.set('my-reaction=') expect(page).not_to have_css(js_dropdown_emoji) end @@ -42,20 +42,20 @@ describe 'Dropdown emoji', :js do end describe 'behavior' do - it 'opens when the search bar has my-reaction:' do - filtered_search.set('my-reaction:') + it 'opens when the search bar has my-reaction=' do + filtered_search.set('my-reaction=') expect(page).to have_css(js_dropdown_emoji, visible: true) end it 'loads all the emojis when opened' do - input_filtered_search('my-reaction:', submit: false, extra_space: false) + input_filtered_search('my-reaction=', submit: false, extra_space: false) expect_filtered_search_dropdown_results(filter_dropdown, 3) end it 'shows the most populated emoji at top of dropdown' do - input_filtered_search('my-reaction:', submit: false, extra_space: false) + input_filtered_search('my-reaction=', submit: false, extra_space: false) expect(first("#{js_dropdown_emoji} .filter-dropdown li")).to have_content(award_emoji_star.name) end diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb index 5994f3a7902..10b092c6957 100644 --- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb @@ -9,11 +9,16 @@ describe 'Dropdown hint', :js do let!(:user) { create(:user) } let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_hint) { '#js-dropdown-hint' } + let(:js_dropdown_operator) { '#js-dropdown-operator' } def click_hint(text) find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click end + def click_operator(op) + find("#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value='#{op}']").click + end + before do project.add_maintainer(user) create(:issue, project: project) @@ -27,7 +32,7 @@ describe 'Dropdown hint', :js do it 'does not exist my-reaction dropdown item' do expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).not_to have_content('my-reaction') + expect(page).not_to have_content('My-reaction') end end @@ -54,15 +59,6 @@ describe 'Dropdown hint', :js do end describe 'filtering' do - it 'does not filter `Press Enter or click to search`' do - filtered_search.set('randomtext') - - hint_dropdown = find(js_dropdown_hint) - - expect(hint_dropdown).to have_content('Press Enter or click to search') - expect(hint_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 0) - end - it 'filters with text' do filtered_search.set('a') @@ -76,21 +72,27 @@ describe 'Dropdown hint', :js do end it 'opens the token dropdown when you click on it' do - click_hint('author') + click_hint('Author') expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css(js_dropdown_operator, visible: true) + + click_operator('=') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css(js_dropdown_operator, visible: false) expect(page).to have_css('#js-dropdown-author', visible: true) - expect_tokens([{ name: 'Author' }]) + expect_tokens([{ name: 'Author', operator: '=' }]) expect_filtered_search_input_empty end end describe 'reselecting from dropdown' do it 'reuses existing token text' do - filtered_search.send_keys('author:') + filtered_search.send_keys('author') filtered_search.send_keys(:backspace) filtered_search.send_keys(:backspace) - click_hint('author') + click_hint('Author') expect_tokens([{ name: 'Author' }]) expect_filtered_search_input_empty diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb index 45112b01eac..1e90efc8d56 100644 --- a/spec/features/issues/filtered_search/dropdown_label_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -21,7 +21,7 @@ describe 'Dropdown label', :js do describe 'behavior' do it 'loads all the labels when opened' do create(:label, project: project, title: 'bug-label') - filtered_search.set('label:') + filtered_search.set('label=') expect_filtered_search_dropdown_results(filter_dropdown, 1) end diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb index 2f18aa8abaa..1f62a8e0c8d 100644 --- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -23,7 +23,7 @@ describe 'Dropdown milestone', :js do describe 'behavior' do before do - filtered_search.set('milestone:') + filtered_search.set('milestone=') end it 'loads all the milestones when opened' do diff --git a/spec/features/issues/filtered_search/dropdown_release_spec.rb b/spec/features/issues/filtered_search/dropdown_release_spec.rb index b9cce5c6998..fd0a98f9ddc 100644 --- a/spec/features/issues/filtered_search/dropdown_release_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_release_spec.rb @@ -23,7 +23,7 @@ describe 'Dropdown release', :js do describe 'behavior' do before do - filtered_search.set('release:') + filtered_search.set('release=') end it 'loads all the releases when opened' do diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 12e010e293a..c99c205d5da 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -67,7 +67,7 @@ describe 'Filter issues', :js do it 'filters by all available tokens' do search_term = 'issue' - input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}") + input_filtered_search("assignee=@#{user.username} author=@#{user.username} label=~#{caps_sensitive_label.title} milestone=%#{milestone.title} #{search_term}") wait_for_requests @@ -84,7 +84,7 @@ describe 'Filter issues', :js do describe 'filter issues by author' do context 'only author' do it 'filters issues by searched author' do - input_filtered_search("author:@#{user.username}") + input_filtered_search("author=@#{user.username}") wait_for_requests @@ -98,7 +98,7 @@ describe 'Filter issues', :js do describe 'filter issues by assignee' do context 'only assignee' do it 'filters issues by searched assignee' do - input_filtered_search("assignee:@#{user.username}") + input_filtered_search("assignee=@#{user.username}") wait_for_requests @@ -108,7 +108,7 @@ describe 'Filter issues', :js do end it 'filters issues by no assignee' do - input_filtered_search('assignee:none') + input_filtered_search('assignee=none') expect_tokens([assignee_token('None')]) expect_issues_list_count(3) @@ -122,7 +122,7 @@ describe 'Filter issues', :js do it 'filters issues by multiple assignees' do create(:issue, project: project, author: user, assignees: [user2, user]) - input_filtered_search("assignee:@#{user.username} assignee:@#{user2.username}") + input_filtered_search("assignee=@#{user.username} assignee=@#{user2.username}") expect_tokens([ assignee_token(user.name), @@ -138,15 +138,31 @@ describe 'Filter issues', :js do describe 'filter issues by label' do context 'only label' do it 'filters issues by searched label' do - input_filtered_search("label:~#{bug_label.title}") + input_filtered_search("label=~#{bug_label.title}") expect_tokens([label_token(bug_label.title)]) expect_issues_list_count(2) expect_filtered_search_input_empty end + it 'filters issues not containing searched label' do + input_filtered_search("label!=~#{bug_label.title}") + + expect_tokens([label_token(bug_label.title)]) + expect_issues_list_count(6) + expect_filtered_search_input_empty + end + + it 'filters issues by no label' do + input_filtered_search('label=none') + + expect_tokens([label_token('None', false)]) + expect_issues_list_count(4) + expect_filtered_search_input_empty + end + it 'filters issues by no label' do - input_filtered_search('label:none') + input_filtered_search('label!=none') expect_tokens([label_token('None', false)]) expect_issues_list_count(4) @@ -154,7 +170,18 @@ describe 'Filter issues', :js do end it 'filters issues by multiple labels' do - input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title}") + input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title}") + + expect_tokens([ + label_token(bug_label.title), + label_token(caps_sensitive_label.title) + ]) + expect_issues_list_count(1) + expect_filtered_search_input_empty + end + + it 'filters issues by multiple labels with not operator' do + input_filtered_search("label!=~#{bug_label.title} label=~#{caps_sensitive_label.title}") expect_tokens([ label_token(bug_label.title), @@ -169,22 +196,42 @@ describe 'Filter issues', :js do special_issue = create(:issue, title: "Issue with special character label", project: project) special_issue.labels << special_label - input_filtered_search("label:~#{special_label.title}") + input_filtered_search("label=~#{special_label.title}") expect_tokens([label_token(special_label.title)]) expect_issues_list_count(1) expect_filtered_search_input_empty end + it 'filters issues by label not containing special characters' do + special_label = create(:label, project: project, title: '!@#{$%^&*()-+[]<>?/:{}|\}') + special_issue = create(:issue, title: "Issue with special character label", project: project) + special_issue.labels << special_label + + input_filtered_search("label!=~#{special_label.title}") + + expect_tokens([label_token(special_label.title)]) + expect_issues_list_count(8) + expect_filtered_search_input_empty + end + it 'does not show issues for unused labels' do new_label = create(:label, project: project, title: 'new_label') - input_filtered_search("label:~#{new_label.title}") + input_filtered_search("label=~#{new_label.title}") expect_tokens([label_token(new_label.title)]) expect_no_issues_list expect_filtered_search_input_empty end + + it 'does show issues for bug label' do + input_filtered_search("label!=~#{bug_label.title}") + + expect_tokens([label_token(bug_label.title)]) + expect_issues_list_count(6) + expect_filtered_search_input_empty + end end context 'label with multiple words' do @@ -193,7 +240,7 @@ describe 'Filter issues', :js do special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project) special_multiple_issue.labels << special_multiple_label - input_filtered_search("label:~'#{special_multiple_label.title}'") + input_filtered_search("label=~'#{special_multiple_label.title}'") # Check for search results (which makes sure that the page has changed) expect_issues_list_count(1) @@ -205,7 +252,7 @@ describe 'Filter issues', :js do end it 'single quotes' do - input_filtered_search("label:~'#{multiple_words_label.title}'") + input_filtered_search("label=~'#{multiple_words_label.title}'") expect_issues_list_count(1) expect_tokens([label_token("\"#{multiple_words_label.title}\"")]) @@ -213,7 +260,7 @@ describe 'Filter issues', :js do end it 'double quotes' do - input_filtered_search("label:~\"#{multiple_words_label.title}\"") + input_filtered_search("label=~\"#{multiple_words_label.title}\"") expect_tokens([label_token("\"#{multiple_words_label.title}\"")]) expect_issues_list_count(1) @@ -225,7 +272,7 @@ describe 'Filter issues', :js do double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project) double_quotes_label_issue.labels << double_quotes_label - input_filtered_search("label:~'#{double_quotes_label.title}'") + input_filtered_search("label=~'#{double_quotes_label.title}'") expect_tokens([label_token("'#{double_quotes_label.title}'")]) expect_issues_list_count(1) @@ -237,7 +284,7 @@ describe 'Filter issues', :js do single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project) single_quotes_label_issue.labels << single_quotes_label - input_filtered_search("label:~\"#{single_quotes_label.title}\"") + input_filtered_search("label=~\"#{single_quotes_label.title}\"") expect_tokens([label_token("\"#{single_quotes_label.title}\"")]) expect_issues_list_count(1) @@ -249,7 +296,7 @@ describe 'Filter issues', :js do it 'filters issues by searched label, label2, author, assignee, milestone and text' do search_term = 'bug' - input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}") + input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title} author=@#{user.username} assignee=@#{user.username} milestone=%#{milestone.title} #{search_term}") wait_for_requests @@ -263,6 +310,24 @@ describe 'Filter issues', :js do expect_issues_list_count(1) expect_filtered_search_input(search_term) end + + it 'filters issues by searched label, label2, author, assignee, not included in a milestone' do + search_term = 'bug' + + input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title} author=@#{user.username} assignee=@#{user.username} milestone!=%#{milestone.title} #{search_term}") + + wait_for_requests + + expect_tokens([ + label_token(bug_label.title), + label_token(caps_sensitive_label.title), + author_token(user.name), + assignee_token(user.name), + milestone_token(milestone.title, false, '!=') + ]) + expect_issues_list_count(0) + expect_filtered_search_input(search_term) + end end context 'issue label clicked' do @@ -279,7 +344,7 @@ describe 'Filter issues', :js do describe 'filter issues by milestone' do context 'only milestone' do it 'filters issues by searched milestone' do - input_filtered_search("milestone:%#{milestone.title}") + input_filtered_search("milestone=%#{milestone.title}") expect_tokens([milestone_token(milestone.title)]) expect_issues_list_count(5) @@ -287,53 +352,102 @@ describe 'Filter issues', :js do end it 'filters issues by no milestone' do - input_filtered_search("milestone:none") + input_filtered_search("milestone=none") expect_tokens([milestone_token('None', false)]) expect_issues_list_count(3) expect_filtered_search_input_empty end + it 'filters issues by negation of no milestone' do + input_filtered_search("milestone!=none ") + + expect_tokens([milestone_token('None', false, '!=')]) + expect_issues_list_count(5) + expect_filtered_search_input_empty + end + it 'filters issues by upcoming milestones' do create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone| create(:issue, project: project, milestone: future_milestone, author: user) end - input_filtered_search("milestone:upcoming") + input_filtered_search("milestone=upcoming") expect_tokens([milestone_token('Upcoming', false)]) expect_issues_list_count(1) expect_filtered_search_input_empty end + it 'filters issues by negation of upcoming milestones' do + create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone| + create(:issue, project: project, milestone: future_milestone, author: user) + end + + input_filtered_search("milestone!=upcoming") + + expect_tokens([milestone_token('Upcoming', false, '!=')]) + expect_issues_list_count(8) + expect_filtered_search_input_empty + end + it 'filters issues by started milestones' do - input_filtered_search("milestone:started") + input_filtered_search("milestone=started") expect_tokens([milestone_token('Started', false)]) expect_issues_list_count(5) expect_filtered_search_input_empty end + it 'filters issues by negation of started milestones' do + input_filtered_search("milestone!=started") + + expect_tokens([milestone_token('Started', false, '!=')]) + expect_issues_list_count(3) + expect_filtered_search_input_empty + end + it 'filters issues by milestone containing special characters' do special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) create(:issue, project: project, milestone: special_milestone) - input_filtered_search("milestone:%#{special_milestone.title}") + input_filtered_search("milestone=%#{special_milestone.title}") expect_tokens([milestone_token(special_milestone.title)]) expect_issues_list_count(1) expect_filtered_search_input_empty end + it 'filters issues by milestone not containing special characters' do + special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) + create(:issue, project: project, milestone: special_milestone) + + input_filtered_search("milestone!=%#{special_milestone.title}") + + expect_tokens([milestone_token(special_milestone.title, false, '!=')]) + expect_issues_list_count(8) + expect_filtered_search_input_empty + end + it 'does not show issues for unused milestones' do new_milestone = create(:milestone, title: 'new', project: project) - input_filtered_search("milestone:%#{new_milestone.title}") + input_filtered_search("milestone=%#{new_milestone.title}") expect_tokens([milestone_token(new_milestone.title)]) expect_no_issues_list expect_filtered_search_input_empty end + + it 'show issues for unused milestones' do + new_milestone = create(:milestone, title: 'new', project: project) + + input_filtered_search("milestone!=%#{new_milestone.title}") + + expect_tokens([milestone_token(new_milestone.title, false, '!=')]) + expect_issues_list_count(8) + expect_filtered_search_input_empty + end end end @@ -407,7 +521,7 @@ describe 'Filter issues', :js do context 'searched text with other filters' do it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do - input_filtered_search("bug author:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo") + input_filtered_search("bug author=@#{user.username} report label=~#{bug_label.title} label=~#{caps_sensitive_label.title} milestone=%#{milestone.title} foo") expect_issues_list_count(1) expect_filtered_search_input('bug report foo') @@ -481,7 +595,7 @@ describe 'Filter issues', :js do end it 'milestone dropdown loads milestones' do - input_filtered_search("milestone:", submit: false) + input_filtered_search("milestone=", submit: false) within('#js-dropdown-milestone') do expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) @@ -489,7 +603,7 @@ describe 'Filter issues', :js do end it 'label dropdown load labels' do - input_filtered_search("label:", submit: false) + input_filtered_search("label=", submit: false) within('#js-dropdown-label') do expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3) diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb index c038281d825..e05c7aa3af5 100644 --- a/spec/features/issues/filtered_search/recent_searches_spec.rb +++ b/spec/features/issues/filtered_search/recent_searches_spec.rb @@ -41,8 +41,8 @@ describe 'Recent searches', :js do items = all('.filtered-search-history-dropdown-item', visible: false, count: 2) - expect(items[0].text).to eq('label: ~qux garply') - expect(items[1].text).to eq('label: ~foo bar') + expect(items[0].text).to eq('label: = ~qux garply') + expect(items[1].text).to eq('label: = ~foo bar') end it 'saved recent searches are restored last on the list' do diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb index e97314e02e6..ad994270218 100644 --- a/spec/features/issues/filtered_search/search_bar_spec.rb +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -34,7 +34,7 @@ describe 'Search bar', :js do it 'selects item' do filtered_search.native.send_keys(:down, :down, :enter) - expect_tokens([author_token]) + expect_tokens([{ name: 'Assignee' }]) expect_filtered_search_input_empty end end @@ -78,7 +78,7 @@ describe 'Search bar', :js do filtered_search.click original_size = page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size - filtered_search.set('author') + filtered_search.set('autho') expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index d1e976c3bca..2af2e096bcc 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -36,8 +36,9 @@ describe 'Visual tokens', :js do describe 'editing a single token' do before do - input_filtered_search('author:@root assignee:none', submit: false) + input_filtered_search('author=@root assignee=none', submit: false) first('.tokens-container .filtered-search-token').click + wait_for_requests end it 'opens author dropdown' do @@ -76,8 +77,8 @@ describe 'Visual tokens', :js do describe 'editing multiple tokens' do before do - input_filtered_search('author:@root assignee:none', submit: false) - first('.tokens-container .filtered-search-token').double_click + input_filtered_search('author=@root assignee=none', submit: false) + first('.tokens-container .filtered-search-token').click end it 'opens author dropdown' do @@ -85,27 +86,33 @@ describe 'Visual tokens', :js do end it 'opens assignee dropdown' do - find('.tokens-container .filtered-search-token', text: 'Assignee').double_click + find('.tokens-container .filtered-search-token', text: 'Assignee').click expect(page).to have_css('#js-dropdown-assignee', visible: true) end end describe 'editing a search term while editing another filter token' do before do - input_filtered_search('author assignee:', submit: false) - first('.tokens-container .filtered-search-term').double_click + input_filtered_search('foo assignee=', submit: false) + first('.tokens-container .filtered-search-term').click end it 'opens author dropdown' do - find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'author').click + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'Author').click + + expect(page).to have_css('#js-dropdown-operator', visible: true) + expect(page).to have_css('#js-dropdown-author', visible: false) + + find('#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value="="]').click + expect(page).to have_css('#js-dropdown-operator', visible: false) expect(page).to have_css('#js-dropdown-author', visible: true) end end describe 'add new token after editing existing token' do before do - input_filtered_search('author:@root assignee:none', submit: false) + input_filtered_search('author=@root assignee=none', submit: false) first('.tokens-container .filtered-search-token').double_click filtered_search.send_keys(' ') end @@ -116,7 +123,7 @@ describe 'Visual tokens', :js do end it 'opens token dropdown' do - filtered_search.send_keys('author:') + filtered_search.send_keys('author=') expect(page).to have_css('#js-dropdown-author', visible: true) end @@ -124,7 +131,7 @@ describe 'Visual tokens', :js do describe 'visual tokens' do it 'creates visual token' do - filtered_search.send_keys('author:@thomas ') + filtered_search.send_keys('author=@thomas ') token = page.all('.tokens-container .filtered-search-token')[1] expect(token.find('.name').text).to eq('Author') @@ -133,7 +140,7 @@ describe 'Visual tokens', :js do end it 'does not tokenize incomplete token' do - filtered_search.send_keys('author:') + filtered_search.send_keys('author=') find('body').click token = page.all('.tokens-container .js-visual-token')[1] @@ -145,7 +152,7 @@ describe 'Visual tokens', :js do describe 'search using incomplete visual tokens' do before do - input_filtered_search('author:@root assignee:none', extra_space: false) + input_filtered_search('author=@root assignee=none', extra_space: false) end it 'tokenizes the search term to complete visual token' do diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb index b7a45905845..c1a2e22a0c2 100644 --- a/spec/features/labels_hierarchy_spec.rb +++ b/spec/features/labels_hierarchy_spec.rb @@ -70,7 +70,7 @@ describe 'Labels Hierarchy', :js do end it 'does not filter by descendant group labels' do - filtered_search.set("label:") + filtered_search.set("label=") wait_for_requests @@ -134,7 +134,7 @@ describe 'Labels Hierarchy', :js do end it 'does not filter by descendant group project labels' do - filtered_search.set("label:") + filtered_search.set("label=") wait_for_requests @@ -227,7 +227,7 @@ describe 'Labels Hierarchy', :js do it_behaves_like 'filtering by ancestor labels for projects' it 'does not filter by descendant group labels' do - filtered_search.set("label:") + filtered_search.set("label=") wait_for_requests diff --git a/spec/features/merge_requests/filters_generic_behavior_spec.rb b/spec/features/merge_requests/filters_generic_behavior_spec.rb index 58aad1b7e91..c3400acae4f 100644 --- a/spec/features/merge_requests/filters_generic_behavior_spec.rb +++ b/spec/features/merge_requests/filters_generic_behavior_spec.rb @@ -23,7 +23,7 @@ describe 'Merge Requests > Filters generic behavior', :js do context 'when filtered by a label' do before do - input_filtered_search('label:~bug') + input_filtered_search('label=~bug') end describe 'state tabs' do diff --git a/spec/features/merge_requests/user_filters_by_assignees_spec.rb b/spec/features/merge_requests/user_filters_by_assignees_spec.rb index 00bd8455ae1..3abee3b656a 100644 --- a/spec/features/merge_requests/user_filters_by_assignees_spec.rb +++ b/spec/features/merge_requests/user_filters_by_assignees_spec.rb @@ -18,7 +18,7 @@ describe 'Merge Requests > User filters by assignees', :js do context 'filtering by assignee:none' do it 'applies the filter' do - input_filtered_search('assignee:none') + input_filtered_search('assignee=none') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).not_to have_content 'Bugfix1' @@ -26,9 +26,9 @@ describe 'Merge Requests > User filters by assignees', :js do end end - context 'filtering by assignee:@username' do + context 'filtering by assignee=@username' do it 'applies the filter' do - input_filtered_search("assignee:@#{user.username}") + input_filtered_search("assignee=@#{user.username}") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix1' diff --git a/spec/features/merge_requests/user_filters_by_labels_spec.rb b/spec/features/merge_requests/user_filters_by_labels_spec.rb index fd2b4b23f96..7a80ebe9be3 100644 --- a/spec/features/merge_requests/user_filters_by_labels_spec.rb +++ b/spec/features/merge_requests/user_filters_by_labels_spec.rb @@ -22,7 +22,7 @@ describe 'Merge Requests > User filters by labels', :js do context 'filtering by label:none' do it 'applies the filter' do - input_filtered_search('label:none') + input_filtered_search('label=none') expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).not_to have_content 'Bugfix1' @@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by labels', :js do context 'filtering by label:~enhancement' do it 'applies the filter' do - input_filtered_search('label:~enhancement') + input_filtered_search('label=~enhancement') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix2' @@ -42,7 +42,7 @@ describe 'Merge Requests > User filters by labels', :js do context 'filtering by label:~enhancement and label:~bug' do it 'applies the filters' do - input_filtered_search('label:~bug label:~enhancement') + input_filtered_search('label=~bug label=~enhancement') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix2' diff --git a/spec/features/merge_requests/user_filters_by_milestones_spec.rb b/spec/features/merge_requests/user_filters_by_milestones_spec.rb index e0ee69d7a5b..8cb686e191e 100644 --- a/spec/features/merge_requests/user_filters_by_milestones_spec.rb +++ b/spec/features/merge_requests/user_filters_by_milestones_spec.rb @@ -18,14 +18,14 @@ describe 'Merge Requests > User filters by milestones', :js do end it 'filters by no milestone' do - input_filtered_search('milestone:none') + input_filtered_search('milestone=none') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) end it 'filters by a specific milestone' do - input_filtered_search("milestone:%'#{milestone.title}'") + input_filtered_search("milestone=%'#{milestone.title}'") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) @@ -33,7 +33,7 @@ describe 'Merge Requests > User filters by milestones', :js do describe 'filters by upcoming milestone' do it 'does not show merge requests with no expiry' do - input_filtered_search('milestone:upcoming') + input_filtered_search('milestone=upcoming') expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).to have_css('.merge-request', count: 0) @@ -43,7 +43,7 @@ describe 'Merge Requests > User filters by milestones', :js do let(:milestone) { create(:milestone, project: project, due_date: Date.tomorrow) } it 'shows merge requests' do - input_filtered_search('milestone:upcoming') + input_filtered_search('milestone=upcoming') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) @@ -54,7 +54,7 @@ describe 'Merge Requests > User filters by milestones', :js do let(:milestone) { create(:milestone, project: project, due_date: Date.yesterday) } it 'does not show any merge requests' do - input_filtered_search('milestone:upcoming') + input_filtered_search('milestone=upcoming') expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).to have_css('.merge-request', count: 0) diff --git a/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb b/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb index bc6e2ac5132..5c9d53778d2 100644 --- a/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb +++ b/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb @@ -20,7 +20,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do describe 'filtering by label:~"Won\'t fix" and assignee:~bug' do it 'applies the filters' do - input_filtered_search("label:~\"Won't fix\" assignee:@#{user.username}") + input_filtered_search("label=~\"Won't fix\" assignee=@#{user.username}") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix2' @@ -30,7 +30,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do describe 'filtering by text, author, assignee, milestone, and label' do it 'filters by text, author, assignee, milestone, and label' do - input_filtered_search_keys("author:@#{user.username} assignee:@#{user.username} milestone:%\"v1.1\" label:~\"Won't fix\" Bug") + input_filtered_search_keys("author=@#{user.username} assignee=@#{user.username} milestone=%\"v1.1\" label=~\"Won't fix\" Bug") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix2' diff --git a/spec/features/merge_requests/user_filters_by_target_branch_spec.rb b/spec/features/merge_requests/user_filters_by_target_branch_spec.rb index 0d03c5eae31..faff7de729d 100644 --- a/spec/features/merge_requests/user_filters_by_target_branch_spec.rb +++ b/spec/features/merge_requests/user_filters_by_target_branch_spec.rb @@ -17,7 +17,7 @@ describe 'Merge Requests > User filters by target branch', :js do context 'filtering by target-branch:master' do it 'applies the filter' do - input_filtered_search('target-branch:master') + input_filtered_search('target-branch=master') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content mr1.title @@ -27,7 +27,7 @@ describe 'Merge Requests > User filters by target branch', :js do context 'filtering by target-branch:merged-target' do it 'applies the filter' do - input_filtered_search('target-branch:merged-target') + input_filtered_search('target-branch=merged-target') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).not_to have_content mr1.title @@ -37,7 +37,7 @@ describe 'Merge Requests > User filters by target branch', :js do context 'filtering by target-branch:feature' do it 'applies the filter' do - input_filtered_search('target-branch:feature') + input_filtered_search('target-branch=feature') expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).not_to have_content mr1.title diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index f00ba884a6c..66104724163 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -1,15 +1,7 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { - GlEmptyState, - GlLoadingIcon, - GlTable, - GlLink, - GlFormInput, - GlDropdown, - GlDropdownItem, - GlPagination, -} from '@gitlab/ui'; +import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination } from '@gitlab/ui'; +import stubChildren from 'helpers/stub_children'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; import errorsList from './list_mock.json'; @@ -32,19 +24,12 @@ describe('ErrorTrackingList', () => { function mountComponent({ errorTrackingEnabled = true, userCanEnableErrorTracking = true, - sync = true, - stubs = { - 'gl-link': GlLink, - 'gl-table': GlTable, - 'gl-pagination': GlPagination, - 'gl-dropdown': GlDropdown, - 'gl-dropdown-item': GlDropdownItem, - }, + stubs = {}, } = {}) { - wrapper = shallowMount(ErrorTrackingList, { + wrapper = mount(ErrorTrackingList, { localVue, store, - sync, + sync: false, propsData: { indexPath: '/path', enableErrorTrackingLink: '/link', @@ -52,7 +37,10 @@ describe('ErrorTrackingList', () => { errorTrackingEnabled, illustrationPath: 'illustration/path', }, - stubs, + stubs: { + ...stubChildren(ErrorTrackingList), + ...stubs, + }, data() { return { errorSearchQuery: 'search' }; }, @@ -122,7 +110,14 @@ describe('ErrorTrackingList', () => { beforeEach(() => { store.state.list.loading = false; store.state.list.errors = errorsList; - mountComponent(); + mountComponent({ + stubs: { + GlTable: false, + GlDropdown: false, + GlDropdownItem: false, + GlLink: false, + }, + }); }); it('shows table', () => { @@ -173,7 +168,13 @@ describe('ErrorTrackingList', () => { store.state.list.loading = false; store.state.list.errors = []; - mountComponent(); + mountComponent({ + stubs: { + GlTable: false, + GlDropdown: false, + GlDropdownItem: false, + }, + }); }); it('shows empty table', () => { @@ -187,7 +188,7 @@ describe('ErrorTrackingList', () => { }); it('restarts polling', () => { - findRefreshLink().trigger('click'); + findRefreshLink().vm.$emit('click'); expect(actions.restartPolling).toHaveBeenCalled(); }); }); @@ -211,8 +212,8 @@ describe('ErrorTrackingList', () => { errorTrackingEnabled: false, userCanEnableErrorTracking: false, stubs: { - 'gl-link': GlLink, - 'gl-empty-state': GlEmptyState, + GlLink: false, + GlEmptyState: false, }, }); }); @@ -226,7 +227,12 @@ describe('ErrorTrackingList', () => { describe('recent searches', () => { beforeEach(() => { - mountComponent(); + mountComponent({ + stubs: { + GlDropdown: false, + GlDropdownItem: false, + }, + }); }); it('shows empty message', () => { @@ -238,11 +244,12 @@ describe('ErrorTrackingList', () => { it('shows items', () => { store.state.list.recentSearches = ['great', 'search']; - const dropdownItems = wrapper.findAll('.filtered-search-box li'); - - expect(dropdownItems.length).toBe(3); - expect(dropdownItems.at(0).text()).toBe('great'); - expect(dropdownItems.at(1).text()).toBe('search'); + return wrapper.vm.$nextTick().then(() => { + const dropdownItems = wrapper.findAll('.filtered-search-box li'); + expect(dropdownItems.length).toBe(3); + expect(dropdownItems.at(0).text()).toBe('great'); + expect(dropdownItems.at(1).text()).toBe('search'); + }); }); describe('clear', () => { @@ -257,16 +264,20 @@ describe('ErrorTrackingList', () => { it('is visible when list has items', () => { store.state.list.recentSearches = ['some', 'searches']; - expect(clearRecentButton().exists()).toBe(true); - expect(clearRecentButton().text()).toBe('Clear recent searches'); + return wrapper.vm.$nextTick().then(() => { + expect(clearRecentButton().exists()).toBe(true); + expect(clearRecentButton().text()).toBe('Clear recent searches'); + }); }); it('clears items on click', () => { store.state.list.recentSearches = ['some', 'searches']; - clearRecentButton().vm.$emit('click'); + return wrapper.vm.$nextTick().then(() => { + clearRecentButton().vm.$emit('click'); - expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1); + expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1); + }); }); }); }); @@ -287,7 +298,11 @@ describe('ErrorTrackingList', () => { describe('and the user is on the first page', () => { beforeEach(() => { store.state.list.loading = false; - mountComponent({ sync: false }); + mountComponent({ + stubs: { + GlPagination: false, + }, + }); }); it('shows a disabled Prev button', () => { @@ -299,8 +314,14 @@ describe('ErrorTrackingList', () => { describe('and the previous button is clicked', () => { beforeEach(() => { store.state.list.loading = false; - mountComponent({ sync: false }); + mountComponent({ + stubs: { + GlTable: false, + GlPagination: false, + }, + }); wrapper.setData({ pageValue: 2 }); + return wrapper.vm.$nextTick(); }); it('fetches the previous page of results', () => { @@ -318,7 +339,7 @@ describe('ErrorTrackingList', () => { describe('and the next page button is clicked', () => { beforeEach(() => { store.state.list.loading = false; - mountComponent({ sync: false }); + mountComponent(); }); it('fetches the next page of results', () => { diff --git a/spec/frontend/filtered_search/filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/filtered_search_token_keys_spec.js index d1fea18dea8..f24d2b118c2 100644 --- a/spec/frontend/filtered_search/filtered_search_token_keys_spec.js +++ b/spec/frontend/filtered_search/filtered_search_token_keys_spec.js @@ -124,6 +124,7 @@ describe('Filtered Search Token Keys', () => { const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue( null, null, + null, ); expect(condition).toBeNull(); @@ -132,6 +133,7 @@ describe('Filtered Search Token Keys', () => { it('should return condition when found by tokenKey and value', () => { const result = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue( conditions[0].tokenKey, + conditions[0].operator, conditions[0].value, ); diff --git a/spec/frontend/helpers/stub_children.js b/spec/frontend/helpers/stub_children.js new file mode 100644 index 00000000000..91171eb3d8c --- /dev/null +++ b/spec/frontend/helpers/stub_children.js @@ -0,0 +1,3 @@ +export default function stubChildren(Component) { + return Object.fromEntries(Object.keys(Component.components).map(c => [c, true])); +} diff --git a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js index c88a182660d..f1b4c370532 100644 --- a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js +++ b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import UserModalManager from '~/pages/admin/users/components/user_modal_manager.vue'; import ModalStub from './stubs/modal_stub'; @@ -22,17 +22,13 @@ describe('Users admin page Modal Manager', () => { let wrapper; const createComponent = (props = {}) => { - wrapper = shallowMount(UserModalManager, { + wrapper = mount(UserModalManager, { propsData: { actionModals, modalConfiguration, csrfToken: 'dummyCSRF', ...props, }, - stubs: { - dummyComponent1: true, - dummyComponent2: true, - }, sync: false, }); }; diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index 18ab03653f4..22346c10547 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -398,14 +398,21 @@ describe('DropLab DropDown', function() { describe('render', function() { beforeEach(function() { - this.list = { querySelector: () => {}, dispatchEvent: () => {} }; - this.dropdown = { renderChildren: () => {}, list: this.list }; this.renderableList = {}; + this.list = { + querySelector: q => { + if (q === '.filter-dropdown-loading') { + return false; + } + return this.renderableList; + }, + dispatchEvent: () => {}, + }; + this.dropdown = { renderChildren: () => {}, list: this.list }; this.data = [0, 1]; this.customEvent = {}; spyOn(this.dropdown, 'renderChildren').and.callFake(data => data); - spyOn(this.list, 'querySelector').and.returnValue(this.renderableList); spyOn(this.list, 'dispatchEvent'); spyOn(this.data, 'map').and.callThrough(); spyOn(window, 'CustomEvent').and.returnValue(this.customEvent); diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js index 62d1bd69635..6eda4f391a4 100644 --- a/spec/javascripts/filtered_search/dropdown_utils_spec.js +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js @@ -222,7 +222,7 @@ describe('Dropdown Utils', () => { hasAttribute: () => false, }; - DropdownUtils.setDataValueIfSelected(null, selected); + DropdownUtils.setDataValueIfSelected(null, '=', selected); expect(FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1); }); @@ -233,9 +233,11 @@ describe('Dropdown Utils', () => { hasAttribute: () => false, }; - const result = DropdownUtils.setDataValueIfSelected(null, selected); + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); expect(result).toBe(true); + expect(result2).toBe(true); }); it('returns false when dataValue does not exist', () => { @@ -243,9 +245,11 @@ describe('Dropdown Utils', () => { getAttribute: () => null, }; - const result = DropdownUtils.setDataValueIfSelected(null, selected); + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); expect(result).toBe(false); + expect(result2).toBe(false); }); }); @@ -349,7 +353,7 @@ describe('Dropdown Utils', () => { beforeEach(() => { loadFixtures(issueListFixture); - authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); const tokensContainer = document.querySelector('.tokens-container'); @@ -364,7 +368,7 @@ describe('Dropdown Utils', () => { const searchQuery = DropdownUtils.getSearchQuery(); - expect(searchQuery).toBe(' search term author:original dance'); + expect(searchQuery).toBe(' search term author:=original dance'); }); }); }); diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js index 8c5a0961a02..853f6b3b7b8 100644 --- a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js @@ -27,7 +27,7 @@ describe('Filtered Search Dropdown Manager', () => { describe('input has no existing value', () => { it('should add just tokenName', () => { - FilteredSearchDropdownManager.addWordToInput('milestone'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'milestone' }); const token = document.querySelector('.tokens-container .js-visual-token'); @@ -36,8 +36,8 @@ describe('Filtered Search Dropdown Manager', () => { expect(getInputValue()).toBe(''); }); - it('should add tokenName and tokenValue', () => { - FilteredSearchDropdownManager.addWordToInput('label'); + it('should add tokenName, tokenOperator, and tokenValue', () => { + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' }); let token = document.querySelector('.tokens-container .js-visual-token'); @@ -45,13 +45,27 @@ describe('Filtered Search Dropdown Manager', () => { expect(token.querySelector('.name').innerText).toBe('label'); expect(getInputValue()).toBe(''); - FilteredSearchDropdownManager.addWordToInput('label', 'none'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label', tokenOperator: '=' }); + + token = document.querySelector('.tokens-container .js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toBe('label'); + expect(token.querySelector('.operator').innerText).toBe('='); + expect(getInputValue()).toBe(''); + + FilteredSearchDropdownManager.addWordToInput({ + tokenName: 'label', + tokenOperator: '=', + tokenValue: 'none', + }); // We have to get that reference again // Because FilteredSearchDropdownManager deletes the previous token token = document.querySelector('.tokens-container .js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('label'); + expect(token.querySelector('.operator').innerText).toBe('='); expect(token.querySelector('.value').innerText).toBe('none'); expect(getInputValue()).toBe(''); }); @@ -60,7 +74,7 @@ describe('Filtered Search Dropdown Manager', () => { describe('input has existing value', () => { it('should be able to just add tokenName', () => { setInputValue('a'); - FilteredSearchDropdownManager.addWordToInput('author'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' }); const token = document.querySelector('.tokens-container .js-visual-token'); @@ -70,29 +84,40 @@ describe('Filtered Search Dropdown Manager', () => { }); it('should replace tokenValue', () => { - FilteredSearchDropdownManager.addWordToInput('author'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' }); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author', tokenOperator: '=' }); setInputValue('roo'); - FilteredSearchDropdownManager.addWordToInput(null, '@root'); + FilteredSearchDropdownManager.addWordToInput({ + tokenName: null, + tokenOperator: '=', + tokenValue: '@root', + }); const token = document.querySelector('.tokens-container .js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('author'); + expect(token.querySelector('.operator').innerText).toBe('='); expect(token.querySelector('.value').innerText).toBe('@root'); expect(getInputValue()).toBe(''); }); it('should add tokenValues containing spaces', () => { - FilteredSearchDropdownManager.addWordToInput('label'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' }); setInputValue('"test '); - FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\''); + FilteredSearchDropdownManager.addWordToInput({ + tokenName: 'label', + tokenOperator: '=', + tokenValue: '~\'"test me"\'', + }); const token = document.querySelector('.tokens-container .js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('label'); + expect(token.querySelector('.operator').innerText).toBe('='); expect(token.querySelector('.value').innerText).toBe('~\'"test me"\''); expect(getInputValue()).toBe(''); }); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index e076120f5cc..e5d1d1d690e 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -201,8 +201,8 @@ describe('Filtered Search Manager', function() { it('removes duplicated tokens', done => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} `); spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => { @@ -234,7 +234,7 @@ describe('Filtered Search Manager', function() { it('should not render placeholder when there are tokens and no input', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), ); const event = new Event('input'); @@ -252,7 +252,7 @@ describe('Filtered Search Manager', function() { describe('tokens and no input', () => { beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), ); }); @@ -306,7 +306,7 @@ describe('Filtered Search Manager', function() { it('removes token even when it is already selected', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), ); tokensContainer.querySelector('.js-visual-token .remove-token').click(); @@ -319,7 +319,7 @@ describe('Filtered Search Manager', function() { spyOn(FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'), ); tokensContainer.querySelector('.js-visual-token .remove-token').click(); }); @@ -338,7 +338,7 @@ describe('Filtered Search Manager', function() { beforeEach(() => { initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), ); }); @@ -424,7 +424,7 @@ describe('Filtered Search Manager', function() { }); it('Clicking the "x" clear button, clears the input', () => { - const inputValue = 'label:~bug '; + const inputValue = 'label:=~bug'; manager.filteredSearchInput.value = inputValue; manager.filteredSearchInput.dispatchEvent(new Event('input')); diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index 0ee13faf841..fda078bd41c 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -6,9 +6,10 @@ describe('Filtered Search Visual Tokens', () => { const findElements = tokenElement => { const tokenNameElement = tokenElement.querySelector('.name'); + const tokenOperatorElement = tokenElement.querySelector('.operator'); const tokenValueContainer = tokenElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); - return { tokenNameElement, tokenValueContainer, tokenValueElement }; + return { tokenNameElement, tokenOperatorElement, tokenValueContainer, tokenValueElement }; }; let tokensContainer; @@ -23,8 +24,8 @@ describe('Filtered Search Visual Tokens', () => { `); tokensContainer = document.querySelector('.tokens-container'); - authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); - bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); }); describe('getLastVisualTokenBeforeInput', () => { @@ -62,7 +63,7 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); @@ -92,7 +93,7 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); @@ -105,7 +106,7 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); @@ -150,8 +151,8 @@ describe('Filtered Search Visual Tokens', () => { it('removes the selected class from buttons', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@author')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '%123', true)} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@author')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '%123', true)} `); const selected = tokensContainer.querySelector('.js-visual-token .selected'); @@ -169,7 +170,7 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~awesome')} `); }); @@ -206,7 +207,7 @@ describe('Filtered Search Visual Tokens', () => { describe('removeSelectedToken', () => { it('does not remove when there are no selected tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'), ); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); @@ -218,7 +219,7 @@ describe('Filtered Search Visual Tokens', () => { it('removes selected token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), ); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); @@ -281,16 +282,22 @@ describe('Filtered Search Visual Tokens', () => { describe('addVisualTokenElement', () => { it('renders search visual tokens', () => { - subject.addVisualTokenElement('search term', null, { isSearchTerm: true }); + subject.addVisualTokenElement({ + name: 'search term', + operator: '=', + value: null, + options: { isSearchTerm: true }, + }); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-term')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('search term'); + expect(token.querySelector('.operator').innerText).toEqual('='); expect(token.querySelector('.value')).toEqual(null); }); it('renders filter visual token name', () => { - subject.addVisualTokenElement('milestone'); + subject.addVisualTokenElement({ name: 'milestone' }); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('search-token-milestone')).toEqual(true); @@ -299,22 +306,23 @@ describe('Filtered Search Visual Tokens', () => { expect(token.querySelector('.value')).toEqual(null); }); - it('renders filter visual token name and value', () => { - subject.addVisualTokenElement('label', 'Frontend'); + it('renders filter visual token name, operator, and value', () => { + subject.addVisualTokenElement({ name: 'label', operator: '!=', value: 'Frontend' }); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('search-token-label')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('label'); + expect(token.querySelector('.operator').innerText).toEqual('!='); expect(token.querySelector('.value').innerText).toEqual('Frontend'); }); it('inserts visual token before input', () => { tokensContainer.appendChild( - FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root'), + FilteredSearchSpecHelper.createFilterVisualToken('assignee', '=', '@root'), ); - subject.addVisualTokenElement('label', 'Frontend'); + subject.addVisualTokenElement({ name: 'label', operator: '!=', value: 'Frontend' }); const tokens = tokensContainer.querySelectorAll('.js-visual-token'); const labelToken = tokens[0]; const assigneeToken = tokens[1]; @@ -323,18 +331,20 @@ describe('Filtered Search Visual Tokens', () => { expect(labelToken.classList.contains('filtered-search-token')).toEqual(true); expect(labelToken.querySelector('.name').innerText).toEqual('label'); expect(labelToken.querySelector('.value').innerText).toEqual('Frontend'); + expect(labelToken.querySelector('.operator').innerText).toEqual('!='); expect(assigneeToken.classList.contains('search-token-assignee')).toEqual(true); expect(assigneeToken.classList.contains('filtered-search-token')).toEqual(true); expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee'); expect(assigneeToken.querySelector('.value').innerText).toEqual('@root'); + expect(assigneeToken.querySelector('.operator').innerText).toEqual('='); }); }); describe('addValueToPreviousVisualTokenElement', () => { it('does not add when previous visual token element has no value', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root'), ); const original = tokensContainer.innerHTML; @@ -345,7 +355,7 @@ describe('Filtered Search Visual Tokens', () => { it('does not add when previous visual token element is a search', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} `); @@ -357,7 +367,7 @@ describe('Filtered Search Visual Tokens', () => { it('adds value to previous visual filter token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label'), + FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label', '='), ); const original = tokensContainer.innerHTML; @@ -377,25 +387,28 @@ describe('Filtered Search Visual Tokens', () => { expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('milestone'); + expect(token.querySelector('.operator')).toEqual(null); expect(token.querySelector('.value')).toEqual(null); }); it('creates visual token with just tokenValue', () => { - subject.addFilterVisualToken('milestone'); + subject.addFilterVisualToken('milestone', '='); subject.addFilterVisualToken('%8.17'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('milestone'); + expect(token.querySelector('.operator').innerText).toEqual('='); expect(token.querySelector('.value').innerText).toEqual('%8.17'); }); it('creates full visual token', () => { - subject.addFilterVisualToken('assignee', '@john'); + subject.addFilterVisualToken('assignee', '=', '@john'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('assignee'); + expect(token.querySelector('.operator').innerText).toEqual('='); expect(token.querySelector('.value').innerText).toEqual('@john'); }); }); @@ -412,7 +425,7 @@ describe('Filtered Search Visual Tokens', () => { it('appends to previous search visual token if previous token was a search token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} `); @@ -467,7 +480,11 @@ describe('Filtered Search Visual Tokens', () => { describe('removeLastTokenPartial', () => { it('should remove the last token value if it exists', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~"Community Contribution"'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML( + 'label', + '=', + '~"Community Contribution"', + ), ); expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null); @@ -507,7 +524,7 @@ describe('Filtered Search Visual Tokens', () => { it('adds search visual token if previous visual token is valid', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', 'none'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', '=', 'none'), ); const input = document.querySelector('.filtered-search'); @@ -523,7 +540,7 @@ describe('Filtered Search Visual Tokens', () => { it('adds value to previous visual token element if previous visual token is invalid', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee'), + FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('assignee', '='), ); const input = document.querySelector('.filtered-search'); @@ -534,6 +551,7 @@ describe('Filtered Search Visual Tokens', () => { expect(input.value).toEqual(''); expect(updatedToken.querySelector('.name').innerText).toEqual('assignee'); + expect(updatedToken.querySelector('.operator').innerText).toEqual('='); expect(updatedToken.querySelector('.value').innerText).toEqual('@john'); }); }); @@ -544,9 +562,9 @@ describe('Filtered Search Visual Tokens', () => { beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'upcoming')} `); input = document.querySelector('.filtered-search'); @@ -614,7 +632,7 @@ describe('Filtered Search Visual Tokens', () => { describe('moveInputTotheRight', () => { it('does nothing if the input is already the right most element', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none'), ); spyOn(subject, 'tokenizeInput').and.callFake(() => {}); @@ -628,12 +646,12 @@ describe('Filtered Search Visual Tokens', () => { it("tokenize's input", () => { tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label', '=')} ${FilteredSearchSpecHelper.createInputHTML()} ${bugLabelToken.outerHTML} `; - document.querySelector('.filtered-search').value = 'none'; + tokensContainer.querySelector('.filtered-search').value = 'none'; subject.moveInputToTheRight(); const value = tokensContainer.querySelector('.js-visual-token .value'); @@ -643,7 +661,7 @@ describe('Filtered Search Visual Tokens', () => { it('converts input into search term token if last token is valid', () => { tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')} ${FilteredSearchSpecHelper.createInputHTML()} ${bugLabelToken.outerHTML} `; @@ -658,7 +676,7 @@ describe('Filtered Search Visual Tokens', () => { it('moves the input to the right most element', () => { tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')} ${FilteredSearchSpecHelper.createInputHTML()} ${bugLabelToken.outerHTML} `; @@ -670,8 +688,8 @@ describe('Filtered Search Visual Tokens', () => { it('tokenizes input even if input is the right most element', () => { tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} - ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')} + ${FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createInputHTML('', '~bug')} `; diff --git a/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js index e33a6c002e5..c7be900ba2c 100644 --- a/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js +++ b/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js @@ -138,6 +138,7 @@ describe('Issues Filtered Search Token Keys', () => { const conditions = IssuableFilteredSearchTokenKeys.getConditions(); const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue( conditions[0].tokenKey, + conditions[0].operator, conditions[0].value, ); diff --git a/spec/javascripts/filtered_search/visual_token_value_spec.js b/spec/javascripts/filtered_search/visual_token_value_spec.js index 5863005de1e..a039e280028 100644 --- a/spec/javascripts/filtered_search/visual_token_value_spec.js +++ b/spec/javascripts/filtered_search/visual_token_value_spec.js @@ -10,9 +10,11 @@ describe('Filtered Search Visual Tokens', () => { const tokenNameElement = tokenElement.querySelector('.name'); const tokenValueContainer = tokenElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); + const tokenOperatorElement = tokenElement.querySelector('.operator'); const tokenType = tokenNameElement.innerText.toLowerCase(); const tokenValue = tokenValueElement.innerText; - const subject = new VisualTokenValue(tokenValue, tokenType); + const tokenOperator = tokenOperatorElement.innerText; + const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator); return { subject, tokenValueContainer, tokenValueElement }; }; @@ -28,8 +30,8 @@ describe('Filtered Search Visual Tokens', () => { `); tokensContainer = document.querySelector('.tokens-container'); - authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); - bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); }); describe('updateUserTokenAppearance', () => { @@ -140,10 +142,12 @@ describe('Filtered Search Visual Tokens', () => { const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( 'label', + '=', '~doesnotexist', ); const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( 'label', + '=', '~"some space"', ); diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js b/spec/javascripts/helpers/filtered_search_spec_helper.js index fd06bb1f324..ceb7982bbc3 100644 --- a/spec/javascripts/helpers/filtered_search_spec_helper.js +++ b/spec/javascripts/helpers/filtered_search_spec_helper.js @@ -1,15 +1,17 @@ export default class FilteredSearchSpecHelper { - static createFilterVisualTokenHTML(name, value, isSelected) { - return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML; + static createFilterVisualTokenHTML(name, operator, value, isSelected) { + return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected) + .outerHTML; } - static createFilterVisualToken(name, value, isSelected = false) { + static createFilterVisualToken(name, operator, value, isSelected = false) { const li = document.createElement('li'); li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`); li.innerHTML = `
${name}
+
${operator}
${value}
@@ -30,6 +32,15 @@ export default class FilteredSearchSpecHelper { `; } + static createNameOperatorFilterVisualTokenHTML(name, operator) { + return ` +
  • +
    ${name}
    +
    ${operator}
    +
  • + `; + } + static createSearchVisualToken(name) { const li = document.createElement('li'); li.classList.add('js-visual-token', 'filtered-search-term'); diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 0dd2ebdb70d..8ddb4c23b81 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -8,6 +8,7 @@ issues: - milestone - notes - resource_label_events +- resource_weight_events - sentry_issue - label_links - labels diff --git a/spec/models/resource_weight_event_spec.rb b/spec/models/resource_weight_event_spec.rb new file mode 100644 index 00000000000..2f00204512e --- /dev/null +++ b/spec/models/resource_weight_event_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceWeightEvent, type: :model do + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + let_it_be(:issue1) { create(:issue, author: user1) } + let_it_be(:issue2) { create(:issue, author: user1) } + let_it_be(:issue3) { create(:issue, author: user2) } + + describe 'validations' do + it { is_expected.not_to allow_value(nil).for(:user) } + it { is_expected.not_to allow_value(nil).for(:issue) } + it { is_expected.to allow_value(nil).for(:weight) } + end + + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:issue) } + end + + describe '.by_issue' do + let_it_be(:event1) { create(:resource_weight_event, issue: issue1) } + let_it_be(:event2) { create(:resource_weight_event, issue: issue2) } + let_it_be(:event3) { create(:resource_weight_event, issue: issue1) } + + it 'returns the expected records for an issue with events' do + events = ResourceWeightEvent.by_issue(issue1) + + expect(events).to contain_exactly(event1, event3) + end + + it 'returns the expected records for an issue with no events' do + events = ResourceWeightEvent.by_issue(issue3) + + expect(events).to be_empty + end + end + + describe '.created_after' do + let!(:created_at1) { 1.day.ago } + let!(:created_at2) { 2.days.ago } + let!(:created_at3) { 3.days.ago } + + let!(:event1) { create(:resource_weight_event, issue: issue1, created_at: created_at1) } + let!(:event2) { create(:resource_weight_event, issue: issue2, created_at: created_at2) } + let!(:event3) { create(:resource_weight_event, issue: issue2, created_at: created_at3) } + + it 'returns the expected events' do + events = ResourceWeightEvent.created_after(created_at3) + + expect(events).to contain_exactly(event1, event2) + end + + it 'returns no events if time is after last record time' do + events = ResourceWeightEvent.created_after(1.minute.ago) + + expect(events).to be_empty + end + end + + describe '#discussion_id' do + let_it_be(:event) { create(:resource_weight_event, issue: issue1, created_at: Time.utc(2019, 12, 30)) } + + it 'returns the expected id' do + allow(Digest::SHA1).to receive(:hexdigest) + .with("ResourceWeightEvent-2019-12-30 00:00:00 UTC-#{user1.id}") + .and_return('73d167c478') + + expect(event.discussion_id).to eq('73d167c478') + end + end +end diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb index 017e94d04f1..0635c318942 100644 --- a/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/spec/presenters/ci/build_runner_presenter_spec.rb @@ -183,29 +183,81 @@ describe Ci::BuildRunnerPresenter do let(:pipeline) { merge_request.all_pipelines.first } let(:build) { create(:ci_build, ref: pipeline.ref, pipeline: pipeline) } - it 'returns the correct refspecs' do - is_expected - .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head') - end - - context 'when GIT_DEPTH is zero' do + context 'when depend_on_persistent_pipeline_ref feature flag is enabled' do before do - create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline) + stub_feature_flags(ci_force_exposing_merge_request_refs: false) + pipeline.persistent_ref.create end it 'returns the correct refspecs' do is_expected - .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head', - '+refs/heads/*:refs/remotes/origin/*', - '+refs/tags/*:refs/tags/*') + .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}") + end + + context 'when ci_force_exposing_merge_request_refs feature flag is enabled' do + before do + stub_feature_flags(ci_force_exposing_merge_request_refs: true) + end + + it 'returns the correct refspecs' do + is_expected + .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}", + '+refs/merge-requests/1/head:refs/merge-requests/1/head') + end + end + + context 'when GIT_DEPTH is zero' do + before do + create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline) + end + + it 'returns the correct refspecs' do + is_expected + .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}", + '+refs/heads/*:refs/remotes/origin/*', + '+refs/tags/*:refs/tags/*') + end + end + + context 'when pipeline is legacy detached merge request pipeline' do + let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) } + + it 'returns the correct refspecs' do + is_expected.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}", + "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}") + end end end - context 'when pipeline is legacy detached merge request pipeline' do - let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) } + context 'when depend_on_persistent_pipeline_ref feature flag is disabled' do + before do + stub_feature_flags(depend_on_persistent_pipeline_ref: false) + end it 'returns the correct refspecs' do - is_expected.to contain_exactly("+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}") + is_expected + .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head') + end + + context 'when GIT_DEPTH is zero' do + before do + create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline) + end + + it 'returns the correct refspecs' do + is_expected + .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/tags/*:refs/tags/*') + end + end + + context 'when pipeline is legacy detached merge request pipeline' do + let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) } + + it 'returns the correct refspecs' do + is_expected.to contain_exactly("+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}") + end end end end diff --git a/spec/rubocop/cop/migration/add_column_with_default_spec.rb b/spec/rubocop/cop/migration/add_column_with_default_spec.rb index d309a960e2f..f3518f2f058 100644 --- a/spec/rubocop/cop/migration/add_column_with_default_spec.rb +++ b/spec/rubocop/cop/migration/add_column_with_default_spec.rb @@ -16,7 +16,7 @@ describe RuboCop::Cop::Migration::AddColumnWithDefault do it 'does not register any offenses' do expect_no_offenses(<<~RUBY) def up - add_reference(:projects, :users) + add_column_with_default(:ci_build_needs, :artifacts, :boolean, default: true, allow_null: false) end RUBY end diff --git a/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb b/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb new file mode 100644 index 00000000000..41902bc1da1 --- /dev/null +++ b/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ResourceEvents::SyntheticLabelNotesBuilderService do + describe '#execute' do + let!(:user) { create(:user) } + + let!(:issue) { create(:issue, author: user) } + + let!(:event1) { create(:resource_label_event, issue: issue) } + let!(:event2) { create(:resource_label_event, issue: issue) } + let!(:event3) { create(:resource_label_event, issue: issue) } + + it 'returns the expected synthetic notes' do + notes = ResourceEvents::SyntheticLabelNotesBuilderService.new(issue, user).execute + + expect(notes.size).to eq(3) + end + end +end diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb index 350c8a29e87..c8b7a9251a9 100644 --- a/spec/support/helpers/filtered_search_helpers.rb +++ b/spec/support/helpers/filtered_search_helpers.rb @@ -26,7 +26,7 @@ module FilteredSearchHelpers # Select a label clicking in the search dropdown instead # of entering label names on the input. def select_label_on_dropdown(label_title) - input_filtered_search("label:", submit: false) + input_filtered_search("label=", submit: false) within('#js-dropdown-label') do wait_for_requests @@ -71,7 +71,7 @@ module FilteredSearchHelpers end def init_label_search - filtered_search.set('label:') + filtered_search.set('label=') # This ensures the dropdown is shown expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading') end @@ -90,6 +90,7 @@ module FilteredSearchHelpers el = token_elements[index] expect(el.find('.name')).to have_content(token[:name]) + expect(el.find('.operator')).to have_content(token[:operator]) if token[:operator].present? expect(el.find('.value')).to have_content(token[:value]) if token[:value].present? # gl-emoji content is blank when the emoji unicode is not supported @@ -101,8 +102,8 @@ module FilteredSearchHelpers end end - def create_token(token_name, token_value = nil, symbol = nil) - { name: token_name, value: "#{symbol}#{token_value}" } + def create_token(token_name, token_value = nil, symbol = nil, token_operator = '=') + { name: token_name, operator: token_operator, value: "#{symbol}#{token_value}" } end def author_token(author_name = nil) @@ -113,9 +114,9 @@ module FilteredSearchHelpers create_token('Assignee', assignee_name) end - def milestone_token(milestone_name = nil, has_symbol = true) + def milestone_token(milestone_name = nil, has_symbol = true, operator = '=') symbol = has_symbol ? '%' : nil - create_token('Milestone', milestone_name, symbol) + create_token('Milestone', milestone_name, symbol, operator) end def release_token(release_tag = nil) diff --git a/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb b/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb index 63ed37cde03..3da80541072 100644 --- a/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb +++ b/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb @@ -13,7 +13,7 @@ shared_examples 'issuable user dropdown behaviors' do it 'only includes members of the project/group' do visit issuables_path - filtered_search.set("#{dropdown}:") + filtered_search.set("#{dropdown}=") expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).to have_content(user_in_dropdown.name) expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).not_to have_content(user_not_in_dropdown.name) diff --git a/vendor/gitignore/C++.gitignore b/vendor/gitignore/C++.gitignore old mode 100755 new mode 100644 diff --git a/vendor/gitignore/Java.gitignore b/vendor/gitignore/Java.gitignore old mode 100755 new mode 100644 -- cgit v1.2.1