summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/ci/notifications.gitlab-ci.yml2
-rw-r--r--app/assets/javascripts/droplab/drop_down.js5
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js2
-rw-r--r--app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js4
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js6
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue2
-rw-r--r--app/assets/javascripts/filtered_search/constants.js6
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js41
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js65
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js38
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js20
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js47
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js161
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js7
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js17
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js161
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js152
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js3
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js1
-rw-r--r--app/assets/stylesheets/framework/filters.scss16
-rw-r--r--app/controllers/boards/issues_controller.rb2
-rw-r--r--app/finders/issuable_finder.rb2
-rw-r--r--app/models/resource_weight_event.rb26
-rw-r--r--app/presenters/ci/build_runner_presenter.rb15
-rw-r--r--app/services/boards/issues/list_service.rb4
-rw-r--r--app/services/resource_events/base_synthetic_notes_builder_service.rb39
-rw-r--r--app/services/resource_events/merge_into_notes_service.rb43
-rw-r--r--app/services/resource_events/synthetic_label_notes_builder_service.rb27
-rw-r--r--app/views/admin/runners/index.html.haml20
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml19
-rw-r--r--changelogs/unreleased/19011-add-operator-dropdown.yml5
-rw-r--r--changelogs/unreleased/sh-drop-ci-pipelines-redundant-index.yml5
-rw-r--r--changelogs/unreleased/stop-exposing-mr-refs-in-favor-of-persistent-refs.yml5
-rw-r--r--db/migrate/20191210211253_create_resource_weight_event.rb18
-rw-r--r--db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb17
-rw-r--r--db/schema.rb14
-rw-r--r--locale/gitlab.pot24
-rw-r--r--qa/qa.rb1
-rw-r--r--qa/qa/page/base.rb27
-rw-r--r--qa/qa/resource/base.rb6
-rw-r--r--qa/qa/support/wait_for_requests.rb25
-rwxr-xr-xscripts/trigger-build3
-rw-r--r--spec/factories/resource_weight_events.rb8
-rw-r--r--spec/features/admin/admin_runners_spec.rb24
-rw-r--r--spec/features/boards/boards_spec.rb2
-rw-r--r--spec/features/boards/modal_filter_spec.rb2
-rw-r--r--spec/features/dashboard/issues_filter_spec.rb6
-rw-r--r--spec/features/dashboard/issues_spec.rb2
-rw-r--r--spec/features/dashboard/merge_requests_spec.rb4
-rw-r--r--spec/features/groups/issues_spec.rb2
-rw-r--r--spec/features/groups/merge_requests_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_assignee_spec.rb6
-rw-r--r--spec/features/issues/filtered_search/dropdown_author_spec.rb6
-rw-r--r--spec/features/issues/filtered_search/dropdown_base_spec.rb8
-rw-r--r--spec/features/issues/filtered_search/dropdown_emoji_spec.rb12
-rw-r--r--spec/features/issues/filtered_search/dropdown_hint_spec.rb30
-rw-r--r--spec/features/issues/filtered_search/dropdown_label_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_milestone_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_release_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb164
-rw-r--r--spec/features/issues/filtered_search/recent_searches_spec.rb4
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb4
-rw-r--r--spec/features/issues/filtered_search/visual_tokens_spec.rb31
-rw-r--r--spec/features/labels_hierarchy_spec.rb6
-rw-r--r--spec/features/merge_requests/filters_generic_behavior_spec.rb2
-rw-r--r--spec/features/merge_requests/user_filters_by_assignees_spec.rb6
-rw-r--r--spec/features/merge_requests/user_filters_by_labels_spec.rb6
-rw-r--r--spec/features/merge_requests/user_filters_by_milestones_spec.rb10
-rw-r--r--spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb4
-rw-r--r--spec/features/merge_requests/user_filters_by_target_branch_spec.rb6
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js101
-rw-r--r--spec/frontend/filtered_search/filtered_search_token_keys_spec.js2
-rw-r--r--spec/frontend/helpers/stub_children.js3
-rw-r--r--spec/frontend/pages/admin/users/components/user_modal_manager_spec.js8
-rw-r--r--spec/javascripts/droplab/drop_down_spec.js13
-rw-r--r--spec/javascripts/filtered_search/dropdown_utils_spec.js14
-rw-r--r--spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js43
-rw-r--r--spec/javascripts/filtered_search/filtered_search_manager_spec.js16
-rw-r--r--spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js88
-rw-r--r--spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js1
-rw-r--r--spec/javascripts/filtered_search/visual_token_value_spec.js10
-rw-r--r--spec/javascripts/helpers/filtered_search_spec_helper.js17
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/models/resource_weight_event_spec.rb75
-rw-r--r--spec/presenters/ci/build_runner_presenter_spec.rb78
-rw-r--r--spec/rubocop/cop/migration/add_column_with_default_spec.rb2
-rw-r--r--spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb21
-rw-r--r--spec/support/helpers/filtered_search_helpers.rb13
-rw-r--r--spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb2
-rw-r--r--[-rwxr-xr-x]vendor/gitignore/C++.gitignore0
-rw-r--r--[-rwxr-xr-x]vendor/gitignore/Java.gitignore0
92 files changed, 1515 insertions, 461 deletions
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"
>
<span class="name">{{ token.prefix }}</span>
+ <span class="name">{{ token.operator }}</span>
<span class="value">{{ token.suffix }}</span>
</span>
</span>
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index b11111f1081..d7264e96b13 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -1,2 +1,6 @@
-/* eslint-disable import/prefer-default-export */
export const USER_TOKEN_TYPES = ['author', 'assignee'];
+
+export const DROPDOWN_TYPE = {
+ hint: 'hint',
+ operator: 'operator',
+};
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index b27bb63c220..92a64ab60db 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -45,7 +45,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown {
getSearchInput() {
const query = DropdownUtils.getSearchInput(this.input);
- const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
+ const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.getKeys());
let value = lastToken || '';
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 1a1135ae929..4f10b6ba9c3 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -3,6 +3,7 @@ import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import { __ } from '~/locale';
export default class DropdownHint extends FilteredSearchDropdown {
constructor(options = {}) {
@@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown {
this.dismissDropdown();
this.dispatchFormSubmitEvent();
} else {
- const token = selected.querySelector('.js-filter-hint').innerText.trim();
- const tag = selected.querySelector('.js-filter-tag').innerText.trim();
+ const filterItemEl = selected.closest('.filter-dropdown-item');
+ const { hint: token, tag } = filterItemEl.dataset;
if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens
@@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown {
const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
- FilteredSearchDropdownManager.addWordToInput(key, '', false, {
- uppercaseTokenName,
+
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: key,
+ clicked: false,
+ options: {
+ uppercaseTokenName,
+ },
});
}
this.dismissDropdown();
@@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown {
}
renderContent() {
- const dropdownData = this.tokenKeys.get().map(tokenKey => ({
- icon: `${gon.sprite_icons}#${tokenKey.icon}`,
- hint: tokenKey.key,
- tag: `:${tokenKey.tag}`,
- type: tokenKey.type,
- }));
+ const searchItem = [
+ {
+ hint: 'search',
+ tag: 'search',
+ formattedKey: __('Search for this text'),
+ icon: `${gon.sprite_icons}#search`,
+ },
+ ];
+
+ const dropdownData = this.tokenKeys
+ .get()
+ .map(tokenKey => ({
+ icon: `${gon.sprite_icons}#${tokenKey.icon}`,
+ hint: tokenKey.key,
+ tag: `:${tokenKey.tag}`,
+ type: tokenKey.type,
+ formattedKey: tokenKey.formattedKey,
+ }))
+ .concat(searchItem);
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
+
+ super.renderContent();
}
init() {
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
new file mode 100644
index 00000000000..bd4fda29609
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -0,0 +1,65 @@
+import Filter from '~/droplab/plugins/filter';
+import { __ } from '~/locale';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+
+export default class DropdownOperator extends FilteredSearchDropdown {
+ constructor(options = {}) {
+ const { input, tokenKeys } = options;
+ super(options);
+
+ this.config = {
+ Filter: {
+ filterFunction: DropdownUtils.filterWithSymbol.bind(null, '', input),
+ template: 'title',
+ },
+ };
+ this.tokenKeys = tokenKeys;
+ }
+
+ itemClicked(e) {
+ const { selected } = e.detail;
+
+ if (selected.tagName === 'LI') {
+ if (selected.hasAttribute('data-value')) {
+ const operator = selected.dataset.value;
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: this.filter,
+ tokenOperator: operator,
+ clicked: false,
+ });
+ }
+ }
+ this.dismissDropdown();
+ this.dispatchInputEvent();
+ }
+
+ renderContent(forceShowList = false) {
+ this.filter = FilteredSearchVisualTokens.getLastTokenPartial();
+
+ const dropdownData = [
+ {
+ tag: 'equal',
+ type: 'string',
+ title: '=',
+ help: __('Is'),
+ },
+ {
+ tag: 'not-equal',
+ type: 'string',
+ title: '!=',
+ help: __('Is not'),
+ },
+ ];
+ this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
+ this.droplab.setData(this.hookId, dropdownData);
+ super.renderContent(forceShowList);
+ }
+
+ init() {
+ this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index 8d92af2cf7e..274c08e6955 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -62,28 +62,42 @@ export default class DropdownUtils {
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
+ const isSearchItem = updatedItem.hint === 'search';
+
+ if (isSearchItem) {
+ updatedItem.droplab_hidden = true;
+ }
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
- } else if (!lastKey || _.last(searchInput.split('')) === ' ') {
+ } else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
const tokenName = _.last(split[0].split(' '));
- const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
+ const match = isSearchItem
+ ? allowedKeys.some(key => key.startsWith(tokenName.toLowerCase()))
+ : updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
+
updatedItem.droplab_hidden = tokenName ? match : false;
}
return updatedItem;
}
- static setDataValueIfSelected(filter, selected) {
+ static setDataValueIfSelected(filter, operator, selected) {
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
- FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, {
- capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: filter,
+ tokenOperator: operator,
+ tokenValue: dataValue,
+ clicked: true,
+ options: {
+ capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
+ },
});
}
@@ -101,7 +115,11 @@ export default class DropdownUtils {
// remove leading symbol and wrapping quotes
tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, '');
}
- return { tokenName, tokenValue };
+
+ const operatorEl = visualToken && visualToken.querySelector('.operator');
+ const tokenOperator = operatorEl && operatorEl.textContent.trim();
+
+ return { tokenName, tokenOperator, tokenValue };
}
// Determines the full search query (visual tokens + input)
@@ -119,10 +137,16 @@ export default class DropdownUtils {
tokens.forEach(token => {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
+ const operatorContainer = token.querySelector('.operator');
const value = token.querySelector('.value');
const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
+ let operator = '';
+
+ if (operatorContainer) {
+ operator = operatorContainer.textContent.trim();
+ }
if (valueContainer && valueContainer.dataset.originalValue) {
valueText = valueContainer.dataset.originalValue;
@@ -131,7 +155,7 @@ export default class DropdownUtils {
}
if (token.className.indexOf('filtered-search-token') !== -1) {
- values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`);
+ values.push(`${name.innerText.toLowerCase()}:${operator}${symbol}${valueText}`);
} else {
values.push(name.innerText);
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index 146d3ba963c..72565c2ca13 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,5 +1,6 @@
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
@@ -31,13 +32,26 @@ export default class FilteredSearchDropdown {
itemClicked(e, getValueFunction) {
const { selected } = e.detail;
-
if (selected.tagName === 'LI' && selected.innerHTML) {
- const dataValueSet = DropdownUtils.setDataValueIfSelected(this.filter, selected);
+ const {
+ lastVisualToken: visualToken,
+ } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ const { tokenOperator } = DropdownUtils.getVisualTokenValues(visualToken);
+
+ const dataValueSet = DropdownUtils.setDataValueIfSelected(
+ this.filter,
+ tokenOperator,
+ selected,
+ );
if (!dataValueSet) {
const value = getValueFunction(selected);
- FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: this.filter,
+ tokenOperator,
+ tokenValue: value,
+ clicked: true,
+ });
}
this.resetFilters();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 5ff95f45be4..566fb295588 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -5,6 +5,7 @@ import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import { DROPDOWN_TYPE } from './constants';
export default class FilteredSearchDropdownManager {
constructor({
@@ -67,10 +68,16 @@ export default class FilteredSearchDropdownManager {
this.mapping = availableMappings.getAllowedMappings(supportedTokens);
}
- static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
+ static addWordToInput({
+ tokenName,
+ tokenOperator = '',
+ tokenValue = '',
+ clicked = false,
+ options = {},
+ }) {
const { uppercaseTokenName = false, capitalizeTokenValue = false } = options;
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
- FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, {
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenOperator, tokenValue, {
uppercaseTokenName,
capitalizeTokenValue,
});
@@ -129,7 +136,10 @@ export default class FilteredSearchDropdownManager {
mappingKey.reference.init();
}
- if (this.currentDropdown === 'hint') {
+ if (
+ this.currentDropdown === DROPDOWN_TYPE.hint ||
+ this.currentDropdown === DROPDOWN_TYPE.operator
+ ) {
// Force the dropdown to show if it was clicked from the hint dropdown
forceShowList = true;
}
@@ -148,13 +158,19 @@ export default class FilteredSearchDropdownManager {
this.droplab = new DropLab();
}
+ if (dropdownName === DROPDOWN_TYPE.operator) {
+ this.load(dropdownName, firstLoad);
+ return;
+ }
+
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown =
match && this.currentDropdown !== match.key && this.mapping[match.key];
- const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint';
+ const shouldOpenHintDropdown = !match && this.currentDropdown !== DROPDOWN_TYPE.hint;
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
- const key = match && match.key ? match.key : 'hint';
+ const key = match && match.key ? match.key : DROPDOWN_TYPE.hint;
+
this.load(key, firstLoad);
}
}
@@ -169,19 +185,32 @@ export default class FilteredSearchDropdownManager {
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
}
-
if (lastToken === searchToken && lastToken !== null) {
// Token is not fully initialized yet because it has no value
// Eg. token = 'label:'
const split = lastToken.split(':');
const dropdownName = _.last(split[0].split(' '));
- this.loadDropdown(split.length > 1 ? dropdownName : '');
+ const possibleOperatorToken = _.last(split[1]);
+
+ const hasOperator = FilteredSearchVisualTokens.permissibleOperatorValues.includes(
+ possibleOperatorToken && possibleOperatorToken.trim(),
+ );
+
+ let dropdownToOpen = '';
+
+ if (split.length > 1) {
+ const lastOperatorToken = FilteredSearchVisualTokens.getLastTokenOperator();
+ dropdownToOpen = hasOperator && lastOperatorToken ? dropdownName : DROPDOWN_TYPE.operator;
+ }
+
+ this.loadDropdown(dropdownToOpen);
} else if (lastToken) {
+ const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator();
// Token has been initialized into an object because it has a value
- this.loadDropdown(lastToken.key);
+ this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator);
} else {
- this.loadDropdown('hint');
+ this.loadDropdown(DROPDOWN_TYPE.hint);
}
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index a4edc5fd52d..0b4f9457c54 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -14,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import DropdownUtils from './dropdown_utils';
+import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes';
import { __ } from '~/locale';
export default class FilteredSearchManager {
@@ -58,6 +59,8 @@ export default class FilteredSearchManager {
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
+ static notTransformableQueryParams = ['scope', 'utf8', 'state', 'search'];
+
setup() {
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService
@@ -84,6 +87,7 @@ export default class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = FilteredSearchTokenizer;
+
this.dropdownManager = new FilteredSearchDropdownManager({
runnerTagsEndpoint:
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
@@ -172,7 +176,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper);
- this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper);
+ this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.addEventListener('click', this.tokenChange);
@@ -194,7 +198,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper);
- this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper);
+ this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
@@ -228,7 +232,7 @@ export default class FilteredSearchManager {
if (backspaceCount === 2) {
backspaceCount = 0;
- this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial();
+ this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(true);
FilteredSearchVisualTokens.removeLastTokenPartial();
}
}
@@ -407,7 +411,12 @@ export default class FilteredSearchManager {
}
}
- handleInputVisualToken() {
+ handleInputVisualToken(e) {
+ // If the keyCode was 8 then do not form new tokens
+ if (e.keyCode === BACKSPACE_KEY_CODE) {
+ return;
+ }
+
const input = this.filteredSearchInput;
const { tokens, searchToken } = this.tokenizer.processTokens(
input.value,
@@ -417,14 +426,21 @@ export default class FilteredSearchManager {
if (isLastVisualTokenValid) {
tokens.forEach(t => {
- input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
- FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, {
- uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
- capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
- });
+ input.value = input.value.replace(`${t.key}:${t.operator}${t.symbol}${t.value}`, '');
+
+ FilteredSearchVisualTokens.addFilterVisualToken(
+ t.key,
+ t.operator,
+ `${t.symbol}${t.value}`,
+ {
+ uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
+ capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
+ },
+ );
});
const fragments = searchToken.split(':');
+
if (fragments.length > 1) {
const inputValues = fragments[0].split(' ');
const tokenKey = _.last(inputValues);
@@ -437,19 +453,58 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
}
- FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, {
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, null, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
input.value = input.value.replace(`${tokenKey}:`, '');
}
+
+ const splitSearchToken = searchToken && searchToken.split(' ');
+ let lastSearchToken = _.last(splitSearchToken);
+ lastSearchToken = lastSearchToken?.toLowerCase();
+
+ /**
+ * If user writes "milestone", a known token, in the input, we should not
+ * wait for leading colon to flush it as a filter token.
+ */
+ if (this.filteredSearchTokenKeys.getKeys().includes(lastSearchToken)) {
+ if (splitSearchToken.length > 1) {
+ splitSearchToken.pop();
+ const searchVisualTokens = splitSearchToken.join(' ');
+
+ input.value = input.value.replace(searchVisualTokens, '');
+ FilteredSearchVisualTokens.addSearchVisualToken(searchVisualTokens);
+ }
+ FilteredSearchVisualTokens.addFilterVisualToken(lastSearchToken, null, null, {
+ uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(
+ lastSearchToken,
+ ),
+ capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(
+ lastSearchToken,
+ ),
+ });
+ input.value = input.value.replace(lastSearchToken, '');
+ }
+ } else if (!isLastVisualTokenValid && !FilteredSearchVisualTokens.getLastTokenOperator()) {
+ const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
+ const tokenOperator = searchToken && searchToken.trim();
+
+ // Tokenize operator only if the operator token is valid
+ if (FilteredSearchVisualTokens.permissibleOperatorValues.includes(tokenOperator)) {
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, tokenOperator, null, {
+ capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
+ });
+ input.value = input.value.replace(searchToken, '').trim();
+ }
} else {
// Keep listening to token until we determine that the user is done typing the token value
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
- FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, {
+ FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, null, {
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
@@ -484,9 +539,52 @@ export default class FilteredSearchManager {
return this.modifyUrlParams ? this.modifyUrlParams(urlParams) : urlParams;
}
+ transformParams(params) {
+ /**
+ * Extract key, value pair from the `not` query param:
+ * Query param looks like not[key]=value
+ *
+ * Eg. not[foo]=%bar
+ * key = foo; value = %bar
+ */
+ const notKeyValueRegex = new RegExp(/not\[(\w+)\]\[?\]?=(.*)/);
+
+ return params.map(query => {
+ // Check if there are matches for `not` operator
+ const matches = query.match(notKeyValueRegex);
+ if (matches && matches.length === 3) {
+ const keyParam = matches[1];
+ if (
+ FilteredSearchManager.notTransformableQueryParams.includes(keyParam) ||
+ this.filteredSearchTokenKeys.searchByConditionUrl(query)
+ ) {
+ return query;
+ }
+
+ const valueParam = matches[2];
+ // Not operator
+ const operator = encodeURIComponent('!=');
+ return `${keyParam}=${operator}${valueParam}`;
+ }
+
+ const [keyParam, valueParam] = query.split('=');
+
+ if (
+ FilteredSearchManager.notTransformableQueryParams.includes(keyParam) ||
+ this.filteredSearchTokenKeys.searchByConditionUrl(query)
+ ) {
+ return query;
+ }
+
+ const operator = encodeURIComponent('=');
+ return `${keyParam}=${operator}${valueParam}`;
+ });
+ }
+
loadSearchParamsFromURL() {
const urlParams = getUrlParamsArray();
- const params = this.getAllParams(urlParams);
+ const withOperatorParams = this.transformParams(urlParams);
+ const params = this.getAllParams(withOperatorParams);
const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false;
@@ -501,9 +599,14 @@ export default class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
- FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value, {
- canEdit,
- });
+ FilteredSearchVisualTokens.addFilterVisualToken(
+ condition.tokenKey,
+ condition.operator,
+ condition.value,
+ {
+ canEdit,
+ },
+ );
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
@@ -522,9 +625,12 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(key, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
+ const operator = FilteredSearchVisualTokens.getOperatorToken(sanitizedValue);
+ const sanitizedToken = FilteredSearchVisualTokens.getValueToken(sanitizedValue);
FilteredSearchVisualTokens.addFilterVisualToken(
key,
- `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
+ operator,
+ `${symbol}${quotationsToUse}${sanitizedToken}${quotationsToUse}`,
{
canEdit,
uppercaseTokenName,
@@ -537,7 +643,10 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'assignee';
const canEdit = this.canEdit && this.canEdit(tokenName);
- FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, {
+ const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]);
+ const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]);
+
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, {
canEdit,
});
}
@@ -547,7 +656,10 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName);
- FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, {
+ const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]);
+ const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]);
+
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, {
canEdit,
});
}
@@ -582,7 +694,6 @@ export default class FilteredSearchManager {
search(state = null) {
const paths = [];
const searchQuery = DropdownUtils.getSearchQuery();
-
this.saveCurrentSearchQuery();
const tokenKeys = this.filteredSearchTokenKeys.getKeys();
@@ -593,6 +704,7 @@ export default class FilteredSearchManager {
tokens.forEach(token => {
const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue(
token.key,
+ token.operator,
token.value,
);
const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
@@ -620,7 +732,16 @@ export default class FilteredSearchManager {
tokenValue = tokenValue.slice(1, tokenValue.length - 1);
}
- tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
+ if (token.operator === '!=') {
+ const isArrayParam = keyParam.endsWith('[]');
+
+ tokenPath = `not[${isArrayParam ? keyParam.slice(0, -2) : keyParam}]${
+ isArrayParam ? '[]' : ''
+ }=${encodeURIComponent(tokenValue)}`;
+ } else {
+ // Default operator is `=`
+ tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
+ }
}
paths.push(tokenPath);
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 0a9579bf491..89fc8047b65 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -65,17 +65,20 @@ export default class FilteredSearchTokenKeys {
return this.conditions.find(condition => condition.url === url) || null;
}
- searchByConditionKeyValue(key, value) {
+ searchByConditionKeyValue(key, operator, value) {
return (
this.conditions.find(
condition =>
- condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(),
+ condition.tokenKey === key &&
+ condition.operator === operator &&
+ condition.value.toLowerCase() === value.toLowerCase(),
) || null
);
}
addExtraTokensForIssues() {
const confidentialToken = {
+ formattedKey: __('Confidential'),
key: 'confidential',
type: 'string',
param: '',
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index b5c4cb15aac..963e8fe5df5 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -2,10 +2,11 @@ import './filtered_search_token_keys';
export default class FilteredSearchTokenizer {
static processTokens(input, allowedKeys) {
- // Regex extracts `(token):(symbol)(value)`
+ // Regex extracts `(token):(operator)(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single)
+
const tokenRegex = new RegExp(
- `(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
+ `(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
'g',
);
const tokens = [];
@@ -13,16 +14,22 @@ export default class FilteredSearchTokenizer {
let lastToken = null;
const searchToken =
input
- .replace(tokenRegex, (match, key, symbol, v1, v2, v3) => {
+ .replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => {
let tokenValue = v1 || v2 || v3;
let tokenSymbol = symbol;
let tokenIndex = '';
+ let tokenOperator = operator;
if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
tokenSymbol = tokenValue;
tokenValue = '';
}
+ if (tokenValue === '!=' || tokenValue === '=') {
+ tokenOperator = tokenValue;
+ tokenValue = '';
+ }
+
tokenIndex = `${key}:${tokenValue}`;
// Prevent adding duplicates
@@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer {
key,
value: tokenValue || '',
symbol: tokenSymbol || '',
+ operator: tokenOperator || '',
});
}
@@ -43,13 +51,12 @@ export default class FilteredSearchTokenizer {
if (tokens.length > 0) {
const last = tokens[tokens.length - 1];
- const lastString = `${last.key}:${last.symbol}${last.value}`;
+ const lastString = `${last.key}:${last.operator}${last.symbol}${last.value}`;
lastToken =
input.lastIndexOf(lastString) === input.length - lastString.length ? last : searchToken;
} else {
lastToken = searchToken;
}
-
return {
tokens,
lastToken,
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 7f6457242ef..d41d5a543b0 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -3,6 +3,32 @@ import { objectToQueryString } from '~/lib/utils/common_utils';
import FilteredSearchContainer from './container';
export default class FilteredSearchVisualTokens {
+ static permissibleOperatorValues = ['=', '!='];
+
+ static getOperatorToken(value) {
+ let token = null;
+
+ FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => {
+ if (value.startsWith(operatorToken)) {
+ token = operatorToken;
+ }
+ });
+
+ return token;
+ }
+
+ static getValueToken(value) {
+ let newValue = value;
+
+ FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => {
+ if (value.startsWith(operatorToken)) {
+ newValue = value.slice(operatorToken.length);
+ }
+ });
+
+ return newValue;
+ }
+
static getLastVisualTokenBeforeInput() {
const inputLi = FilteredSearchContainer.container.querySelector('.input-token');
const lastVisualToken = inputLi && inputLi.previousElementSibling;
@@ -12,7 +38,9 @@ export default class FilteredSearchVisualTokens {
isLastVisualTokenValid:
lastVisualToken === null ||
lastVisualToken.className.indexOf('filtered-search-term') !== -1 ||
- (lastVisualToken && lastVisualToken.querySelector('.value') !== null),
+ (lastVisualToken &&
+ lastVisualToken.querySelector('.operator') !== null &&
+ lastVisualToken.querySelector('.value') !== null),
};
}
@@ -42,11 +70,17 @@ export default class FilteredSearchVisualTokens {
}
static createVisualTokenElementHTML(options = {}) {
- const { canEdit = true, uppercaseTokenName = false, capitalizeTokenValue = false } = options;
+ const {
+ canEdit = true,
+ hasOperator = false,
+ uppercaseTokenName = false,
+ capitalizeTokenValue = false,
+ } = options;
return `
<div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>
+ ${hasOperator ? '<div class="operator"></div>' : ''}
<div class="value-container">
<div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div>
<div class="remove-token" role="button">
@@ -57,18 +91,18 @@ export default class FilteredSearchVisualTokens {
`;
}
- static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
+ static renderVisualTokenValue(parentElement, tokenName, tokenValue, tokenOperator) {
const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue;
- const visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
+ const visualTokenValue = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
visualTokenValue.render(tokenValueContainer, tokenValueElement);
}
- static addVisualTokenElement(name, value, options = {}) {
+ static addVisualTokenElement({ name, operator, value, options = {} }) {
const {
isSearchTerm = false,
canEdit,
@@ -84,17 +118,32 @@ export default class FilteredSearchVisualTokens {
li.classList.add(tokenClass);
}
+ const hasOperator = Boolean(operator);
+
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
+ operator,
+ hasOperator,
capitalizeTokenValue,
});
- FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
+ FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value, operator);
} else {
- li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
+ const nameHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
+ let operatorHTML = '';
+
+ if (hasOperator) {
+ operatorHTML = '<div class="operator"></div>';
+ }
+
+ li.innerHTML = nameHTML + operatorHTML;
}
+
li.querySelector('.name').innerText = name;
+ if (hasOperator) {
+ li.querySelector('.operator').innerText = operator;
+ }
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
@@ -109,14 +158,19 @@ export default class FilteredSearchVisualTokens {
if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
- lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
+ const operator = FilteredSearchVisualTokens.getLastTokenOperator();
+ lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
+ hasOperator: Boolean(operator),
+ });
lastVisualToken.querySelector('.name').innerText = name;
- FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value);
+ lastVisualToken.querySelector('.operator').innerText = operator;
+ FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value, operator);
}
}
static addFilterVisualToken(
tokenName,
+ tokenOperator,
tokenValue,
{ canEdit, uppercaseTokenName = false, capitalizeTokenValue = false } = {},
) {
@@ -127,21 +181,51 @@ export default class FilteredSearchVisualTokens {
const { addVisualTokenElement } = FilteredSearchVisualTokens;
if (isLastVisualTokenValid) {
- addVisualTokenElement(tokenName, tokenValue, {
- canEdit,
- uppercaseTokenName,
- capitalizeTokenValue,
+ addVisualTokenElement({
+ name: tokenName,
+ operator: tokenOperator,
+ value: tokenValue,
+ options: {
+ canEdit,
+ uppercaseTokenName,
+ capitalizeTokenValue,
+ },
+ });
+ } else if (
+ !isLastVisualTokenValid &&
+ (lastVisualToken && !lastVisualToken.querySelector('.operator'))
+ ) {
+ const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
+ tokensContainer.removeChild(lastVisualToken);
+ addVisualTokenElement({
+ name: tokenName,
+ operator: tokenOperator,
+ value: tokenValue,
+ options: {
+ canEdit,
+ uppercaseTokenName,
+ capitalizeTokenValue,
+ },
});
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
+ const previousTokenOperator = lastVisualToken.querySelector('.operator').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
- const value = tokenValue || tokenName;
- addVisualTokenElement(previousTokenName, value, {
- canEdit,
- uppercaseTokenName,
- capitalizeTokenValue,
+ let value = tokenValue;
+ if (!value && !tokenOperator) {
+ value = tokenName;
+ }
+ addVisualTokenElement({
+ name: previousTokenName,
+ operator: previousTokenOperator,
+ value,
+ options: {
+ canEdit,
+ uppercaseTokenName,
+ capitalizeTokenValue,
+ },
});
}
}
@@ -152,13 +236,18 @@ export default class FilteredSearchVisualTokens {
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
} else {
- FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, {
- isSearchTerm: true,
+ FilteredSearchVisualTokens.addVisualTokenElement({
+ name: searchTerm,
+ operator: null,
+ value: null,
+ options: {
+ isSearchTerm: true,
+ },
});
}
}
- static getLastTokenPartial() {
+ static getLastTokenPartial(includeOperator = false) {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!lastVisualToken) return '';
@@ -175,20 +264,36 @@ export default class FilteredSearchVisualTokens {
const valueText = value ? value.innerText : '';
const nameText = name ? name.innerText : '';
+ if (includeOperator) {
+ const operator = lastVisualToken.querySelector('.operator');
+ const operatorText = operator ? operator.innerText : '';
+ return valueText || operatorText || nameText;
+ }
+
return valueText || nameText;
}
+ static getLastTokenOperator() {
+ const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+
+ const operator = lastVisualToken && lastVisualToken.querySelector('.operator');
+
+ return operator?.innerText;
+ }
+
static removeLastTokenPartial() {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (lastVisualToken) {
const value = lastVisualToken.querySelector('.value');
-
+ const operator = lastVisualToken.querySelector('.operator');
if (value) {
const button = lastVisualToken.querySelector('.selectable');
const valueContainer = lastVisualToken.querySelector('.value-container');
button.removeChild(valueContainer);
lastVisualToken.innerHTML = button.innerHTML;
+ } else if (operator) {
+ lastVisualToken.removeChild(operator);
} else {
lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken);
}
@@ -236,12 +341,18 @@ export default class FilteredSearchVisualTokens {
tokenContainer.replaceChild(inputLi, token);
const nameElement = token.querySelector('.name');
+ const operatorElement = token.querySelector('.operator');
let value;
if (token.classList.contains('filtered-search-token')) {
- FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, {
- uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
- });
+ FilteredSearchVisualTokens.addFilterVisualToken(
+ nameElement.innerText,
+ operatorElement.innerText,
+ null,
+ {
+ uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
+ },
+ );
const valueContainerElement = token.querySelector('.value-container');
value = valueContainerElement.dataset.originalValue;
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index eb518eb1f52..8722fc64b62 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -1,8 +1,10 @@
+import { flatten } from 'underscore';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale';
export const tokenKeys = [
{
+ formattedKey: __('Author'),
key: 'author',
type: 'string',
param: 'username',
@@ -11,6 +13,7 @@ export const tokenKeys = [
tag: '@author',
},
{
+ formattedKey: __('Assignee'),
key: 'assignee',
type: 'string',
param: 'username',
@@ -19,6 +22,7 @@ export const tokenKeys = [
tag: '@assignee',
},
{
+ formattedKey: __('Milestone'),
key: 'milestone',
type: 'string',
param: 'title',
@@ -27,6 +31,7 @@ export const tokenKeys = [
tag: '%milestone',
},
{
+ formattedKey: __('Release'),
key: 'release',
type: 'string',
param: 'tag',
@@ -35,6 +40,7 @@ export const tokenKeys = [
tag: __('tag name'),
},
{
+ formattedKey: __('Label'),
key: 'label',
type: 'array',
param: 'name[]',
@@ -47,6 +53,7 @@ export const tokenKeys = [
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
+ formattedKey: __('My-Reaction'),
key: 'my-reaction',
type: 'string',
param: 'emoji',
@@ -58,6 +65,7 @@ if (gon.current_user_id) {
export const alternativeTokenKeys = [
{
+ formattedKey: __('Label'),
key: 'label',
type: 'string',
param: 'name',
@@ -65,68 +73,88 @@ export const alternativeTokenKeys = [
},
];
-export const conditions = [
- {
- url: 'assignee_id=None',
- tokenKey: 'assignee',
- value: __('None'),
- },
- {
- url: 'assignee_id=Any',
- tokenKey: 'assignee',
- value: __('Any'),
- },
- {
- url: 'milestone_title=None',
- tokenKey: 'milestone',
- value: __('None'),
- },
- {
- url: 'milestone_title=Any',
- tokenKey: 'milestone',
- value: __('Any'),
- },
- {
- url: 'milestone_title=%23upcoming',
- tokenKey: 'milestone',
- value: __('Upcoming'),
- },
- {
- url: 'milestone_title=%23started',
- tokenKey: 'milestone',
- value: __('Started'),
- },
- {
- url: 'release_tag=None',
- tokenKey: 'release',
- value: __('None'),
- },
- {
- url: 'release_tag=Any',
- tokenKey: 'release',
- value: __('Any'),
- },
- {
- url: 'label_name[]=None',
- tokenKey: 'label',
- value: __('None'),
- },
- {
- url: 'label_name[]=Any',
- tokenKey: 'label',
- value: __('Any'),
- },
- {
- url: 'my_reaction_emoji=None',
- tokenKey: 'my-reaction',
- value: __('None'),
- },
- {
- url: 'my_reaction_emoji=Any',
- tokenKey: 'my-reaction',
- value: __('Any'),
- },
-];
+export const conditions = flatten(
+ [
+ {
+ url: 'assignee_id=None',
+ tokenKey: 'assignee',
+ value: __('None'),
+ },
+ {
+ url: 'assignee_id=Any',
+ tokenKey: 'assignee',
+ value: __('Any'),
+ },
+ {
+ url: 'milestone_title=None',
+ tokenKey: 'milestone',
+ value: __('None'),
+ },
+ {
+ url: 'milestone_title=Any',
+ tokenKey: 'milestone',
+ value: __('Any'),
+ },
+ {
+ url: 'milestone_title=%23upcoming',
+ tokenKey: 'milestone',
+ value: __('Upcoming'),
+ },
+ {
+ url: 'milestone_title=%23started',
+ tokenKey: 'milestone',
+ value: __('Started'),
+ },
+ {
+ url: 'release_tag=None',
+ tokenKey: 'release',
+ value: __('None'),
+ },
+ {
+ url: 'release_tag=Any',
+ tokenKey: 'release',
+ value: __('Any'),
+ },
+ {
+ url: 'label_name[]=None',
+ tokenKey: 'label',
+ value: __('None'),
+ },
+ {
+ url: 'label_name[]=Any',
+ tokenKey: 'label',
+ value: __('Any'),
+ },
+ {
+ url: 'my_reaction_emoji=None',
+ tokenKey: 'my-reaction',
+ value: __('None'),
+ },
+ {
+ url: 'my_reaction_emoji=Any',
+ tokenKey: 'my-reaction',
+ value: __('Any'),
+ },
+ ].map(condition => {
+ const [keyPart, valuePart] = condition.url.split('=');
+ const hasBrackets = keyPart.includes('[]');
+
+ const notEqualUrl = `not[${hasBrackets ? keyPart.slice(0, -2) : keyPart}]${
+ hasBrackets ? '[]' : ''
+ }=${valuePart}`;
+ return [
+ {
+ ...condition,
+ operator: '=',
+ },
+ {
+ ...condition,
+ operator: '!=',
+ url: notEqualUrl,
+ },
+ ];
+ }),
+);
const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys(
tokenKeys,
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 1343ccd6468..9f3cf881af4 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -9,9 +9,10 @@ import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
export default class VisualTokenValue {
- constructor(tokenValue, tokenType) {
+ constructor(tokenValue, tokenType, tokenOperator) {
this.tokenValue = tokenValue;
this.tokenType = tokenType;
+ this.tokenOperator = tokenOperator;
}
render(tokenValueContainer, tokenValueElement) {
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
index 5e0f9b612a2..2270d329c24 100644
--- a/app/assets/javascripts/lib/utils/keycodes.js
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -2,3 +2,4 @@ export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
+export const BACKSPACE_KEY_CODE = 8;
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 1c252584047..b5d1c3f6732 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -88,6 +88,7 @@
}
.name,
+ .operator,
.value {
display: inline-block;
padding: 2px 7px;
@@ -101,6 +102,12 @@
text-transform: capitalize;
}
+ .operator {
+ background-color: $white-normal;
+ color: $filter-value-text-color;
+ margin-right: 1px;
+ }
+
.value-container {
display: flex;
align-items: center;
@@ -147,6 +154,10 @@
background-color: $filter-name-selected-color;
}
+ .operator {
+ box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
+ }
+
.value-container {
box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
}
@@ -260,6 +271,11 @@
max-width: none;
min-width: 100%;
}
+
+ .btn-helptext {
+ margin-left: auto;
+ color: var(--gray);
+ }
}
.filtered-search-history-dropdown-wrapper {
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 1298b33471b..99c48186fba 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -90,7 +90,7 @@ module Boards
end
def filter_params
- params.merge(board_id: params[:board_id], id: params[:list_id])
+ params.permit(*Boards::Issues::ListService.valid_params).merge(board_id: params[:board_id], id: params[:list_id])
.reject { |_, value| value.nil? }
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index e3ea81d5564..194d7da1cab 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -87,7 +87,7 @@ class IssuableFinder
end
def valid_params
- @valid_params ||= scalar_params + [array_params] + [{ not: [] }]
+ @valid_params ||= scalar_params + [array_params.merge(not: {})]
end
end
diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb
new file mode 100644
index 00000000000..ab288798aed
--- /dev/null
+++ b/app/models/resource_weight_event.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class ResourceWeightEvent < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
+
+ validates :user, presence: true
+ validates :issue, presence: true
+
+ belongs_to :user
+ belongs_to :issue
+
+ scope :by_issue, ->(issue) { where(issue_id: issue.id) }
+ scope :created_after, ->(time) { where('created_at > ?', time) }
+
+ def discussion_id(resource = nil)
+ strong_memoize(:discussion_id) do
+ Digest::SHA1.hexdigest(discussion_id_key.join("-"))
+ end
+ end
+
+ private
+
+ def discussion_id_key
+ [self.class.name, created_at, user_id]
+ end
+end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 8e469795581..33b7899f912 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -34,7 +34,7 @@ module Ci
def refspecs
specs = []
- specs << refspec_for_pipeline_ref if merge_request_ref?
+ specs << refspec_for_pipeline_ref if should_expose_merge_request_ref?
specs << refspec_for_persistent_ref if persistent_ref_exist?
if git_depth > 0
@@ -50,6 +50,19 @@ module Ci
private
+ # We will stop exposing merge request refs when we fully depend on persistent refs
+ # (i.e. remove `refspec_for_pipeline_ref` when we remove `depend_on_persistent_pipeline_ref` feature flag.)
+ # `ci_force_exposing_merge_request_refs` is an extra feature flag that allows us to
+ # forcibly expose MR refs even if the `depend_on_persistent_pipeline_ref` feature flag enabled.
+ # This is useful when we see an unexpected behaviors/reports from users.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/35140.
+ def should_expose_merge_request_ref?
+ return false unless merge_request_ref?
+ return true if Feature.enabled?(:ci_force_exposing_merge_request_refs, project)
+
+ Feature.disabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true)
+ end
+
def create_archive(artifacts)
return unless artifacts[:untracked] || artifacts[:paths]
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 37a74cd1b00..a9240e1d8a0 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -5,6 +5,10 @@ module Boards
class ListService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
+ def self.valid_params
+ IssuesFinder.valid_params
+ end
+
def execute
fetch_issues.order_by_position_and_priority
end
diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb
new file mode 100644
index 00000000000..1b85ca811a1
--- /dev/null
+++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# We store events about issuable label changes and weight changes in a separate
+# table (not as other system notes), but we still want to display notes about
+# label changes and weight changes as classic system notes in UI. This service
+# generates "synthetic" notes for label event changes.
+
+module ResourceEvents
+ class BaseSyntheticNotesBuilderService
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :resource, :current_user, :params
+
+ def initialize(resource, current_user, params = {})
+ @resource = resource
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ synthetic_notes
+ end
+
+ private
+
+ def since_fetch_at(events)
+ return events unless params[:last_fetched_at].present?
+
+ last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i)
+ events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
+ end
+
+ def resource_parent
+ strong_memoize(:resource_parent) do
+ resource.project || resource.group
+ end
+ end
+ end
+end
diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb
index 7504773a002..47948fcff6e 100644
--- a/app/services/resource_events/merge_into_notes_service.rb
+++ b/app/services/resource_events/merge_into_notes_service.rb
@@ -1,10 +1,9 @@
# frozen_string_literal: true
-# We store events about issuable label changes in a separate table (not as
-# other system notes), but we still want to display notes about label changes
-# as classic system notes in UI. This service generates "synthetic" notes for
-# label event changes and merges them with classic notes and sorts them by
-# creation time.
+# We store events about issuable label changes and weight changes in separate tables (not as
+# other system notes), but we still want to display notes about label and weight changes
+# as classic system notes in UI. This service merges synthetic label and weight notes
+# with classic notes and sorts them by creation time.
module ResourceEvents
class MergeIntoNotesService
@@ -19,39 +18,15 @@ module ResourceEvents
end
def execute(notes = [])
- (notes + label_notes).sort_by { |n| n.created_at }
+ (notes + synthetic_notes).sort_by { |n| n.created_at }
end
private
- def label_notes
- label_events_by_discussion_id.map do |discussion_id, events|
- LabelNote.from_events(events, resource: resource, resource_parent: resource_parent)
- end
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def label_events_by_discussion_id
- return [] unless resource.respond_to?(:resource_label_events)
-
- events = resource.resource_label_events.includes(:label, user: :status)
- events = since_fetch_at(events)
-
- events.group_by { |event| event.discussion_id }
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def since_fetch_at(events)
- return events unless params[:last_fetched_at].present?
-
- last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i)
- events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
- end
-
- def resource_parent
- strong_memoize(:resource_parent) do
- resource.project || resource.group
- end
+ def synthetic_notes
+ SyntheticLabelNotesBuilderService.new(resource, current_user, params).execute
end
end
end
+
+ResourceEvents::MergeIntoNotesService.prepend_if_ee('EE::ResourceEvents::MergeIntoNotesService')
diff --git a/app/services/resource_events/synthetic_label_notes_builder_service.rb b/app/services/resource_events/synthetic_label_notes_builder_service.rb
new file mode 100644
index 00000000000..fd128101b49
--- /dev/null
+++ b/app/services/resource_events/synthetic_label_notes_builder_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# We store events about issuable label changes in a separate table (not as
+# other system notes), but we still want to display notes about label changes
+# as classic system notes in UI. This service generates "synthetic" notes for
+# label event changes.
+
+module ResourceEvents
+ class SyntheticLabelNotesBuilderService < BaseSyntheticNotesBuilderService
+ private
+
+ def synthetic_notes
+ label_events_by_discussion_id.map do |discussion_id, events|
+ LabelNote.from_events(events, resource: resource, resource_parent: resource_parent)
+ end
+ end
+
+ def label_events_by_discussion_id
+ return [] unless resource.respond_to?(:resource_label_events)
+
+ events = resource.resource_label_events.includes(:label, user: :status) # rubocop: disable CodeReuse/ActiveRecord
+ events = since_fetch_at(events)
+
+ events.group_by { |event| event.discussion_id }
+ end
+ end
+end
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index f8ef7a45f7f..818d265c767 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -57,24 +57,22 @@
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { action: 'submit' } }
- = button_tag class: %w[btn btn-link] do
- = sprite_icon('search')
- %span
- = _('Press Enter or click to search')
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
+ %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[btn btn-link] do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
- {{hint}}
- %span.js-filter-tag.dropdown-light-content
- {{tag}}
-
+ {{formattedKey}}
+ #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
+ %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
+ %button.btn.btn-link{ type: 'button' }
+ {{ title }}
+ %span.btn-helptext
+ {{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 5da86195243..50530498f52 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -30,23 +30,22 @@
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options(type) }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { action: 'submit' } }
- %button.btn.btn-link{ type: 'button' }
- = sprite_icon('search')
- %span
- = _('Press Enter or click to search')
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
+ %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
%button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
- {{hint}}
- %span.js-filter-tag.dropdown-light-content
- {{tag}}
+ {{formattedKey}}
+ #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
+ %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
+ %button.btn.btn-link{ type: 'button' }
+ {{ title }}
+ %span.btn-helptext
+ {{ help }}
#js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
- if current_user
%ul{ data: { dropdown: true } }
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 = `
<div class="selectable ${isSelected ? 'selected' : ''}" role="button">
<div class="name">${name}</div>
+ <div class="operator">${operator}</div>
<div class="value-container">
<div class="value">${value}</div>
<div class="remove-token" role="button">
@@ -30,6 +32,15 @@ export default class FilteredSearchSpecHelper {
`;
}
+ static createNameOperatorFilterVisualTokenHTML(name, operator) {
+ return `
+ <li class="js-visual-token filtered-search-token">
+ <div class="name">${name}</div>
+ <div class="operator">${operator}</div>
+ </li>
+ `;
+ }
+
static createSearchVisualToken(name) {
const li = document.createElement('li');
li.classList.add('js-visual-token', 'filtered-search-term');
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
index 259148fa18f..259148fa18f 100755..100644
--- a/vendor/gitignore/C++.gitignore
+++ b/vendor/gitignore/C++.gitignore
diff --git a/vendor/gitignore/Java.gitignore b/vendor/gitignore/Java.gitignore
index a1c2a238a96..a1c2a238a96 100755..100644
--- a/vendor/gitignore/Java.gitignore
+++ b/vendor/gitignore/Java.gitignore