summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Schatz <jschatz@gitlab.com>2017-01-12 14:57:57 +0000
committerJacob Schatz <jschatz@gitlab.com>2017-01-12 14:57:57 +0000
commit3c9a3918e6a93a3e3cdcbe63214809d2ba9eb959 (patch)
tree8cffc46c996b4baf6a751da6a658beb24509d5f5
parent50fff719fc148ac9f6f6a309770c6136a3aeb31b (diff)
parent3e457f78cf6798042a1fc30790c24f935861182f (diff)
downloadgitlab-ce-3c9a3918e6a93a3e3cdcbe63214809d2ba9eb959.tar.gz
Merge branch 'search-bar-first-iteration' into 'master'
Search bar first iteration Closes #21747 See merge request !7345
-rw-r--r--app/assets/javascripts/application.js1
-rw-r--r--app/assets/javascripts/dispatcher.js.es63
-rw-r--r--app/assets/javascripts/droplab/droplab.js701
-rw-r--r--app/assets/javascripts/droplab/droplab_ajax.js79
-rw-r--r--app/assets/javascripts/droplab/droplab_ajax_filter.js145
-rw-r--r--app/assets/javascripts/droplab/droplab_filter.js60
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js.es666
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js.es644
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js.es653
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js.es679
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_bundle.js7
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6102
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6193
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js.es6171
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es683
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es645
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js.es66
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js15
-rw-r--r--app/assets/javascripts/search_autocomplete.js.es67
-rw-r--r--app/assets/stylesheets/framework/filters.scss115
-rw-r--r--app/assets/stylesheets/framework/mobile.scss6
-rw-r--r--app/assets/stylesheets/framework/variables.scss5
-rw-r--r--app/finders/issuable_finder.rb54
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/views/projects/issues/index.html.haml6
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml127
-rw-r--r--config/application.rb1
-rw-r--r--features/project/issues/filter_labels.feature28
-rw-r--r--features/project/issues/issues.feature56
-rw-r--r--lib/gitlab/gon_helper.rb1
-rw-r--r--spec/features/issues/filter_by_milestone_spec.rb91
-rw-r--r--spec/features/issues/filtered_search/dropdown_assignee_spec.rb166
-rw-r--r--spec/features/issues/filtered_search/dropdown_author_spec.rb154
-rw-r--r--spec/features/issues/filtered_search/dropdown_hint_spec.rb134
-rw-r--r--spec/features/issues/filtered_search/dropdown_label_spec.rb242
-rw-r--r--spec/features/issues/filtered_search/dropdown_milestone_spec.rb222
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb759
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb88
-rw-r--r--spec/features/issues/reset_filters_spec.rb89
-rw-r--r--spec/features/merge_requests/filter_by_labels_spec.rb (renamed from spec/features/issues/filter_by_labels_spec.rb)26
-rw-r--r--spec/features/merge_requests/filter_merge_requests_spec.rb (renamed from spec/features/issues/filter_issues_spec.rb)143
-rw-r--r--spec/features/merge_requests/reset_filters_spec.rb96
-rw-r--r--spec/features/search_spec.rb8
-rw-r--r--spec/javascripts/filtered_search/dropdown_utils_spec.js.es6107
-rw-r--r--spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es659
-rw-r--r--spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6104
-rw-r--r--spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6104
-rw-r--r--spec/javascripts/lib/utils/common_utils_spec.js.es624
-rw-r--r--spec/javascripts/lib/utils/text_utility_spec.js.es625
-rw-r--r--spec/javascripts/search_autocomplete_spec.js6
50 files changed, 4522 insertions, 386 deletions
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index e43afbb4cc9..f0615481ed2 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -58,6 +58,7 @@
/*= require_directory ./extensions */
/*= require_directory ./lib/utils */
/*= require_directory ./u2f */
+/*= require_directory ./droplab */
/*= require_directory . */
/*= require fuzzaldrin-plus */
/*= require es6-promise.auto */
diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6
index 54f13e328bd..99a34651639 100644
--- a/app/assets/javascripts/dispatcher.js.es6
+++ b/app/assets/javascripts/dispatcher.js.es6
@@ -84,6 +84,9 @@
break;
case 'projects:merge_requests:index':
case 'projects:issues:index':
+ if (gl.FilteredSearchManager) {
+ new gl.FilteredSearchManager();
+ }
Issuable.init();
new gl.IssuableBulkActions({
prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_',
diff --git a/app/assets/javascripts/droplab/droplab.js b/app/assets/javascripts/droplab/droplab.js
new file mode 100644
index 00000000000..ed545ec8748
--- /dev/null
+++ b/app/assets/javascripts/droplab/droplab.js
@@ -0,0 +1,701 @@
+/* eslint-disable */
+// Determine where to place this
+if (typeof Object.assign != 'function') {
+ Object.assign = function (target, varArgs) { // .length of function is 2
+ 'use strict';
+ if (target == null) { // TypeError if undefined or null
+ throw new TypeError('Cannot convert undefined or null to object');
+ }
+
+ var to = Object(target);
+
+ for (var index = 1; index < arguments.length; index++) {
+ var nextSource = arguments[index];
+
+ if (nextSource != null) { // Skip over if undefined or null
+ for (var nextKey in nextSource) {
+ // Avoid bugs when hasOwnProperty is shadowed
+ if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
+ to[nextKey] = nextSource[nextKey];
+ }
+ }
+ }
+ }
+ return to;
+ };
+}
+
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.droplab = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
+var DATA_TRIGGER = 'data-dropdown-trigger';
+var DATA_DROPDOWN = 'data-dropdown';
+
+module.exports = {
+ DATA_TRIGGER: DATA_TRIGGER,
+ DATA_DROPDOWN: DATA_DROPDOWN,
+}
+
+},{}],2:[function(require,module,exports){
+// Custom event support for IE
+if ( typeof CustomEvent === "function" ) {
+ module.exports = CustomEvent;
+} else {
+ require('./window')(function(w){
+ var CustomEvent = function ( event, params ) {
+ params = params || { bubbles: false, cancelable: false, detail: undefined };
+ var evt = document.createEvent( 'CustomEvent' );
+ evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail );
+ return evt;
+ }
+ CustomEvent.prototype = w.Event.prototype;
+
+ w.CustomEvent = CustomEvent;
+ });
+ module.exports = CustomEvent;
+}
+
+},{"./window":11}],3:[function(require,module,exports){
+var CustomEvent = require('./custom_event_polyfill');
+var utils = require('./utils');
+
+var DropDown = function(list) {
+ this.hidden = true;
+ this.list = list;
+ this.items = [];
+ this.getItems();
+ this.addEvents();
+ this.initialState = list.innerHTML;
+};
+
+Object.assign(DropDown.prototype, {
+ getItems: function() {
+ this.items = [].slice.call(this.list.querySelectorAll('li'));
+ return this.items;
+ },
+
+ clickEvent: function(e) {
+ // climb up the tree to find the LI
+ var selected = utils.closest(e.target, 'LI');
+
+ if(selected) {
+ e.preventDefault();
+ this.hide();
+ var listEvent = new CustomEvent('click.dl', {
+ detail: {
+ list: this,
+ selected: selected,
+ data: e.target.dataset,
+ },
+ });
+ this.list.dispatchEvent(listEvent);
+ }
+ },
+
+ addEvents: function() {
+ this.clickWrapper = this.clickEvent.bind(this);
+ // event delegation.
+ this.list.addEventListener('click', this.clickWrapper);
+ },
+
+ toggle: function() {
+ if(this.hidden) {
+ this.show();
+ } else {
+ this.hide();
+ }
+ },
+
+ setData: function(data) {
+ this.data = data;
+ this.render(data);
+ },
+
+ addData: function(data) {
+ this.data = (this.data || []).concat(data);
+ this.render(data);
+ },
+
+ // call render manually on data;
+ render: function(data){
+ // debugger
+ // empty the list first
+ var sampleItem;
+ var newChildren = [];
+ var toAppend;
+
+ for(var i = 0; i < this.items.length; i++) {
+ var item = this.items[i];
+ sampleItem = item;
+ if(item.parentNode && item.parentNode.dataset.hasOwnProperty('dynamic')) {
+ item.parentNode.removeChild(item);
+ }
+ }
+
+ newChildren = this.data.map(function(dat){
+ var html = utils.t(sampleItem.outerHTML, dat);
+ var template = document.createElement('div');
+ template.innerHTML = html;
+ // console.log(template.content)
+
+ // Help set the image src template
+ var imageTags = template.querySelectorAll('img[data-src]');
+ // debugger
+ for(var i = 0; i < imageTags.length; i++) {
+ var imageTag = imageTags[i];
+ imageTag.src = imageTag.getAttribute('data-src');
+ imageTag.removeAttribute('data-src');
+ }
+
+ if(dat.hasOwnProperty('droplab_hidden') && dat.droplab_hidden){
+ template.firstChild.style.display = 'none'
+ }else{
+ template.firstChild.style.display = 'block';
+ }
+ return template.firstChild.outerHTML;
+ });
+ toAppend = this.list.querySelector('ul[data-dynamic]');
+ if(toAppend) {
+ toAppend.innerHTML = newChildren.join('');
+ } else {
+ this.list.innerHTML = newChildren.join('');
+ }
+ },
+
+ show: function() {
+ // debugger
+ this.list.style.display = 'block';
+ this.hidden = false;
+ },
+
+ hide: function() {
+ // debugger
+ this.list.style.display = 'none';
+ this.hidden = true;
+ },
+
+ destroy: function() {
+ if (!this.hidden) {
+ this.hide();
+ }
+
+ this.list.removeEventListener('click', this.clickWrapper);
+ }
+});
+
+module.exports = DropDown;
+
+},{"./custom_event_polyfill":2,"./utils":10}],4:[function(require,module,exports){
+require('./window')(function(w){
+ module.exports = function(deps) {
+ deps = deps || {};
+ var window = deps.window || w;
+ var document = deps.document || window.document;
+ var CustomEvent = deps.CustomEvent || require('./custom_event_polyfill');
+ var HookButton = deps.HookButton || require('./hook_button');
+ var HookInput = deps.HookInput || require('./hook_input');
+ var utils = deps.utils || require('./utils');
+ var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
+
+ var DropLab = function(hook){
+ if (!(this instanceof DropLab)) return new DropLab(hook);
+ this.ready = false;
+ this.hooks = [];
+ this.queuedData = [];
+ this.config = {};
+ this.loadWrapper;
+ if(typeof hook !== 'undefined'){
+ this.addHook(hook);
+ }
+ };
+
+
+ Object.assign(DropLab.prototype, {
+ load: function() {
+ this.loadWrapper();
+ },
+
+ loadWrapper: function(){
+ var dropdownTriggers = [].slice.apply(document.querySelectorAll('['+DATA_TRIGGER+']'));
+ this.addHooks(dropdownTriggers).init();
+ },
+
+ addData: function () {
+ var args = [].slice.apply(arguments);
+ this.applyArgs(args, '_addData');
+ },
+
+ setData: function() {
+ var args = [].slice.apply(arguments);
+ this.applyArgs(args, '_setData');
+ },
+
+ destroy: function() {
+ for(var i = 0; i < this.hooks.length; i++) {
+ this.hooks[i].destroy();
+ }
+ this.hooks = [];
+ this.removeEvents();
+ },
+
+ applyArgs: function(args, methodName) {
+ if(this.ready) {
+ this[methodName].apply(this, args);
+ } else {
+ this.queuedData = this.queuedData || [];
+ this.queuedData.push(args);
+ }
+ },
+
+ _addData: function(trigger, data) {
+ this._processData(trigger, data, 'addData');
+ },
+
+ _setData: function(trigger, data) {
+ this._processData(trigger, data, 'setData');
+ },
+
+ _processData: function(trigger, data, methodName) {
+ for(var i = 0; i < this.hooks.length; i++) {
+ var hook = this.hooks[i];
+ if(hook.trigger.dataset.hasOwnProperty('id')) {
+ if(hook.trigger.dataset.id === trigger) {
+ hook.list[methodName](data);
+ }
+ }
+ }
+ },
+
+ addEvents: function() {
+ var self = this;
+ this.windowClickedWrapper = function(e){
+ var thisTag = e.target;
+ if(thisTag.tagName !== 'UL'){
+ // climb up the tree to find the UL
+ thisTag = utils.closest(thisTag, 'UL');
+ }
+ if(utils.isDropDownParts(thisTag)){ return }
+ if(utils.isDropDownParts(e.target)){ return }
+ for(var i = 0; i < self.hooks.length; i++) {
+ self.hooks[i].list.hide();
+ }
+ }.bind(this);
+ w.addEventListener('click', this.windowClickedWrapper);
+ },
+
+ removeEvents: function(){
+ w.removeEventListener('click', this.windowClickedWrapper);
+ w.removeEventListener('load', this.loadWrapper);
+ },
+
+ changeHookList: function(trigger, list, plugins, config) {
+ trigger = document.querySelector('[data-id="'+trigger+'"]');
+ // list = document.querySelector(list);
+ this.hooks.every(function(hook, i) {
+ if(hook.trigger === trigger) {
+ hook.destroy();
+ this.hooks.splice(i, 1);
+ this.addHook(trigger, list, plugins, config);
+ return false;
+ }
+ return true
+ }.bind(this));
+ },
+
+ addHook: function(hook, list, plugins, config) {
+ if(!(hook instanceof HTMLElement) && typeof hook === 'string'){
+ hook = document.querySelector(hook);
+ }
+ if(!list){
+ list = document.querySelector(hook.dataset[utils.toDataCamelCase(DATA_TRIGGER)]);
+ }
+
+ if(hook) {
+ if(hook.tagName === 'A' || hook.tagName === 'BUTTON') {
+ this.hooks.push(new HookButton(hook, list, plugins, config));
+ } else if(hook.tagName === 'INPUT') {
+ this.hooks.push(new HookInput(hook, list, plugins, config));
+ }
+ }
+ return this;
+ },
+
+ addHooks: function(hooks, plugins, config) {
+ for(var i = 0; i < hooks.length; i++) {
+ var hook = hooks[i];
+ this.addHook(hook, null, plugins, config);
+ }
+ return this;
+ },
+
+ setConfig: function(obj){
+ this.config = obj;
+ },
+
+ init: function () {
+ this.addEvents();
+ var readyEvent = new CustomEvent('ready.dl', {
+ detail: {
+ dropdown: this,
+ },
+ });
+ window.dispatchEvent(readyEvent);
+ this.ready = true;
+ for(var i = 0; i < this.queuedData.length; i++) {
+ this.addData.apply(this, this.queuedData[i]);
+ }
+ this.queuedData = [];
+ return this;
+ },
+ });
+
+ return DropLab;
+ };
+});
+
+},{"./constants":1,"./custom_event_polyfill":2,"./hook_button":6,"./hook_input":7,"./utils":10,"./window":11}],5:[function(require,module,exports){
+var DropDown = require('./dropdown');
+
+var Hook = function(trigger, list, plugins, config){
+ this.trigger = trigger;
+ this.list = new DropDown(list);
+ this.type = 'Hook';
+ this.event = 'click';
+ this.plugins = plugins || [];
+ this.config = config || {};
+ this.id = trigger.dataset.id;
+};
+
+Object.assign(Hook.prototype, {
+
+ addEvents: function(){},
+
+ constructor: Hook,
+});
+
+module.exports = Hook;
+
+},{"./dropdown":3}],6:[function(require,module,exports){
+var CustomEvent = require('./custom_event_polyfill');
+var Hook = require('./hook');
+
+var HookButton = function(trigger, list, plugins, config) {
+ Hook.call(this, trigger, list, plugins, config);
+ this.type = 'button';
+ this.event = 'click';
+ this.addEvents();
+ this.addPlugins();
+};
+
+HookButton.prototype = Object.create(Hook.prototype);
+
+Object.assign(HookButton.prototype, {
+ addPlugins: function() {
+ for(var i = 0; i < this.plugins.length; i++) {
+ this.plugins[i].init(this);
+ }
+ },
+
+ clicked: function(e){
+ var buttonEvent = new CustomEvent('click.dl', {
+ detail: {
+ hook: this,
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ this.list.show();
+ e.target.dispatchEvent(buttonEvent);
+ },
+
+ addEvents: function(){
+ this.clickedWrapper = this.clicked.bind(this);
+ this.trigger.addEventListener('click', this.clickedWrapper);
+ },
+
+ removeEvents: function(){
+ this.trigger.removeEventListener('click', this.clickedWrapper);
+ },
+
+ restoreInitialState: function() {
+ this.list.list.innerHTML = this.list.initialState;
+ },
+
+ removePlugins: function() {
+ for(var i = 0; i < this.plugins.length; i++) {
+ this.plugins[i].destroy();
+ }
+ },
+
+ destroy: function() {
+ this.restoreInitialState();
+ this.removeEvents();
+ this.removePlugins();
+ },
+
+
+ constructor: HookButton,
+});
+
+
+module.exports = HookButton;
+
+},{"./custom_event_polyfill":2,"./hook":5}],7:[function(require,module,exports){
+var CustomEvent = require('./custom_event_polyfill');
+var Hook = require('./hook');
+
+var HookInput = function(trigger, list, plugins, config) {
+ Hook.call(this, trigger, list, plugins, config);
+ this.type = 'input';
+ this.event = 'input';
+ this.addPlugins();
+ this.addEvents();
+};
+
+Object.assign(HookInput.prototype, {
+ addPlugins: function() {
+ var self = this;
+ for(var i = 0; i < this.plugins.length; i++) {
+ this.plugins[i].init(self);
+ }
+ },
+
+ addEvents: function(){
+ var self = this;
+
+ this.mousedown = function mousedown(e) {
+ var mouseEvent = new CustomEvent('mousedown.dl', {
+ detail: {
+ hook: self,
+ text: e.target.value,
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ e.target.dispatchEvent(mouseEvent);
+ }
+
+ this.input = function input(e) {
+ var inputEvent = new CustomEvent('input.dl', {
+ detail: {
+ hook: self,
+ text: e.target.value,
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ e.target.dispatchEvent(inputEvent);
+ self.list.show();
+ }
+
+ this.keyup = function keyup(e) {
+ keyEvent(e, 'keyup.dl');
+ }
+
+ this.keydown = function keydown(e) {
+ keyEvent(e, 'keydown.dl');
+ }
+
+ function keyEvent(e, keyEventName){
+ var keyEvent = new CustomEvent(keyEventName, {
+ detail: {
+ hook: self,
+ text: e.target.value,
+ which: e.which,
+ key: e.key,
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ e.target.dispatchEvent(keyEvent);
+ self.list.show();
+ }
+
+ this.events = this.events || {};
+ this.events.mousedown = this.mousedown;
+ this.events.input = this.input;
+ this.events.keyup = this.keyup;
+ this.events.keydown = this.keydown;
+ this.trigger.addEventListener('mousedown', this.mousedown);
+ this.trigger.addEventListener('input', this.input);
+ this.trigger.addEventListener('keyup', this.keyup);
+ this.trigger.addEventListener('keydown', this.keydown);
+ },
+
+ removeEvents: function(){
+ this.trigger.removeEventListener('mousedown', this.mousedown);
+ this.trigger.removeEventListener('input', this.input);
+ this.trigger.removeEventListener('keyup', this.keyup);
+ this.trigger.removeEventListener('keydown', this.keydown);
+ },
+
+ restoreInitialState: function() {
+ this.list.list.innerHTML = this.list.initialState;
+ },
+
+ removePlugins: function() {
+ for(var i = 0; i < this.plugins.length; i++) {
+ this.plugins[i].destroy();
+ }
+ },
+
+ destroy: function() {
+ this.restoreInitialState();
+ this.removeEvents();
+ this.removePlugins();
+ this.list.destroy();
+ }
+});
+
+module.exports = HookInput;
+
+},{"./custom_event_polyfill":2,"./hook":5}],8:[function(require,module,exports){
+var DropLab = require('./droplab')();
+var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
+var keyboard = require('./keyboard')();
+var setup = function() {
+ window.DropLab = DropLab;
+};
+
+
+module.exports = setup();
+
+},{"./constants":1,"./droplab":4,"./keyboard":9}],9:[function(require,module,exports){
+require('./window')(function(w){
+ module.exports = function(){
+ var currentKey;
+ var currentFocus;
+ var currentIndex = 0;
+ var isUpArrow = false;
+ var isDownArrow = false;
+ var removeHighlight = function removeHighlight(list) {
+ var listItems = list.list.querySelectorAll('li');
+ for(var i = 0; i < listItems.length; i++) {
+ listItems[i].classList.remove('dropdown-active');
+ }
+ return listItems;
+ };
+
+ var setMenuForArrows = function setMenuForArrows(list) {
+ var listItems = removeHighlight(list);
+ if(currentIndex>0){
+ if(!listItems[currentIndex-1]){
+ currentIndex = currentIndex-1;
+ }
+ listItems[currentIndex-1].classList.add('dropdown-active');
+ }
+ };
+
+ var mousedown = function mousedown(e) {
+ var list = e.detail.hook.list;
+ removeHighlight(list);
+ list.show();
+ currentIndex = 0;
+ isUpArrow = false;
+ isDownArrow = false;
+ };
+ var selectItem = function selectItem(list) {
+ var listItems = removeHighlight(list);
+ var currentItem = listItems[currentIndex-1];
+ var listEvent = new CustomEvent('click.dl', {
+ detail: {
+ list: list,
+ selected: currentItem,
+ data: currentItem.dataset,
+ },
+ });
+ list.list.dispatchEvent(listEvent);
+ list.hide();
+ }
+
+ var keydown = function keydown(e){
+ var typedOn = e.target;
+ isUpArrow = false;
+ isDownArrow = false;
+
+ if(e.detail.which){
+ currentKey = e.detail.which;
+ if(currentKey === 13){
+ selectItem(e.detail.hook.list);
+ return;
+ }
+ if(currentKey === 38) {
+ isUpArrow = true;
+ }
+ if(currentKey === 40) {
+ isDownArrow = true;
+ }
+ } else if(e.detail.key) {
+ currentKey = e.detail.key;
+ if(currentKey === 'Enter'){
+ selectItem(e.detail.hook.list);
+ return;
+ }
+ if(currentKey === 'ArrowUp') {
+ isUpArrow = true;
+ }
+ if(currentKey === 'ArrowDown') {
+ isDownArrow = true;
+ }
+ }
+ if(isUpArrow){ currentIndex--; }
+ if(isDownArrow){ currentIndex++; }
+ if(currentIndex < 0){ currentIndex = 0; }
+ setMenuForArrows(e.detail.hook.list);
+ };
+
+ w.addEventListener('mousedown.dl', mousedown);
+ w.addEventListener('keydown.dl', keydown);
+ };
+});
+},{"./window":11}],10:[function(require,module,exports){
+var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
+var DATA_DROPDOWN = require('./constants').DATA_DROPDOWN;
+
+var toDataCamelCase = function(attr){
+ return this.camelize(attr.split('-').slice(1).join(' '));
+};
+
+// the tiniest damn templating I can do
+var t = function(s,d){
+ for(var p in d)
+ s=s.replace(new RegExp('{{'+p+'}}','g'), d[p]);
+ return s;
+};
+
+var camelize = function(str) {
+ return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(letter, index) {
+ return index == 0 ? letter.toLowerCase() : letter.toUpperCase();
+ }).replace(/\s+/g, '');
+};
+
+var closest = function(thisTag, stopTag) {
+ while(thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML'){
+ thisTag = thisTag.parentNode;
+ }
+ return thisTag;
+};
+
+var isDropDownParts = function(target) {
+ if(target.tagName === 'HTML') { return false; }
+ return (
+ target.hasAttribute(DATA_TRIGGER) ||
+ target.hasAttribute(DATA_DROPDOWN)
+ );
+};
+
+module.exports = {
+ toDataCamelCase: toDataCamelCase,
+ t: t,
+ camelize: camelize,
+ closest: closest,
+ isDropDownParts: isDropDownParts,
+};
+
+},{"./constants":1}],11:[function(require,module,exports){
+module.exports = function(callback) {
+ return (function() {
+ callback(this);
+ }).call(null);
+};
+
+},{}]},{},[8])(8)
+});
diff --git a/app/assets/javascripts/droplab/droplab_ajax.js b/app/assets/javascripts/droplab/droplab_ajax.js
new file mode 100644
index 00000000000..f20610b3811
--- /dev/null
+++ b/app/assets/javascripts/droplab/droplab_ajax.js
@@ -0,0 +1,79 @@
+/* eslint-disable */
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
+/* global droplab */
+
+require('../window')(function(w){
+ function droplabAjaxException(message) {
+ this.message = message;
+ }
+
+ w.droplabAjax = {
+ _loadUrlData: function _loadUrlData(url) {
+ return new Promise(function(resolve, reject) {
+ var xhr = new XMLHttpRequest;
+ xhr.open('GET', url, true);
+ xhr.onreadystatechange = function () {
+ if(xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ var data = JSON.parse(xhr.responseText);
+ return resolve(data);
+ } else {
+ return reject([xhr.responseText, xhr.status]);
+ }
+ }
+ };
+ xhr.send();
+ });
+ },
+
+ init: function init(hook) {
+ var self = this;
+ var config = hook.config.droplabAjax;
+
+ if (!config || !config.endpoint || !config.method) {
+ return;
+ }
+
+ if (config.method !== 'setData' && config.method !== 'addData') {
+ return;
+ }
+
+ if (config.loadingTemplate) {
+ var dynamicList = hook.list.list.querySelector('[data-dynamic]');
+
+ var loadingTemplate = document.createElement('div');
+ loadingTemplate.innerHTML = config.loadingTemplate;
+ loadingTemplate.setAttribute('data-loading-template', '');
+
+ this.listTemplate = dynamicList.outerHTML;
+ dynamicList.outerHTML = loadingTemplate.outerHTML;
+ }
+
+ this._loadUrlData(config.endpoint)
+ .then(function(d) {
+ if (config.loadingTemplate) {
+ var dataLoadingTemplate = hook.list.list.querySelector('[data-loading-template]');
+
+ if (dataLoadingTemplate) {
+ dataLoadingTemplate.outerHTML = self.listTemplate;
+ }
+ }
+ hook.list[config.method].call(hook.list, d);
+ }).catch(function(e) {
+ throw new droplabAjaxException(e.message || e);
+ });
+ },
+
+ destroy: function() {
+ }
+ };
+});
+},{"../window":2}],2:[function(require,module,exports){
+module.exports = function(callback) {
+ return (function() {
+ callback(this);
+ }).call(null);
+};
+
+},{}]},{},[1])(1)
+}); \ No newline at end of file
diff --git a/app/assets/javascripts/droplab/droplab_ajax_filter.js b/app/assets/javascripts/droplab/droplab_ajax_filter.js
new file mode 100644
index 00000000000..af163f76851
--- /dev/null
+++ b/app/assets/javascripts/droplab/droplab_ajax_filter.js
@@ -0,0 +1,145 @@
+/* eslint-disable */
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
+/* global droplab */
+
+require('../window')(function(w){
+ w.droplabAjaxFilter = {
+ init: function(hook) {
+ this.destroyed = false;
+ this.hook = hook;
+ this.notLoading();
+
+ this.debounceTriggerWrapper = this.debounceTrigger.bind(this);
+ this.hook.trigger.addEventListener('keydown.dl', this.debounceTriggerWrapper);
+ this.hook.trigger.addEventListener('focus', this.debounceTriggerWrapper);
+ this.trigger(true);
+ },
+
+ notLoading: function notLoading() {
+ this.loading = false;
+ },
+
+ debounceTrigger: function debounceTrigger(e) {
+ var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93];
+ var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1;
+ var focusEvent = e.type === 'focus';
+
+ if (invalidKeyPressed || this.loading) {
+ return;
+ }
+
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+
+ this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200);
+ },
+
+ trigger: function trigger(getEntireList) {
+ var config = this.hook.config.droplabAjaxFilter;
+ var searchValue = this.trigger.value;
+
+ if (!config || !config.endpoint || !config.searchKey) {
+ return;
+ }
+
+ if (config.searchValueFunction) {
+ searchValue = config.searchValueFunction();
+ }
+
+ if (config.loadingTemplate && this.hook.list.data === undefined ||
+ this.hook.list.data.length === 0) {
+ var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
+
+ var loadingTemplate = document.createElement('div');
+ loadingTemplate.innerHTML = config.loadingTemplate;
+ loadingTemplate.setAttribute('data-loading-template', true);
+
+ this.listTemplate = dynamicList.outerHTML;
+ dynamicList.outerHTML = loadingTemplate.outerHTML;
+ }
+
+ if (getEntireList) {
+ searchValue = '';
+ }
+
+ if (config.searchKey === searchValue) {
+ return this.list.show();
+ }
+
+ this.loading = true;
+
+ var params = config.params || {};
+ params[config.searchKey] = searchValue;
+ var self = this;
+ this._loadUrlData(config.endpoint + this.buildParams(params)).then(function(data) {
+ if (config.loadingTemplate && self.hook.list.data === undefined ||
+ self.hook.list.data.length === 0) {
+ const dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
+
+ if (dataLoadingTemplate) {
+ dataLoadingTemplate.outerHTML = self.listTemplate;
+ }
+ }
+
+ if (!self.destroyed) {
+ var hookListChildren = self.hook.list.list.children;
+ var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
+
+ if (onlyDynamicList && data.length === 0) {
+ self.hook.list.hide();
+ }
+
+ self.hook.list.setData.call(self.hook.list, data);
+ }
+ self.notLoading();
+ });
+ },
+
+ _loadUrlData: function _loadUrlData(url) {
+ return new Promise(function(resolve, reject) {
+ var xhr = new XMLHttpRequest;
+ xhr.open('GET', url, true);
+ xhr.onreadystatechange = function () {
+ if(xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ var data = JSON.parse(xhr.responseText);
+ return resolve(data);
+ } else {
+ return reject([xhr.responseText, xhr.status]);
+ }
+ }
+ };
+ xhr.send();
+ });
+ },
+
+ buildParams: function(params) {
+ if (!params) return '';
+ var paramsArray = Object.keys(params).map(function(param) {
+ return param + '=' + (params[param] || '');
+ });
+ return '?' + paramsArray.join('&');
+ },
+
+ destroy: function destroy() {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+
+ this.destroyed = true;
+
+ this.hook.trigger.removeEventListener('keydown.dl', this.debounceTriggerWrapper);
+ this.hook.trigger.removeEventListener('focus', this.debounceTriggerWrapper);
+ }
+ };
+});
+},{"../window":2}],2:[function(require,module,exports){
+module.exports = function(callback) {
+ return (function() {
+ callback(this);
+ }).call(null);
+};
+
+},{}]},{},[1])(1)
+}); \ No newline at end of file
diff --git a/app/assets/javascripts/droplab/droplab_filter.js b/app/assets/javascripts/droplab/droplab_filter.js
new file mode 100644
index 00000000000..41a220831f9
--- /dev/null
+++ b/app/assets/javascripts/droplab/droplab_filter.js
@@ -0,0 +1,60 @@
+/* eslint-disable */
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.filter||(g.filter = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
+/* global droplab */
+
+require('../window')(function(w){
+ w.droplabFilter = {
+
+ keydownWrapper: function(e){
+ var list = e.detail.hook.list;
+ var data = list.data;
+ var value = e.detail.hook.trigger.value.toLowerCase();
+ var config = e.detail.hook.config.droplabFilter;
+ var matches = [];
+ var filterFunction;
+ // will only work on dynamically set data
+ if(!data){
+ return;
+ }
+
+ if (config && config.filterFunction && typeof config.filterFunction === 'function') {
+ filterFunction = config.filterFunction;
+ } else {
+ filterFunction = function(o){
+ // cheap string search
+ o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1;
+ return o;
+ };
+ }
+
+ matches = data.map(function(o) {
+ return filterFunction(o, value);
+ });
+ list.render(matches);
+ },
+
+ init: function init(hookInput) {
+ var config = hookInput.config.droplabFilter;
+
+ if (!config || (!config.template && !config.filterFunction)) {
+ return;
+ }
+
+ this.hookInput = hookInput;
+ this.hookInput.trigger.addEventListener('keyup.dl', this.keydownWrapper);
+ },
+
+ destroy: function destroy(){
+ this.hookInput.trigger.removeEventListener('keyup.dl', this.keydownWrapper);
+ }
+ };
+});
+},{"../window":2}],2:[function(require,module,exports){
+module.exports = function(callback) {
+ return (function() {
+ callback(this);
+ }).call(null);
+};
+
+},{}]},{},[1])(1)
+}); \ No newline at end of file
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js.es6 b/app/assets/javascripts/filtered_search/dropdown_hint.js.es6
new file mode 100644
index 00000000000..63c20f57520
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js.es6
@@ -0,0 +1,66 @@
+/*= require filtered_search/filtered_search_dropdown */
+
+/* global droplabFilter */
+
+(() => {
+ class DropdownHint extends gl.FilteredSearchDropdown {
+ constructor(droplab, dropdown, input, filter) {
+ super(droplab, dropdown, input, filter);
+ this.config = {
+ droplabFilter: {
+ template: 'hint',
+ filterFunction: gl.DropdownUtils.filterHint,
+ },
+ };
+ }
+
+ itemClicked(e) {
+ const { selected } = e.detail;
+
+ if (selected.tagName === 'LI') {
+ if (selected.hasAttribute('data-value')) {
+ this.dismissDropdown();
+ } else {
+ const token = selected.querySelector('.js-filter-hint').innerText.trim();
+ const tag = selected.querySelector('.js-filter-tag').innerText.trim();
+
+ if (tag.length) {
+ gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''));
+ }
+ this.dismissDropdown();
+ this.dispatchInputEvent();
+ }
+ }
+ }
+
+ renderContent() {
+ const dropdownData = [{
+ icon: 'fa-pencil',
+ hint: 'author:',
+ tag: '&lt;@author&gt;',
+ }, {
+ icon: 'fa-user',
+ hint: 'assignee:',
+ tag: '&lt;@assignee&gt;',
+ }, {
+ icon: 'fa-clock-o',
+ hint: 'milestone:',
+ tag: '&lt;%milestone&gt;',
+ }, {
+ icon: 'fa-tag',
+ hint: 'label:',
+ tag: '&lt;~label&gt;',
+ }];
+
+ this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config);
+ this.droplab.setData(this.hookId, dropdownData);
+ }
+
+ init() {
+ this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init();
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.DropdownHint = DropdownHint;
+})();
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6
new file mode 100644
index 00000000000..f06c3fc9c6f
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6
@@ -0,0 +1,44 @@
+/*= require filtered_search/filtered_search_dropdown */
+
+/* global droplabAjax */
+/* global droplabFilter */
+
+(() => {
+ class DropdownNonUser extends gl.FilteredSearchDropdown {
+ constructor(droplab, dropdown, input, filter, endpoint, symbol) {
+ super(droplab, dropdown, input, filter);
+ this.symbol = symbol;
+ this.config = {
+ droplabAjax: {
+ endpoint,
+ method: 'setData',
+ loadingTemplate: this.loadingTemplate,
+ },
+ droplabFilter: {
+ filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol),
+ },
+ };
+ }
+
+ itemClicked(e) {
+ super.itemClicked(e, (selected) => {
+ const title = selected.querySelector('.js-data-value').innerText.trim();
+ return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`;
+ });
+ }
+
+ renderContent(forceShowList = false) {
+ this.droplab
+ .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config);
+ super.renderContent(forceShowList);
+ }
+
+ init() {
+ this.droplab
+ .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init();
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.DropdownNonUser = DropdownNonUser;
+})();
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_user.js.es6
new file mode 100644
index 00000000000..e80d266ae89
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js.es6
@@ -0,0 +1,53 @@
+/*= require filtered_search/filtered_search_dropdown */
+
+/* global droplabAjaxFilter */
+
+(() => {
+ class DropdownUser extends gl.FilteredSearchDropdown {
+ constructor(droplab, dropdown, input, filter) {
+ super(droplab, dropdown, input, filter);
+ this.config = {
+ droplabAjaxFilter: {
+ endpoint: '/autocomplete/users.json',
+ searchKey: 'search',
+ params: {
+ per_page: 20,
+ active: true,
+ project_id: this.getProjectId(),
+ current_user: true,
+ },
+ searchValueFunction: this.getSearchInput.bind(this),
+ loadingTemplate: this.loadingTemplate,
+ },
+ };
+ }
+
+ itemClicked(e) {
+ super.itemClicked(e,
+ selected => selected.querySelector('.dropdown-light-content').innerText.trim());
+ }
+
+ renderContent(forceShowList = false) {
+ this.droplab.changeHookList(this.hookId, this.dropdown, [droplabAjaxFilter], this.config);
+ super.renderContent(forceShowList);
+ }
+
+ getProjectId() {
+ return this.input.getAttribute('data-project-id');
+ }
+
+ getSearchInput() {
+ const query = this.input.value.trim();
+ const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
+
+ return lastToken.value || '';
+ }
+
+ init() {
+ this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init();
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.DropdownUser = DropdownUser;
+})();
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6
new file mode 100644
index 00000000000..c27ef3042d1
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6
@@ -0,0 +1,79 @@
+(() => {
+ class DropdownUtils {
+ static getEscapedText(text) {
+ let escapedText = text;
+ const hasSpace = text.indexOf(' ') !== -1;
+ const hasDoubleQuote = text.indexOf('"') !== -1;
+
+ // Encapsulate value with quotes if it has spaces
+ // Known side effect: values's with both single and double quotes
+ // won't escape properly
+ if (hasSpace) {
+ if (hasDoubleQuote) {
+ escapedText = `'${text}'`;
+ } else {
+ // Encapsulate singleQuotes or if it hasSpace
+ escapedText = `"${text}"`;
+ }
+ }
+
+ return escapedText;
+ }
+
+ static filterWithSymbol(filterSymbol, item, query) {
+ const updatedItem = item;
+ const { lastToken, searchToken } = gl.FilteredSearchTokenizer.processTokens(query);
+
+ if (lastToken !== searchToken) {
+ const title = updatedItem.title.toLowerCase();
+ let value = lastToken.value.toLowerCase();
+
+ if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) {
+ value = value.slice(1);
+ }
+
+ // Eg. filterSymbol = ~ for labels
+ const matchWithoutSymbol = lastToken.symbol === filterSymbol && title.indexOf(value) !== -1;
+ const match = title.indexOf(`${lastToken.symbol}${value}`) !== -1;
+
+ updatedItem.droplab_hidden = !match && !matchWithoutSymbol;
+ } else {
+ updatedItem.droplab_hidden = false;
+ }
+
+ return updatedItem;
+ }
+
+ static filterHint(item, query) {
+ const updatedItem = item;
+ let { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
+ lastToken = lastToken.key || lastToken || '';
+
+ if (!lastToken || query.split('').last() === ' ') {
+ updatedItem.droplab_hidden = false;
+ } else if (lastToken) {
+ const split = lastToken.split(':');
+ const tokenName = split[0].split(' ').last();
+
+ const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
+ updatedItem.droplab_hidden = tokenName ? match : false;
+ }
+
+ return updatedItem;
+ }
+
+ static setDataValueIfSelected(filter, selected) {
+ const dataValue = selected.getAttribute('data-value');
+
+ if (dataValue) {
+ gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue);
+ }
+
+ // Return boolean based on whether it was set
+ return dataValue !== null;
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.DropdownUtils = DropdownUtils;
+})();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
new file mode 100644
index 00000000000..d188718c5f3
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
@@ -0,0 +1,7 @@
+ // This is a manifest file that'll be compiled into including all the files listed below.
+ // Add new JavaScript code in separate files in this directory and they'll automatically
+ // be included in the compiled file accessible from http://example.com/assets/application.js
+ // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
+ // the compiled file.
+ //
+ /*= require_tree . */
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6
new file mode 100644
index 00000000000..886d8113f4a
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6
@@ -0,0 +1,102 @@
+(() => {
+ const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
+
+ class FilteredSearchDropdown {
+ constructor(droplab, dropdown, input, filter) {
+ this.droplab = droplab;
+ this.hookId = input.getAttribute('data-id');
+ this.input = input;
+ this.filter = filter;
+ this.dropdown = dropdown;
+ this.loadingTemplate = `<div class="filter-dropdown-loading">
+ <i class="fa fa-spinner fa-spin"></i>
+ </div>`;
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ this.itemClickedWrapper = this.itemClicked.bind(this);
+ this.dropdown.addEventListener('click.dl', this.itemClickedWrapper);
+ }
+
+ unbindEvents() {
+ this.dropdown.removeEventListener('click.dl', this.itemClickedWrapper);
+ }
+
+ getCurrentHook() {
+ return this.droplab.hooks.filter(h => h.id === this.hookId)[0] || null;
+ }
+
+ itemClicked(e, getValueFunction) {
+ const { selected } = e.detail;
+
+ if (selected.tagName === 'LI' && selected.innerHTML) {
+ const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected);
+
+ if (!dataValueSet) {
+ const value = getValueFunction(selected);
+ gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value);
+ }
+
+ this.dismissDropdown();
+ }
+ }
+
+ setAsDropdown() {
+ this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`);
+ }
+
+ setOffset(offset = 0) {
+ this.dropdown.style.left = `${offset}px`;
+ }
+
+ renderContent(forceShowList = false) {
+ if (forceShowList && this.getCurrentHook().list.hidden) {
+ this.getCurrentHook().list.show();
+ }
+ }
+
+ render(forceRenderContent = false, forceShowList = false) {
+ this.setAsDropdown();
+
+ const currentHook = this.getCurrentHook();
+ const firstTimeInitialized = currentHook === null;
+
+ if (firstTimeInitialized || forceRenderContent) {
+ this.renderContent(forceShowList);
+ } else if (currentHook.list.list.id !== this.dropdown.id) {
+ this.renderContent(forceShowList);
+ }
+ }
+
+ dismissDropdown() {
+ // Focusing on the input will dismiss dropdown
+ // (default droplab functionality)
+ this.input.focus();
+ }
+
+ dispatchInputEvent() {
+ // Propogate input change to FilteredSearchDropdownManager
+ // so that it can determine which dropdowns to open
+ this.input.dispatchEvent(new Event('input'));
+ }
+
+ hideDropdown() {
+ this.getCurrentHook().list.hide();
+ }
+
+ resetFilters() {
+ const hook = this.getCurrentHook();
+ const data = hook.list.data;
+ const results = data.map((o) => {
+ const updated = o;
+ updated.droplab_hidden = false;
+ return updated;
+ });
+ hook.list.render(results);
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.FilteredSearchDropdown = FilteredSearchDropdown;
+})();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6
new file mode 100644
index 00000000000..1cd0483877a
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6
@@ -0,0 +1,193 @@
+/* global DropLab */
+
+(() => {
+ class FilteredSearchDropdownManager {
+ constructor() {
+ this.tokenizer = gl.FilteredSearchTokenizer;
+ this.filteredSearchInput = document.querySelector('.filtered-search');
+
+ this.setupMapping();
+
+ this.cleanupWrapper = this.cleanup.bind(this);
+ document.addEventListener('page:fetch', this.cleanupWrapper);
+ }
+
+ cleanup() {
+ if (this.droplab) {
+ this.droplab.destroy();
+ this.droplab = null;
+ }
+
+ this.setupMapping();
+
+ document.removeEventListener('page:fetch', this.cleanupWrapper);
+ }
+
+ setupMapping() {
+ this.mapping = {
+ author: {
+ reference: null,
+ gl: 'DropdownUser',
+ element: document.querySelector('#js-dropdown-author'),
+ },
+ assignee: {
+ reference: null,
+ gl: 'DropdownUser',
+ element: document.querySelector('#js-dropdown-assignee'),
+ },
+ milestone: {
+ reference: null,
+ gl: 'DropdownNonUser',
+ extraArguments: ['milestones.json', '%'],
+ element: document.querySelector('#js-dropdown-milestone'),
+ },
+ label: {
+ reference: null,
+ gl: 'DropdownNonUser',
+ extraArguments: ['labels.json', '~'],
+ element: document.querySelector('#js-dropdown-label'),
+ },
+ hint: {
+ reference: null,
+ gl: 'DropdownHint',
+ element: document.querySelector('#js-dropdown-hint'),
+ },
+ };
+ }
+
+ static addWordToInput(tokenName, tokenValue = '') {
+ const input = document.querySelector('.filtered-search');
+ const word = `${tokenName}:${tokenValue}`;
+
+ const { lastToken, searchToken } = gl.FilteredSearchTokenizer.processTokens(input.value);
+ const lastSearchToken = searchToken.split(' ').last();
+ const lastInputCharacter = input.value[input.value.length - 1];
+ const lastInputTrimmedCharacter = input.value.trim()[input.value.trim().length - 1];
+
+ // Remove the typed tokenName
+ if (word.indexOf(lastSearchToken) === 0 && searchToken !== '') {
+ // Remove spaces after the colon
+ if (lastInputCharacter === ' ' && lastInputTrimmedCharacter === ':') {
+ input.value = input.value.trim();
+ }
+
+ input.value = input.value.slice(0, -1 * lastSearchToken.length);
+ } else if (lastInputCharacter !== ' ' || (lastToken && lastToken.value[lastToken.value.length - 1] === ' ')) {
+ // Remove the existing tokenValue
+ const lastTokenString = `${lastToken.key}:${lastToken.symbol}${lastToken.value}`;
+ input.value = input.value.slice(0, -1 * lastTokenString.length);
+ }
+
+ input.value += word;
+ }
+
+ updateCurrentDropdownOffset() {
+ this.updateDropdownOffset(this.currentDropdown);
+ }
+
+ updateDropdownOffset(key) {
+ if (!this.font) {
+ this.font = window.getComputedStyle(this.filteredSearchInput).font;
+ }
+
+ const filterIconPadding = 27;
+ const offset = gl.text
+ .getTextWidth(this.filteredSearchInput.value, this.font) + filterIconPadding;
+
+ this.mapping[key].reference.setOffset(offset);
+ }
+
+ load(key, firstLoad = false) {
+ const mappingKey = this.mapping[key];
+ const glClass = mappingKey.gl;
+ const element = mappingKey.element;
+ let forceShowList = false;
+
+ if (!mappingKey.reference) {
+ const dl = this.droplab;
+ const defaultArguments = [null, dl, element, this.filteredSearchInput, key];
+ const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
+
+ // Passing glArguments to `new gl[glClass](<arguments>)`
+ mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))();
+ }
+
+ if (firstLoad) {
+ mappingKey.reference.init();
+ }
+
+ if (this.currentDropdown === 'hint') {
+ // Force the dropdown to show if it was clicked from the hint dropdown
+ forceShowList = true;
+ }
+
+ this.updateDropdownOffset(key);
+ mappingKey.reference.render(firstLoad, forceShowList);
+
+ this.currentDropdown = key;
+ }
+
+ loadDropdown(dropdownName = '') {
+ let firstLoad = false;
+
+ if (!this.droplab) {
+ firstLoad = true;
+ this.droplab = new DropLab();
+ }
+
+ const match = gl.FilteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
+ const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key
+ && this.mapping[match.key];
+ const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint';
+
+ if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
+ const key = match && match.key ? match.key : 'hint';
+ this.load(key, firstLoad);
+ }
+ }
+
+ setDropdown() {
+ const { lastToken, searchToken } = this.tokenizer
+ .processTokens(this.filteredSearchInput.value);
+
+ if (this.filteredSearchInput.value.split('').last() === ' ') {
+ 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 = split[0].split(' ').last();
+ this.loadDropdown(split.length > 1 ? dropdownName : '');
+ } else if (lastToken) {
+ // Token has been initialized into an object because it has a value
+ this.loadDropdown(lastToken.key);
+ } else {
+ this.loadDropdown('hint');
+ }
+ }
+
+ resetDropdowns() {
+ // Force current dropdown to hide
+ this.mapping[this.currentDropdown].reference.hideDropdown();
+
+ // Re-Load dropdown
+ this.setDropdown();
+
+ // Reset filters for current dropdown
+ this.mapping[this.currentDropdown].reference.resetFilters();
+
+ // Reposition dropdown so that it is aligned with cursor
+ this.updateDropdownOffset(this.currentDropdown);
+ }
+
+ destroyDroplab() {
+ this.droplab.destroy();
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager;
+})();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6
new file mode 100644
index 00000000000..ffd0d7e9cba
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6
@@ -0,0 +1,171 @@
+/* global Turbolinks */
+
+(() => {
+ class FilteredSearchManager {
+ constructor() {
+ this.filteredSearchInput = document.querySelector('.filtered-search');
+ this.clearSearchButton = document.querySelector('.clear-search');
+
+ if (this.filteredSearchInput) {
+ this.tokenizer = gl.FilteredSearchTokenizer;
+ this.dropdownManager = new gl.FilteredSearchDropdownManager();
+
+ this.bindEvents();
+ this.loadSearchParamsFromURL();
+ this.dropdownManager.setDropdown();
+
+ this.cleanupWrapper = this.cleanup.bind(this);
+ document.addEventListener('page:fetch', this.cleanupWrapper);
+ }
+ }
+
+ cleanup() {
+ this.unbindEvents();
+ document.removeEventListener('page:fetch', this.cleanupWrapper);
+ }
+
+ bindEvents() {
+ this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager);
+ this.toggleClearSearchButtonWrapper = this.toggleClearSearchButton.bind(this);
+ this.checkForEnterWrapper = this.checkForEnter.bind(this);
+ this.clearSearchWrapper = this.clearSearch.bind(this);
+ this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
+
+ this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
+ this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
+ this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
+ this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
+ this.clearSearchButton.addEventListener('click', this.clearSearchWrapper);
+ }
+
+ unbindEvents() {
+ this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper);
+ this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper);
+ this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
+ this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
+ this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper);
+ }
+
+ checkForBackspace(e) {
+ // 8 = Backspace Key
+ // 46 = Delete Key
+ if (e.keyCode === 8 || e.keyCode === 46) {
+ // Reposition dropdown so that it is aligned with cursor
+ this.dropdownManager.updateCurrentDropdownOffset();
+ }
+ }
+
+ checkForEnter(e) {
+ if (e.keyCode === 13) {
+ e.preventDefault();
+
+ // Prevent droplab from opening dropdown
+ this.dropdownManager.destroyDroplab();
+
+ this.search();
+ }
+ }
+
+ toggleClearSearchButton(e) {
+ if (e.target.value) {
+ this.clearSearchButton.classList.remove('hidden');
+ } else {
+ this.clearSearchButton.classList.add('hidden');
+ }
+ }
+
+ clearSearch(e) {
+ e.preventDefault();
+
+ this.filteredSearchInput.value = '';
+ this.clearSearchButton.classList.add('hidden');
+
+ this.dropdownManager.resetDropdowns();
+ }
+
+ loadSearchParamsFromURL() {
+ const params = gl.utils.getUrlParamsArray();
+ const inputValues = [];
+
+ params.forEach((p) => {
+ const split = p.split('=');
+ const keyParam = decodeURIComponent(split[0]);
+ const value = split[1];
+
+ // Check if it matches edge conditions listed in gl.FilteredSearchTokenKeys
+ const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(p);
+
+ if (condition) {
+ inputValues.push(`${condition.tokenKey}:${condition.value}`);
+ } else {
+ // Sanitize value since URL converts spaces into +
+ // Replace before decode so that we know what was originally + versus the encoded +
+ const sanitizedValue = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : value;
+ const match = gl.FilteredSearchTokenKeys.searchByKeyParam(keyParam);
+
+ if (match) {
+ const indexOf = keyParam.indexOf('_');
+ const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam;
+ const symbol = match.symbol;
+ let quotationsToUse = '';
+
+ if (sanitizedValue.indexOf(' ') !== -1) {
+ // Prefer ", but use ' if required
+ quotationsToUse = sanitizedValue.indexOf('"') === -1 ? '"' : '\'';
+ }
+
+ inputValues.push(`${sanitizedKey}:${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`);
+ } else if (!match && keyParam === 'search') {
+ inputValues.push(sanitizedValue);
+ }
+ }
+ });
+
+ // Trim the last space value
+ this.filteredSearchInput.value = inputValues.join(' ');
+
+ if (inputValues.length > 0) {
+ this.clearSearchButton.classList.remove('hidden');
+ }
+ }
+
+ search() {
+ const paths = [];
+ const { tokens, searchToken } = this.tokenizer.processTokens(this.filteredSearchInput.value);
+ const currentState = gl.utils.getParameterByName('state') || 'opened';
+ paths.push(`state=${currentState}`);
+
+ tokens.forEach((token) => {
+ const condition = gl.FilteredSearchTokenKeys
+ .searchByConditionKeyValue(token.key, token.value.toLowerCase());
+ const { param } = gl.FilteredSearchTokenKeys.searchByKey(token.key);
+ const keyParam = param ? `${token.key}_${param}` : token.key;
+ let tokenPath = '';
+
+ if (condition) {
+ tokenPath = condition.url;
+ } else {
+ let tokenValue = token.value;
+
+ if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') ||
+ (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) {
+ tokenValue = tokenValue.slice(1, tokenValue.length - 1);
+ }
+
+ tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
+ }
+
+ paths.push(tokenPath);
+ });
+
+ if (searchToken) {
+ paths.push(`search=${encodeURIComponent(searchToken)}`);
+ }
+
+ Turbolinks.visit(`?scope=all&utf8=✓&${paths.join('&')}`);
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.FilteredSearchManager = FilteredSearchManager;
+})();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6
new file mode 100644
index 00000000000..e46373024b6
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6
@@ -0,0 +1,83 @@
+(() => {
+ const tokenKeys = [{
+ key: 'author',
+ type: 'string',
+ param: 'username',
+ symbol: '@',
+ }, {
+ key: 'assignee',
+ type: 'string',
+ param: 'username',
+ symbol: '@',
+ }, {
+ key: 'milestone',
+ type: 'string',
+ param: 'title',
+ symbol: '%',
+ }, {
+ key: 'label',
+ type: 'array',
+ param: 'name[]',
+ symbol: '~',
+ }];
+
+ const conditions = [{
+ url: 'assignee_id=0',
+ tokenKey: 'assignee',
+ value: 'none',
+ }, {
+ url: 'milestone_title=No+Milestone',
+ tokenKey: 'milestone',
+ value: 'none',
+ }, {
+ url: 'milestone_title=%23upcoming',
+ tokenKey: 'milestone',
+ value: 'upcoming',
+ }, {
+ url: 'label_name[]=No+Label',
+ tokenKey: 'label',
+ value: 'none',
+ }];
+
+ class FilteredSearchTokenKeys {
+ static get() {
+ return tokenKeys;
+ }
+
+ static getConditions() {
+ return conditions;
+ }
+
+ static searchByKey(key) {
+ return tokenKeys.find(tokenKey => tokenKey.key === key) || null;
+ }
+
+ static searchBySymbol(symbol) {
+ return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
+ }
+
+ static searchByKeyParam(keyParam) {
+ return tokenKeys.find((tokenKey) => {
+ let tokenKeyParam = tokenKey.key;
+
+ if (tokenKey.param) {
+ tokenKeyParam += `_${tokenKey.param}`;
+ }
+
+ return keyParam === tokenKeyParam;
+ }) || null;
+ }
+
+ static searchByConditionUrl(url) {
+ return conditions.find(condition => condition.url === url) || null;
+ }
+
+ static searchByConditionKeyValue(key, value) {
+ return conditions
+ .find(condition => condition.tokenKey === key && condition.value === value) || null;
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys;
+})();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6
new file mode 100644
index 00000000000..cf53845a48b
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6
@@ -0,0 +1,45 @@
+(() => {
+ class FilteredSearchTokenizer {
+ static processTokens(input) {
+ // Regex extracts `(token):(symbol)(value)`
+ // Values that start with a double quote must end in a double quote (same for single)
+ const tokenRegex = /(\w+):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\S+))/g;
+ const tokens = [];
+ let lastToken = null;
+ const searchToken = input.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => {
+ let tokenValue = v1 || v2 || v3;
+ let tokenSymbol = symbol;
+
+ if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
+ tokenSymbol = tokenValue;
+ tokenValue = '';
+ }
+
+ tokens.push({
+ key,
+ value: tokenValue || '',
+ symbol: tokenSymbol || '',
+ });
+ return '';
+ }).replace(/\s{2,}/g, ' ').trim() || '';
+
+ if (tokens.length > 0) {
+ const last = tokens[tokens.length - 1];
+ const lastString = `${last.key}:${last.symbol}${last.value}`;
+ lastToken = input.lastIndexOf(lastString) ===
+ input.length - lastString.length ? last : searchToken;
+ } else {
+ lastToken = searchToken;
+ }
+
+ return {
+ tokens,
+ lastToken,
+ searchToken,
+ };
+ }
+ }
+
+ window.gl = window.gl || {};
+ gl.FilteredSearchTokenizer = FilteredSearchTokenizer;
+})();
diff --git a/app/assets/javascripts/lib/utils/common_utils.js.es6 b/app/assets/javascripts/lib/utils/common_utils.js.es6
index b8d637a9827..0c6a3cc3170 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js.es6
+++ b/app/assets/javascripts/lib/utils/common_utils.js.es6
@@ -124,6 +124,12 @@
return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : '/' + parsedUrl.pathname;
};
+ gl.utils.getUrlParamsArray = function () {
+ // We can trust that each param has one & since values containing & will be encoded
+ // Remove the first character of search as it is always ?
+ return window.location.search.slice(1).split('&');
+ };
+
gl.utils.isMetaKey = function(e) {
return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 5066e3282d7..c856a26ae40 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -17,6 +17,21 @@
gl.text.replaceRange = function(s, start, end, substitute) {
return s.substring(0, start) + substitute + s.substring(end);
};
+ gl.text.getTextWidth = function(text, font) {
+ /**
+ * Uses canvas.measureText to compute and return the width of the given text of given font in pixels.
+ *
+ * @param {String} text The text to be rendered.
+ * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana").
+ *
+ * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
+ */
+ // re-use canvas object for better performance
+ var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas'));
+ var context = canvas.getContext('2d');
+ context.font = font;
+ return context.measureText(text).width;
+ };
gl.text.selectedText = function(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
};
diff --git a/app/assets/javascripts/search_autocomplete.js.es6 b/app/assets/javascripts/search_autocomplete.js.es6
index 437f5dbbf7d..cec8856d4e7 100644
--- a/app/assets/javascripts/search_autocomplete.js.es6
+++ b/app/assets/javascripts/search_autocomplete.js.es6
@@ -142,8 +142,9 @@
}
getCategoryContents() {
- var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, utils;
+ var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, userName, utils;
userId = gon.current_user_id;
+ userName = gon.current_username;
utils = gl.utils, projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions;
if (utils.isInGroupsPage() && groupOptions) {
options = groupOptions[utils.getGroupSlug()];
@@ -158,10 +159,10 @@
header: "" + name
}, {
text: 'Issues assigned to me',
- url: issuesPath + "/?assignee_id=" + userId
+ url: issuesPath + "/?assignee_username=" + userName
}, {
text: "Issues I've created",
- url: issuesPath + "/?author_id=" + userId
+ url: issuesPath + "/?author_username=" + userName
}, 'separator', {
text: 'Merge requests assigned to me',
url: mrPath + "/?assignee_id=" + userId
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 19827943385..fee38b05023 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -23,3 +23,118 @@
}
}
+.filtered-search-container {
+ display: -webkit-flex;
+ display: flex;
+}
+
+.filtered-search-input-container {
+ display: -webkit-flex;
+ display: flex;
+ position: relative;
+ width: 100%;
+
+ .form-control {
+ padding-left: 25px;
+ padding-right: 25px;
+
+ &:focus ~ .fa-filter {
+ color: $common-gray-dark;
+ }
+ }
+
+ .fa-filter {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ color: $gray-darkest;
+ }
+
+ .fa-times {
+ right: 10px;
+ color: $gray-darkest;
+ }
+
+ .clear-search {
+ width: 35px;
+ background-color: transparent;
+ border: none;
+ position: absolute;
+ right: 0;
+ height: 100%;
+ outline: none;
+
+ &:hover .fa-times {
+ color: $common-gray-dark;
+ }
+ }
+}
+
+.dropdown-menu .filter-dropdown-item {
+ padding: 0;
+}
+
+.filter-dropdown {
+ max-height: 215px;
+ overflow-x: scroll;
+}
+
+.filter-dropdown-item {
+ .btn {
+ border: none;
+ width: 100%;
+ text-align: left;
+ padding: 8px 16px;
+ text-overflow: ellipsis;
+ overflow-y: hidden;
+ border-radius: 0;
+
+ .fa {
+ width: 15px;
+ }
+
+ .dropdown-label-box {
+ border-color: $white-light;
+ border-style: solid;
+ border-width: 1px;
+ width: 17px;
+ height: 17px;
+ }
+
+ &:hover,
+ &:focus {
+ background-color: $dropdown-hover-color;
+ color: $white-light;
+ text-decoration: none;
+
+ .avatar {
+ border-color: $white-light;
+ }
+ }
+ }
+
+ .dropdown-light-content {
+ font-size: 14px;
+ font-weight: 400;
+ }
+
+ .dropdown-user {
+ display: -webkit-flex;
+ display: flex;
+ }
+
+ .dropdown-user-details {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ }
+}
+
+.hint-dropdown {
+ width: 250px;
+}
+
+.filter-dropdown-loading {
+ padding: 8px 16px;
+}
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index 92d687996e9..8e2c56a8488 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -23,12 +23,16 @@
margin-right: 0;
}
- .issues-details-filters,
+ .issues-details-filters:not(.filtered-search-block),
.dash-projects-filters,
.check-all-holder {
display: none;
}
+ .issues-holder .issue-check {
+ display: none;
+ }
+
.rss-btn {
display: none;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 3e52c482ece..cf9424ea5dd 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -264,6 +264,11 @@ $dropdown-toggle-active-border-color: darken($border-color, 14%);
/*
+* Filtered Search
+*/
+$dropdown-hover-color: #3b86ff;
+
+/*
* Buttons
*/
$btn-active-gray: #ececec;
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index b4c14d05eaf..1576fc80a6b 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -165,31 +165,53 @@ class IssuableFinder
end
end
- def assignee?
- params[:assignee_id].present?
+ def assignee_id?
+ params[:assignee_id].present? && params[:assignee_id] != NONE
+ end
+
+ def assignee_username?
+ params[:assignee_username].present? && params[:assignee_username] != NONE
+ end
+
+ def no_assignee?
+ # Assignee_id takes precedence over assignee_username
+ params[:assignee_id] == NONE || params[:assignee_username] == NONE
end
def assignee
return @assignee if defined?(@assignee)
@assignee =
- if assignee? && params[:assignee_id] != NONE
- User.find(params[:assignee_id])
+ if assignee_id?
+ User.find_by(id: params[:assignee_id])
+ elsif assignee_username?
+ User.find_by(username: params[:assignee_username])
else
nil
end
end
- def author?
- params[:author_id].present?
+ def author_id?
+ params[:author_id].present? && params[:author_id] != NONE
+ end
+
+ def author_username?
+ params[:author_username].present? && params[:author_username] != NONE
+ end
+
+ def no_author?
+ # author_id takes precedence over author_username
+ params[:author_id] == NONE || params[:author_username] == NONE
end
def author
return @author if defined?(@author)
@author =
- if author? && params[:author_id] != NONE
- User.find(params[:author_id])
+ if author_id?
+ User.find_by(id: params[:author_id])
+ elsif author_username?
+ User.find_by(username: params[:author_username])
else
nil
end
@@ -263,16 +285,24 @@ class IssuableFinder
end
def by_assignee(items)
- if assignee?
- items = items.where(assignee_id: assignee.try(:id))
+ if assignee
+ items = items.where(assignee_id: assignee.id)
+ elsif no_assignee?
+ items = items.where(assignee_id: nil)
+ elsif assignee_id? || assignee_username? # assignee not found
+ items = items.none
end
items
end
def by_author(items)
- if author?
- items = items.where(author_id: author.try(:id))
+ if author
+ items = items.where(author_id: author.id)
+ elsif no_author?
+ items = items.where(author_id: nil)
+ elsif author_id? || author_username? # author not found
+ items = items.none
end
items
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index c816b616631..a112928c6de 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -244,7 +244,9 @@ module ApplicationHelper
scope: params[:scope],
milestone_title: params[:milestone_title],
assignee_id: params[:assignee_id],
+ assignee_username: params[:assignee_username],
author_id: params[:author_id],
+ author_username: params[:author_username],
search: params[:search],
label_name: params[:label_name]
}
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 26f3f0ac292..18e8372ecab 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -6,6 +6,9 @@
= content_for :sub_nav do
= render "projects/issues/head"
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('filtered_search/filtered_search_bundle.js')
+
= content_for :meta_tags do
- if current_user
= auto_discovery_link_tag(:atom, url_for(params.merge(format: :atom, private_token: current_user.private_token)), title: "#{@project.name} issues")
@@ -20,7 +23,6 @@
= icon('rss')
%span.icon-label
Subscribe
- = render 'shared/issuable/search_form', path: namespace_project_issues_path(@project.namespace, @project)
- if can? current_user, :create_issue, @project
= link_to new_namespace_project_issue_path(@project.namespace,
@project,
@@ -30,7 +32,7 @@
title: "New Issue",
id: "new_issue_link" do
New Issue
- = render 'shared/issuable/filter', type: :issues
+ = render 'shared/issuable/search_bar', type: :issues
.issues-holder
= render 'issues'
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
new file mode 100644
index 00000000000..8d7b1d616f4
--- /dev/null
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -0,0 +1,127 @@
+- type = local_assigns.fetch(:type)
+
+.issues-filters
+ .issues-details-filters.row-content-block.second-block.filtered-search-block
+ = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
+ - if params[:search].present?
+ = hidden_field_tag :search, params[:search]
+ - if @bulk_edit
+ .check-all-holder
+ = check_box_tag "check_all_issues", nil, false,
+ class: "check_all_issues left"
+ .issues-other-filters.filtered-search-container
+ .filtered-search-input-container
+ %input.form-control.filtered-search{ placeholder: 'Search or filter results...', 'data-id' => 'filtered-search', 'data-project-id' => @project.id }
+ = icon('filter')
+ %button.clear-search.hidden{ type: 'button' }
+ = icon('times')
+ #js-dropdown-hint.dropdown-menu.hint-dropdown
+ %ul{ 'data-dropdown' => true }
+ %li.filter-dropdown-item{ 'data-value' => '' }
+ %button.btn.btn-link
+ = icon('search')
+ %span
+ Keep typing and press Enter
+ %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true }
+ %li.filter-dropdown-item
+ %button.btn.btn-link
+ -# Encapsulate static class name `{{icon}}` inside #{} to bypass
+ -# haml lint's ClassAttributeWithStaticValue
+ %i.fa{ class: "#{'{{icon}}'}" }
+ %span.js-filter-hint
+ {{hint}}
+ %span.js-filter-tag.dropdown-light-content
+ {{tag}}
+ #js-dropdown-author.dropdown-menu
+ %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.dropdown-user
+ %img.avatar.avatar-inline{ 'data-src' => '{{avatar_url}}', alt: '{{name}}\'s avatar', width: '30' }
+ .dropdown-user-details
+ %span
+ {{name}}
+ %span.dropdown-light-content
+ @{{username}}
+ #js-dropdown-assignee.dropdown-menu
+ %ul{ 'data-dropdown' => true }
+ %li.filter-dropdown-item{ 'data-value' => 'none' }
+ %button.btn.btn-link
+ No Assignee
+ %li.divider
+ %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.dropdown-user
+ %img.avatar.avatar-inline{ 'data-src' => '{{avatar_url}}', alt: '{{name}}\'s avatar', width: '30' }
+ .dropdown-user-details
+ %span
+ {{name}}
+ %span.dropdown-light-content
+ @{{username}}
+ #js-dropdown-milestone.dropdown-menu{ 'data-dropdown' => true }
+ %ul{ 'data-dropdown' => true }
+ %li.filter-dropdown-item{ 'data-value' => 'none' }
+ %button.btn.btn-link
+ No Milestone
+ %li.filter-dropdown-item{ 'data-value' => 'upcoming' }
+ %button.btn.btn-link
+ Upcoming
+ %li.divider
+ %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value
+ {{title}}
+ #js-dropdown-label.dropdown-menu{ 'data-dropdown' => true }
+ %ul{ 'data-dropdown' => true }
+ %li.filter-dropdown-item{ 'data-value' => 'none' }
+ %button.btn.btn-link
+ No Label
+ %li.divider
+ %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true }
+ %li.filter-dropdown-item
+ %button.btn.btn-link
+ %span.dropdown-label-box{ style: 'background: {{color}}' }
+ %span.label-title.js-data-value
+ {{title}}
+ .pull-right
+ = render 'shared/sort_dropdown'
+
+ - if @bulk_edit
+ .issues_bulk_update.hide
+ = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
+ .filter-item.inline
+ = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do
+ %ul
+ %li
+ %a{ href: "#", data: { id: "reopen" } } Open
+ %li
+ %a{ href: "#", data: { id: "close" } } Closed
+ .filter-item.inline
+ = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
+ placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } })
+ .filter-item.inline
+ = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
+ .filter-item.inline.labels-filter
+ = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
+ .filter-item.inline
+ = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do
+ %ul
+ %li
+ %a{ href: "#", data: { id: "subscribe" } } Subscribe
+ %li
+ %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe
+
+ = hidden_field_tag 'update[issuable_ids]', []
+ = hidden_field_tag :state_event, params[:state_event]
+ .filter-item.inline
+ = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save"
+
+:javascript
+ new UsersSelect();
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssueStatusSelect();
+ new SubscriptionSelect();
+ $('form.filter-form').on('submit', function (event) {
+ event.preventDefault();
+ Turbolinks.visit(this.action + '&' + $(this).serialize());
+ });
diff --git a/config/application.rb b/config/application.rb
index 1de7fb7bdb8..aa52b0cd512 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -106,6 +106,7 @@ module Gitlab
config.assets.precompile << "blob_edit/blob_edit_bundle.js"
config.assets.precompile << "snippet/snippet_bundle.js"
config.assets.precompile << "terminal/terminal_bundle.js"
+ config.assets.precompile << "filtered_search/filtered_search_bundle.js"
config.assets.precompile << "lib/utils/*.js"
config.assets.precompile << "lib/*.js"
config.assets.precompile << "u2f.js"
diff --git a/features/project/issues/filter_labels.feature b/features/project/issues/filter_labels.feature
deleted file mode 100644
index 49d7a3b9af2..00000000000
--- a/features/project/issues/filter_labels.feature
+++ /dev/null
@@ -1,28 +0,0 @@
-@project_issues
-Feature: Project Issues Filter Labels
- Background:
- Given I sign in as a user
- And I own project "Shop"
- And project "Shop" has labels: "bug", "feature", "enhancement"
- And project "Shop" has issue "Bugfix1" with labels: "bug", "feature"
- And project "Shop" has issue "Bugfix2" with labels: "bug", "enhancement"
- And project "Shop" has issue "Feature1" with labels: "feature"
- Given I visit project "Shop" issues page
-
- @javascript
- Scenario: I filter by one label
- Given I click link "bug"
- And I click "dropdown close button"
- Then I should see "Bugfix1" in issues list
- And I should see "Bugfix2" in issues list
- And I should not see "Feature1" in issues list
-
- # TODO: make labels filter works according to this scanario
- # right now it looks for label 1 OR label 2. Old behaviour (this test) was
- # all issues that have both label 1 AND label 2
- #Scenario: I filter by two labels
- #Given I click link "bug"
- #And I click link "feature"
- #Then I should see "Bugfix1" in issues list
- #And I should not see "Bugfix2" in issues list
- #And I should not see "Feature1" in issues list
diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature
index 80670063ea0..b2b4fe72220 100644
--- a/features/project/issues/issues.feature
+++ b/features/project/issues/issues.feature
@@ -26,12 +26,6 @@ Feature: Project Issues
Given I click link "Release 0.4"
Then I should see issue "Release 0.4"
- @javascript
- Scenario: I filter by author
- Given I add a user to project "Shop"
- And I click "author" dropdown
- Then I see current user as the first user
-
Scenario: I submit new unassigned issue
Given I click link "New Issue"
And I submit new issue "500 error on profile"
@@ -84,56 +78,6 @@ Feature: Project Issues
And I sort the list by "Least popular"
Then The list should be sorted by "Least popular"
- @javascript
- Scenario: I search issue
- Given I fill in issue search with "Re"
- Then I should see "Release 0.4" in issues
- And I should not see "Release 0.3" in issues
- And I should not see "Tweet control" in issues
-
- @javascript
- Scenario: I search issue that not exist
- Given I fill in issue search with "Bu"
- Then I should not see "Release 0.4" in issues
- And I should not see "Release 0.3" in issues
-
- @javascript
- Scenario: I search all issues
- Given I click link "All"
- And I fill in issue search with ".3"
- Then I should see "Release 0.3" in issues
- And I should not see "Release 0.4" in issues
-
- @javascript
- Scenario: Search issues when search string exactly matches issue description
- Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1'
- And I fill in issue search with 'Description for issue1'
- Then I should see 'Bugfix1' in issues
- And I should not see "Release 0.4" in issues
- And I should not see "Release 0.3" in issues
- And I should not see "Tweet control" in issues
-
- @javascript
- Scenario: Search issues when search string partially matches issue description
- Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1'
- And project 'Shop' has issue 'Feature1' with description: 'Feature submitted for issue1'
- And I fill in issue search with 'issue1'
- Then I should see 'Feature1' in issues
- Then I should see 'Bugfix1' in issues
- And I should not see "Release 0.4" in issues
- And I should not see "Release 0.3" in issues
- And I should not see "Tweet control" in issues
-
- @javascript
- Scenario: Search issues when search string matches no issue description
- Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1'
- And I fill in issue search with 'Rock and roll'
- Then I should not see 'Bugfix1' in issues
- And I should not see "Release 0.4" in issues
- And I should not see "Release 0.3" in issues
- And I should not see "Tweet control" in issues
-
-
# Markdown
Scenario: Headers inside the description should have ids generated for them.
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index 4d4e04e9e35..b8a5ac907a4 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -13,6 +13,7 @@ module Gitlab
if current_user
gon.current_user_id = current_user.id
+ gon.current_username = current_user.username
end
end
end
diff --git a/spec/features/issues/filter_by_milestone_spec.rb b/spec/features/issues/filter_by_milestone_spec.rb
deleted file mode 100644
index 9dfa5d1de19..00000000000
--- a/spec/features/issues/filter_by_milestone_spec.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-require 'rails_helper'
-
-feature 'Issue filtering by Milestone', feature: true do
- let(:project) { create(:project, :public) }
- let(:milestone) { create(:milestone, project: project) }
-
- scenario 'filters by no Milestone', js: true do
- create(:issue, project: project)
- create(:issue, project: project, milestone: milestone)
-
- visit_issues(project)
- filter_by_milestone(Milestone::None.title)
-
- expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'No Milestone')
- expect(page).to have_css('.issue', count: 1)
- end
-
- context 'filters by upcoming milestone', js: true do
- it 'does not show issues with no expiry' do
- create(:issue, project: project)
- create(:issue, project: project, milestone: milestone)
-
- visit_issues(project)
- filter_by_milestone(Milestone::Upcoming.title)
-
- expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming')
- expect(page).to have_css('.issue', count: 0)
- end
-
- it 'shows issues in future' do
- milestone = create(:milestone, project: project, due_date: Date.tomorrow)
- create(:issue, project: project)
- create(:issue, project: project, milestone: milestone)
-
- visit_issues(project)
- filter_by_milestone(Milestone::Upcoming.title)
-
- expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming')
- expect(page).to have_css('.issue', count: 1)
- end
-
- it 'does not show issues in past' do
- milestone = create(:milestone, project: project, due_date: Date.yesterday)
- create(:issue, project: project)
- create(:issue, project: project, milestone: milestone)
-
- visit_issues(project)
- filter_by_milestone(Milestone::Upcoming.title)
-
- expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming')
- expect(page).to have_css('.issue', count: 0)
- end
- end
-
- scenario 'filters by a specific Milestone', js: true do
- create(:issue, project: project, milestone: milestone)
- create(:issue, project: project)
-
- visit_issues(project)
- filter_by_milestone(milestone.title)
-
- expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: milestone.title)
- expect(page).to have_css('.issue', count: 1)
- end
-
- context 'when milestone has single quotes in title' do
- background do
- milestone.update(name: "rock 'n' roll")
- end
-
- scenario 'filters by a specific Milestone', js: true do
- create(:issue, project: project, milestone: milestone)
- create(:issue, project: project)
-
- visit_issues(project)
- filter_by_milestone(milestone.title)
-
- expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: milestone.title)
- expect(page).to have_css('.issue', count: 1)
- end
- end
-
- def visit_issues(project)
- visit namespace_project_issues_path(project.namespace, project)
- end
-
- def filter_by_milestone(title)
- find(".js-milestone-select").click
- find(".milestone-filter .dropdown-content a", text: title).click
- end
-end
diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
new file mode 100644
index 00000000000..6f6a2532c04
--- /dev/null
+++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
@@ -0,0 +1,166 @@
+require 'rails_helper'
+
+describe 'Dropdown assignee', js: true, feature: true do
+ include WaitForAjax
+
+ let!(:project) { create(:empty_project) }
+ let!(:user) { create(:user, name: 'administrator', username: 'root') }
+ let!(:user_john) { create(:user, name: 'John', username: 'th0mas') }
+ let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') }
+ let(:filtered_search) { find('.filtered-search') }
+ let(:js_dropdown_assignee) { '#js-dropdown-assignee' }
+
+ def send_keys_to_filtered_search(input)
+ input.split("").each do |i|
+ filtered_search.send_keys(i)
+ sleep 5
+ wait_for_ajax
+ end
+ end
+
+ def dropdown_assignee_size
+ page.all('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item').size
+ end
+
+ def click_assignee(text)
+ find('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item', text: text).click
+ end
+
+ before do
+ project.team << [user, :master]
+ project.team << [user_john, :master]
+ project.team << [user_jacob, :master]
+ login_as(user)
+ create(:issue, project: project)
+
+ visit namespace_project_issues_path(project.namespace, project)
+ end
+
+ describe 'behavior' do
+ it 'opens when the search bar has assignee:' do
+ filtered_search.set('assignee:')
+
+ expect(page).to have_css(js_dropdown_assignee, visible: true)
+ end
+
+ it 'closes when the search bar is unfocused' do
+ find('body').click()
+
+ expect(page).to have_css(js_dropdown_assignee, visible: false)
+ end
+
+ it 'should show loading indicator when opened' do
+ filtered_search.set('assignee:')
+
+ expect(page).to have_css('#js-dropdown-assignee .filter-dropdown-loading', visible: true)
+ end
+
+ it 'should hide loading indicator when loaded' do
+ send_keys_to_filtered_search('assignee:')
+
+ expect(page).not_to have_css('#js-dropdown-assignee .filter-dropdown-loading')
+ end
+
+ it 'should load all the assignees when opened' do
+ send_keys_to_filtered_search('assignee:')
+
+ expect(dropdown_assignee_size).to eq(3)
+ end
+ end
+
+ describe 'filtering' do
+ before do
+ send_keys_to_filtered_search('assignee:')
+ end
+
+ it 'filters by name' do
+ send_keys_to_filtered_search('j')
+
+ expect(dropdown_assignee_size).to eq(2)
+ end
+
+ it 'filters by case insensitive name' do
+ send_keys_to_filtered_search('J')
+
+ expect(dropdown_assignee_size).to eq(2)
+ end
+
+ it 'filters by username with symbol' do
+ send_keys_to_filtered_search('@ot')
+
+ expect(dropdown_assignee_size).to eq(2)
+ end
+
+ it 'filters by case insensitive username with symbol' do
+ send_keys_to_filtered_search('@OT')
+
+ expect(dropdown_assignee_size).to eq(2)
+ end
+
+ it 'filters by username without symbol' do
+ send_keys_to_filtered_search('ot')
+
+ expect(dropdown_assignee_size).to eq(2)
+ end
+
+ it 'filters by case insensitive username without symbol' do
+ send_keys_to_filtered_search('OT')
+
+ expect(dropdown_assignee_size).to eq(2)
+ end
+ end
+
+ describe 'selecting from dropdown' do
+ before do
+ filtered_search.set('assignee:')
+ end
+
+ it 'fills in the assignee username when the assignee has not been filtered' do
+ click_assignee(user_jacob.name)
+
+ expect(page).to have_css(js_dropdown_assignee, visible: false)
+ expect(filtered_search.value).to eq("assignee:@#{user_jacob.username}")
+ end
+
+ it 'fills in the assignee username when the assignee has been filtered' do
+ send_keys_to_filtered_search('roo')
+ click_assignee(user.name)
+
+ expect(page).to have_css(js_dropdown_assignee, visible: false)
+ expect(filtered_search.value).to eq("assignee:@#{user.username}")
+ end
+
+ it 'selects `no assignee`' do
+ find('#js-dropdown-assignee .filter-dropdown-item', text: 'No Assignee').click
+
+ expect(page).to have_css(js_dropdown_assignee, visible: false)
+ expect(filtered_search.value).to eq("assignee:none")
+ end
+ end
+
+ describe 'input has existing content' do
+ it 'opens assignee dropdown with existing search term' do
+ filtered_search.set('searchTerm assignee:')
+
+ expect(page).to have_css(js_dropdown_assignee, visible: true)
+ end
+
+ it 'opens assignee dropdown with existing author' do
+ filtered_search.set('author:@user assignee:')
+
+ expect(page).to have_css(js_dropdown_assignee, visible: true)
+ end
+
+ it 'opens assignee dropdown with existing label' do
+ filtered_search.set('label:~bug assignee:')
+
+ expect(page).to have_css(js_dropdown_assignee, visible: true)
+ end
+
+ it 'opens assignee dropdown with existing milestone' do
+ filtered_search.set('milestone:%v1.0 assignee:')
+
+ expect(page).to have_css(js_dropdown_assignee, visible: true)
+ end
+ end
+end
diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb
new file mode 100644
index 00000000000..60a86cc93d4
--- /dev/null
+++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb
@@ -0,0 +1,154 @@
+require 'rails_helper'
+
+describe 'Dropdown author', js: true, feature: true do
+ include WaitForAjax
+
+ let!(:project) { create(:empty_project) }
+ let!(:user) { create(:user, name: 'administrator', username: 'root') }
+ let!(:user_john) { create(:user, name: 'John', username: 'th0mas') }
+ let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') }
+ let(:filtered_search) { find('.filtered-search') }
+ let(:js_dropdown_author) { '#js-dropdown-author' }
+
+ def send_keys_to_filtered_search(input)
+ input.split("").each do |i|
+ filtered_search.send_keys(i)
+ sleep 5
+ wait_for_ajax
+ end
+ end
+
+ def dropdown_author_size
+ page.all('#js-dropdown-author .filter-dropdown .filter-dropdown-item').size
+ end
+
+ def click_author(text)
+ find('#js-dropdown-author .filter-dropdown .filter-dropdown-item', text: text).click
+ end
+
+ before do
+ project.team << [user, :master]
+ project.team << [user_john, :master]
+ project.team << [user_jacob, :master]
+ login_as(user)
+ create(:issue, project: project)
+
+ visit namespace_project_issues_path(project.namespace, project)
+ end
+
+ describe 'behavior' do
+ it 'opens when the search bar has author:' do
+ filtered_search.set('author:')
+
+ expect(page).to have_css(js_dropdown_author, visible: true)
+ end
+
+ it 'closes when the search bar is unfocused' do
+ find('body').click()
+
+ expect(page).to have_css(js_dropdown_author, visible: false)
+ end
+
+ it 'should show loading indicator when opened' do
+ filtered_search.set('author:')
+
+ expect(page).to have_css('#js-dropdown-author .filter-dropdown-loading', visible: true)
+ end
+
+ it 'should hide loading indicator when loaded' do
+ send_keys_to_filtered_search('author:')
+
+ expect(page).not_to have_css('#js-dropdown-author .filter-dropdown-loading')
+ end
+
+ it 'should load all the authors when opened' do
+ send_keys_to_filtered_search('author:')
+
+ expect(dropdown_author_size).to eq(3)
+ end
+ end
+
+ describe 'filtering' do
+ before do
+ filtered_search.set('author')
+ send_keys_to_filtered_search(':')
+ end
+
+ it 'filters by name' do
+ send_keys_to_filtered_search('ja')
+
+ expect(dropdown_author_size).to eq(1)
+ end
+
+ it 'filters by case insensitive name' do
+ send_keys_to_filtered_search('Ja')
+
+ expect(dropdown_author_size).to eq(1)
+ end
+
+ it 'filters by username with symbol' do
+ send_keys_to_filtered_search('@ot')
+
+ expect(dropdown_author_size).to eq(2)
+ end
+
+ it 'filters by username without symbol' do
+ send_keys_to_filtered_search('ot')
+
+ expect(dropdown_author_size).to eq(2)
+ end
+
+ it 'filters by case insensitive username without symbol' do
+ send_keys_to_filtered_search('OT')
+
+ expect(dropdown_author_size).to eq(2)
+ end
+ end
+
+ describe 'selecting from dropdown' do
+ before do
+ filtered_search.set('author')
+ send_keys_to_filtered_search(':')
+ end
+
+ it 'fills in the author username when the author has not been filtered' do
+ click_author(user_jacob.name)
+
+ expect(page).to have_css(js_dropdown_author, visible: false)
+ expect(filtered_search.value).to eq("author:@#{user_jacob.username}")
+ end
+
+ it 'fills in the author username when the author has been filtered' do
+ click_author(user.name)
+
+ expect(page).to have_css(js_dropdown_author, visible: false)
+ expect(filtered_search.value).to eq("author:@#{user.username}")
+ end
+ end
+
+ describe 'input has existing content' do
+ it 'opens author dropdown with existing search term' do
+ filtered_search.set('searchTerm author:')
+
+ expect(page).to have_css(js_dropdown_author, visible: true)
+ end
+
+ it 'opens author dropdown with existing assignee' do
+ filtered_search.set('assignee:@user author:')
+
+ expect(page).to have_css(js_dropdown_author, visible: true)
+ end
+
+ it 'opens author dropdown with existing label' do
+ filtered_search.set('label:~bug author:')
+
+ expect(page).to have_css(js_dropdown_author, visible: true)
+ end
+
+ it 'opens author dropdown with existing milestone' do
+ filtered_search.set('milestone:%v1.0 author:')
+
+ expect(page).to have_css(js_dropdown_author, visible: true)
+ end
+ end
+end
diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
new file mode 100644
index 00000000000..04dd54ab459
--- /dev/null
+++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb
@@ -0,0 +1,134 @@
+require 'rails_helper'
+
+describe 'Dropdown hint', js: true, feature: true do
+ include WaitForAjax
+
+ let!(:project) { create(:empty_project) }
+ let!(:user) { create(:user) }
+ let(:filtered_search) { find('.filtered-search') }
+ let(:js_dropdown_hint) { '#js-dropdown-hint' }
+
+ def dropdown_hint_size
+ page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size
+ end
+
+ def click_hint(text)
+ find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click
+ end
+
+ before do
+ project.team << [user, :master]
+ login_as(user)
+ create(:issue, project: project)
+
+ visit namespace_project_issues_path(project.namespace, project)
+ end
+
+ describe 'behavior' do
+ before do
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ filtered_search.click
+ end
+
+ it 'opens when the search bar is first focused' do
+ expect(page).to have_css(js_dropdown_hint, visible: true)
+ end
+
+ it 'closes when the search bar is unfocused' do
+ find('body').click
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ end
+ end
+
+ describe 'filtering' do
+ it 'does not filter `Keep typing and press Enter`' do
+ filtered_search.set('randomtext')
+
+ expect(page).to have_css(js_dropdown_hint, text: 'Keep typing and press Enter', visible: false)
+ expect(dropdown_hint_size).to eq(0)
+ end
+
+ it 'filters with text' do
+ filtered_search.set('a')
+
+ expect(dropdown_hint_size).to eq(3)
+ end
+ end
+
+ describe 'selecting from dropdown with no input' do
+ before do
+ filtered_search.click
+ end
+
+ it 'opens the author dropdown when you click on author' do
+ click_hint('author')
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect(page).to have_css('#js-dropdown-author', visible: true)
+ expect(filtered_search.value).to eq('author:')
+ end
+
+ it 'opens the assignee dropdown when you click on assignee' do
+ click_hint('assignee')
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect(page).to have_css('#js-dropdown-assignee', visible: true)
+ expect(filtered_search.value).to eq('assignee:')
+ end
+
+ it 'opens the milestone dropdown when you click on milestone' do
+ click_hint('milestone')
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect(page).to have_css('#js-dropdown-milestone', visible: true)
+ expect(filtered_search.value).to eq('milestone:')
+ end
+
+ it 'opens the label dropdown when you click on label' do
+ click_hint('label')
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect(page).to have_css('#js-dropdown-label', visible: true)
+ expect(filtered_search.value).to eq('label:')
+ end
+ end
+
+ describe 'selecting from dropdown with some input' do
+ it 'opens the author dropdown when you click on author' do
+ filtered_search.set('auth')
+ click_hint('author')
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect(page).to have_css('#js-dropdown-author', visible: true)
+ expect(filtered_search.value).to eq('author:')
+ end
+
+ it 'opens the assignee dropdown when you click on assignee' do
+ filtered_search.set('assign')
+ click_hint('assignee')
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect(page).to have_css('#js-dropdown-assignee', visible: true)
+ expect(filtered_search.value).to eq('assignee:')
+ end
+
+ it 'opens the milestone dropdown when you click on milestone' do
+ filtered_search.set('mile')
+ click_hint('milestone')
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect(page).to have_css('#js-dropdown-milestone', visible: true)
+ expect(filtered_search.value).to eq('milestone:')
+ end
+
+ it 'opens the label dropdown when you click on label' do
+ filtered_search.set('lab')
+ click_hint('label')
+
+ expect(page).to have_css(js_dropdown_hint, visible: false)
+ expect(page).to have_css('#js-dropdown-label', visible: true)
+ expect(filtered_search.value).to eq('label:')
+ end
+ end
+end
diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb
new file mode 100644
index 00000000000..89c144141c9
--- /dev/null
+++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb
@@ -0,0 +1,242 @@
+require 'rails_helper'
+
+describe 'Dropdown label', js: true, feature: true do
+ include WaitForAjax
+
+ let!(:project) { create(:empty_project) }
+ let!(:user) { create(:user) }
+ let!(:bug_label) { create(:label, project: project, title: 'bug') }
+ let!(:uppercase_label) { create(:label, project: project, title: 'BUG') }
+ let!(:two_words_label) { create(:label, project: project, title: 'High Priority') }
+ let!(:wont_fix_label) { create(:label, project: project, title: 'Won"t Fix') }
+ let!(:wont_fix_single_label) { create(:label, project: project, title: 'Won\'t Fix') }
+ let!(:special_label) { create(:label, project: project, title: '!@#$%^+&*()')}
+ let!(:long_label) { create(:label, project: project, title: 'this is a very long title this is a very long title this is a very long title this is a very long title this is a very long title')}
+ let(:filtered_search) { find('.filtered-search') }
+ let(:js_dropdown_label) { '#js-dropdown-label' }
+
+ def send_keys_to_filtered_search(input)
+ input.split("").each do |i|
+ filtered_search.send_keys(i)
+ sleep 3
+ wait_for_ajax
+ sleep 3
+ end
+ end
+
+ def dropdown_label_size
+ page.all('#js-dropdown-label .filter-dropdown .filter-dropdown-item').size
+ end
+
+ def click_label(text)
+ find('#js-dropdown-label .filter-dropdown .filter-dropdown-item', text: text).click
+ end
+
+ before do
+ project.team << [user, :master]
+ login_as(user)
+ create(:issue, project: project)
+
+ visit namespace_project_issues_path(project.namespace, project)
+ end
+
+ describe 'behavior' do
+ it 'opens when the search bar has label:' do
+ filtered_search.set('label:')
+
+ expect(page).to have_css(js_dropdown_label, visible: true)
+ end
+
+ it 'closes when the search bar is unfocused' do
+ find('body').click()
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ end
+
+ it 'should show loading indicator when opened' do
+ filtered_search.set('label:')
+
+ expect(page).to have_css('#js-dropdown-label .filter-dropdown-loading', visible: true)
+ end
+
+ it 'should hide loading indicator when loaded' do
+ send_keys_to_filtered_search('label:')
+
+ expect(page).not_to have_css('#js-dropdown-label .filter-dropdown-loading')
+ end
+
+ it 'should load all the labels when opened' do
+ send_keys_to_filtered_search('label:')
+
+ expect(dropdown_label_size).to be > 0
+ end
+ end
+
+ describe 'filtering' do
+ before do
+ filtered_search.set('label')
+ end
+
+ it 'filters by name' do
+ send_keys_to_filtered_search(':b')
+
+ expect(dropdown_label_size).to eq(2)
+ end
+
+ it 'filters by case insensitive name' do
+ send_keys_to_filtered_search(':B')
+
+ expect(dropdown_label_size).to eq(2)
+ end
+
+ it 'filters by name with symbol' do
+ send_keys_to_filtered_search(':~bu')
+
+ expect(dropdown_label_size).to eq(2)
+ end
+
+ it 'filters by case insensitive name with symbol' do
+ send_keys_to_filtered_search(':~BU')
+
+ expect(dropdown_label_size).to eq(2)
+ end
+
+ it 'filters by multiple words' do
+ send_keys_to_filtered_search(':Hig')
+
+ expect(dropdown_label_size).to eq(1)
+ end
+
+ it 'filters by multiple words with symbol' do
+ send_keys_to_filtered_search(':~Hig')
+
+ expect(dropdown_label_size).to eq(1)
+ end
+
+ it 'filters by multiple words containing single quotes' do
+ send_keys_to_filtered_search(':won\'t')
+
+ expect(dropdown_label_size).to eq(1)
+ end
+
+ it 'filters by multiple words containing single quotes with symbol' do
+ send_keys_to_filtered_search(':~won\'t')
+
+ expect(dropdown_label_size).to eq(1)
+ end
+
+ it 'filters by multiple words containing double quotes' do
+ send_keys_to_filtered_search(':won"t')
+
+ expect(dropdown_label_size).to eq(1)
+ end
+
+ it 'filters by multiple words containing double quotes with symbol' do
+ send_keys_to_filtered_search(':~won"t')
+
+ expect(dropdown_label_size).to eq(1)
+ end
+
+ it 'filters by special characters' do
+ send_keys_to_filtered_search(':^+')
+
+ expect(dropdown_label_size).to eq(1)
+ end
+
+ it 'filters by special characters with symbol' do
+ send_keys_to_filtered_search(':~^+')
+
+ expect(dropdown_label_size).to eq(1)
+ end
+ end
+
+ describe 'selecting from dropdown' do
+ before do
+ filtered_search.set('label:')
+ end
+
+ it 'fills in the label name when the label has not been filled' do
+ click_label(bug_label.title)
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ expect(filtered_search.value).to eq("label:~#{bug_label.title}")
+ end
+
+ it 'fills in the label name when the label is partially filled' do
+ send_keys_to_filtered_search('bu')
+ click_label(bug_label.title)
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ expect(filtered_search.value).to eq("label:~#{bug_label.title}")
+ end
+
+ it 'fills in the label name that contains multiple words' do
+ click_label(two_words_label.title)
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ expect(filtered_search.value).to eq("label:~\"#{two_words_label.title}\"")
+ end
+
+ it 'fills in the label name that contains multiple words and is very long' do
+ click_label(long_label.title)
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ expect(filtered_search.value).to eq("label:~\"#{long_label.title}\"")
+ end
+
+ it 'fills in the label name that contains double quotes' do
+ click_label(wont_fix_label.title)
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ expect(filtered_search.value).to eq("label:~'#{wont_fix_label.title}'")
+ end
+
+ it 'fills in the label name with the correct capitalization' do
+ click_label(uppercase_label.title)
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ expect(filtered_search.value).to eq("label:~#{uppercase_label.title}")
+ end
+
+ it 'fills in the label name with special characters' do
+ click_label(special_label.title)
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ expect(filtered_search.value).to eq("label:~#{special_label.title}")
+ end
+
+ it 'selects `no label`' do
+ find('#js-dropdown-label .filter-dropdown-item', text: 'No Label').click
+
+ expect(page).to have_css(js_dropdown_label, visible: false)
+ expect(filtered_search.value).to eq("label:none")
+ end
+ end
+
+ describe 'input has existing content' do
+ it 'opens label dropdown with existing search term' do
+ filtered_search.set('searchTerm label:')
+ expect(page).to have_css(js_dropdown_label, visible: true)
+ end
+
+ it 'opens label dropdown with existing author' do
+ filtered_search.set('author:@person label:')
+ expect(page).to have_css(js_dropdown_label, visible: true)
+ end
+
+ it 'opens label dropdown with existing assignee' do
+ filtered_search.set('assignee:@person label:')
+ expect(page).to have_css(js_dropdown_label, visible: true)
+ end
+
+ it 'opens label dropdown with existing label' do
+ filtered_search.set('label:~urgent label:')
+ expect(page).to have_css(js_dropdown_label, visible: true)
+ end
+
+ it 'opens label dropdown with existing milestone' do
+ filtered_search.set('milestone:%v2.0 label:')
+ expect(page).to have_css(js_dropdown_label, visible: true)
+ end
+ end
+end
diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
new file mode 100644
index 00000000000..e5a271b663f
--- /dev/null
+++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb
@@ -0,0 +1,222 @@
+require 'rails_helper'
+
+describe 'Dropdown milestone', js: true, feature: true do
+ include WaitForAjax
+
+ let!(:project) { create(:empty_project) }
+ let!(:user) { create(:user) }
+ let!(:milestone) { create(:milestone, title: 'v1.0', project: project) }
+ let!(:uppercase_milestone) { create(:milestone, title: 'CAP_MILESTONE', project: project) }
+ let!(:two_words_milestone) { create(:milestone, title: 'Future Plan', project: project) }
+ let!(:wont_fix_milestone) { create(:milestone, title: 'Won"t Fix', project: project) }
+ let!(:special_milestone) { create(:milestone, title: '!@#$%^&*(+)', project: project) }
+ let!(:long_milestone) { create(:milestone, title: 'this is a very long title this is a very long title this is a very long title this is a very long title this is a very long title', project: project) }
+
+ let(:filtered_search) { find('.filtered-search') }
+ let(:js_dropdown_milestone) { '#js-dropdown-milestone' }
+
+ def send_keys_to_filtered_search(input)
+ input.split("").each do |i|
+ filtered_search.send_keys(i)
+ sleep 3
+ wait_for_ajax
+ sleep 3
+ end
+ end
+
+ def dropdown_milestone_size
+ page.all('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item').size
+ end
+
+ def click_milestone(text)
+ find('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item', text: text).click
+ end
+
+ def click_static_milestone(text)
+ find('#js-dropdown-milestone .filter-dropdown-item', text: text).click
+ end
+
+ before do
+ project.team << [user, :master]
+ login_as(user)
+ create(:issue, project: project)
+
+ visit namespace_project_issues_path(project.namespace, project)
+ end
+
+ describe 'behavior' do
+ it 'opens when the search bar has milestone:' do
+ filtered_search.set('milestone:')
+
+ expect(page).to have_css(js_dropdown_milestone, visible: true)
+ end
+
+ it 'closes when the search bar is unfocused' do
+ find('body').click()
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ end
+
+ it 'should show loading indicator when opened' do
+ filtered_search.set('milestone:')
+
+ expect(page).to have_css('#js-dropdown-milestone .filter-dropdown-loading', visible: true)
+ end
+
+ it 'should hide loading indicator when loaded' do
+ send_keys_to_filtered_search('milestone:')
+
+ expect(page).not_to have_css('#js-dropdown-milestone .filter-dropdown-loading')
+ end
+
+ it 'should load all the milestones when opened' do
+ send_keys_to_filtered_search('milestone:')
+
+ expect(dropdown_milestone_size).to be > 0
+ end
+ end
+
+ describe 'filtering' do
+ before do
+ filtered_search.set('milestone')
+ end
+
+ it 'filters by name' do
+ send_keys_to_filtered_search(':v1')
+
+ expect(dropdown_milestone_size).to eq(1)
+ end
+
+ it 'filters by case insensitive name' do
+ send_keys_to_filtered_search(':V1')
+
+ expect(dropdown_milestone_size).to eq(1)
+ end
+
+ it 'filters by name with symbol' do
+ send_keys_to_filtered_search(':%v1')
+
+ expect(dropdown_milestone_size).to eq(1)
+ end
+
+ it 'filters by case insensitive name with symbol' do
+ send_keys_to_filtered_search(':%V1')
+
+ expect(dropdown_milestone_size).to eq(1)
+ end
+
+ it 'filters by special characters' do
+ send_keys_to_filtered_search(':(+')
+
+ expect(dropdown_milestone_size).to eq(1)
+ end
+
+ it 'filters by special characters with symbol' do
+ send_keys_to_filtered_search(':%(+')
+
+ expect(dropdown_milestone_size).to eq(1)
+ end
+ end
+
+ describe 'selecting from dropdown' do
+ before do
+ filtered_search.set('milestone:')
+ end
+
+ it 'fills in the milestone name when the milestone has not been filled' do
+ click_milestone(milestone.title)
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:%#{milestone.title}")
+ end
+
+ it 'fills in the milestone name when the milestone is partially filled' do
+ send_keys_to_filtered_search('v')
+ click_milestone(milestone.title)
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:%#{milestone.title}")
+ end
+
+ it 'fills in the milestone name that contains multiple words' do
+ click_milestone(two_words_milestone.title)
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:%\"#{two_words_milestone.title}\"")
+ end
+
+ it 'fills in the milestone name that contains multiple words and is very long' do
+ click_milestone(long_milestone.title)
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:%\"#{long_milestone.title}\"")
+ end
+
+ it 'fills in the milestone name that contains double quotes' do
+ click_milestone(wont_fix_milestone.title)
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:%'#{wont_fix_milestone.title}'")
+ end
+
+ it 'fills in the milestone name with the correct capitalization' do
+ click_milestone(uppercase_milestone.title)
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:%#{uppercase_milestone.title}")
+ end
+
+ it 'fills in the milestone name with special characters' do
+ click_milestone(special_milestone.title)
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:%#{special_milestone.title}")
+ end
+
+ it 'selects `no milestone`' do
+ click_static_milestone('No Milestone')
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:none")
+ end
+
+ it 'selects `upcoming milestone`' do
+ click_static_milestone('Upcoming')
+
+ expect(page).to have_css(js_dropdown_milestone, visible: false)
+ expect(filtered_search.value).to eq("milestone:upcoming")
+ end
+ end
+
+ describe 'input has existing content' do
+ it 'opens milestone dropdown with existing search term' do
+ filtered_search.set('searchTerm milestone:')
+
+ expect(page).to have_css(js_dropdown_milestone, visible: true)
+ end
+
+ it 'opens milestone dropdown with existing author' do
+ filtered_search.set('author:@john milestone:')
+
+ expect(page).to have_css(js_dropdown_milestone, visible: true)
+ end
+
+ it 'opens milestone dropdown with existing assignee' do
+ filtered_search.set('assignee:@john milestone:')
+
+ expect(page).to have_css(js_dropdown_milestone, visible: true)
+ end
+
+ it 'opens milestone dropdown with existing label' do
+ filtered_search.set('label:~important milestone:')
+
+ expect(page).to have_css(js_dropdown_milestone, visible: true)
+ end
+
+ it 'opens milestone dropdown with existing milestone' do
+ filtered_search.set('milestone:%100 milestone:')
+
+ expect(page).to have_css(js_dropdown_milestone, visible: true)
+ end
+ end
+end
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
new file mode 100644
index 00000000000..ead43d6784a
--- /dev/null
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -0,0 +1,759 @@
+require 'rails_helper'
+
+describe 'Filter issues', js: true, feature: true do
+ include WaitForAjax
+
+ let!(:group) { create(:group) }
+ let!(:project) { create(:project, group: group) }
+ let!(:user) { create(:user) }
+ let!(:user2) { create(:user) }
+ let!(:milestone) { create(:milestone, project: project) }
+ let!(:label) { create(:label, project: project) }
+ let!(:wontfix) { create(:label, project: project, title: "Won't fix") }
+
+ let!(:bug_label) { create(:label, project: project, title: 'bug') }
+ let!(:caps_sensitive_label) { create(:label, project: project, title: 'CAPS_sensitive') }
+ let!(:milestone) { create(:milestone, title: "8", project: project) }
+ let!(:multiple_words_label) { create(:label, project: project, title: "Two words") }
+
+ let!(:closed_issue) { create(:issue, title: 'bug that is closed', project: project, state: :closed) }
+ let(:filtered_search) { find('.filtered-search') }
+
+ def input_filtered_search(search_term)
+ filtered_search.set(search_term)
+ filtered_search.send_keys(:enter)
+ end
+
+ def expect_filtered_search_input(input)
+ expect(find('.filtered-search').value).to eq(input)
+ end
+
+ def expect_no_issues_list
+ page.within '.issues-list' do
+ expect(page).not_to have_selector('.issue')
+ end
+ end
+
+ def expect_issues_list_count(open_count, closed_count = 0)
+ all_count = open_count + closed_count
+
+ expect(page).to have_issuable_counts(open: open_count, closed: closed_count, all: all_count)
+ page.within '.issues-list' do
+ expect(page).to have_selector('.issue', count: open_count)
+ end
+ end
+
+ before do
+ project.team << [user, :master]
+ project.team << [user2, :master]
+ group.add_developer(user)
+ group.add_developer(user2)
+ login_as(user)
+ create(:issue, project: project)
+
+ create(:issue, title: "Bug report 1", project: project)
+ create(:issue, title: "Bug report 2", project: project)
+ create(:issue, title: "issue with 'single quotes'", project: project)
+ create(:issue, title: "issue with \"double quotes\"", project: project)
+ create(:issue, title: "issue with !@\#{$%^&*()-+", project: project)
+ create(:issue, title: "issue by assignee", project: project, milestone: milestone, author: user, assignee: user)
+ create(:issue, title: "issue by assignee with searchTerm", project: project, milestone: milestone, author: user, assignee: user)
+
+ issue = create(:issue,
+ title: "Bug 2",
+ project: project,
+ milestone: milestone,
+ author: user,
+ assignee: user)
+ issue.labels << bug_label
+
+ issue_with_caps_label = create(:issue,
+ title: "issue by assignee with searchTerm and label",
+ project: project,
+ milestone: milestone,
+ author: user,
+ assignee: user)
+ issue_with_caps_label.labels << caps_sensitive_label
+
+ issue_with_everything = create(:issue,
+ title: "Bug report with everything you thought was possible",
+ project: project,
+ milestone: milestone,
+ author: user,
+ assignee: user)
+ issue_with_everything.labels << bug_label
+ issue_with_everything.labels << caps_sensitive_label
+
+ multiple_words_label_issue = create(:issue, title: "Issue with multiple words label", project: project)
+ multiple_words_label_issue.labels << multiple_words_label
+
+ future_milestone = create(:milestone, title: "future", project: project, due_date: Time.now + 1.month)
+
+ create(:issue,
+ title: "Issue with future milestone",
+ milestone: future_milestone,
+ project: project)
+
+ visit namespace_project_issues_path(project.namespace, project)
+ end
+
+ describe 'filter issues by author' do
+ context 'only author' do
+ it 'filters issues by searched author' do
+ input_filtered_search("author:@#{user.username}")
+
+ expect_issues_list_count(5)
+ end
+
+ it 'filters issues by invalid author' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+
+ it 'filters issues by multiple authors' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+ end
+
+ context 'author with other filters' do
+ it 'filters issues by searched author and text' do
+ search = "author:@#{user.username} issue"
+ input_filtered_search(search)
+
+ expect_issues_list_count(3)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched author, assignee and text' do
+ search = "author:@#{user.username} assignee:@#{user.username} issue"
+ input_filtered_search(search)
+
+ expect_issues_list_count(3)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched author, assignee, label, and text' do
+ search = "author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} issue"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched author, assignee, label, milestone and text' do
+ search = "author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} issue"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+ end
+
+ it 'sorting' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+ end
+
+ describe 'filter issues by assignee' do
+ context 'only assignee' do
+ it 'filters issues by searched assignee' do
+ search = "assignee:@#{user.username}"
+ input_filtered_search(search)
+
+ expect_issues_list_count(5)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by no assignee' do
+ search = "assignee:none"
+ input_filtered_search(search)
+
+ expect_issues_list_count(8, 1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by invalid assignee' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+
+ it 'filters issues by multiple assignees' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+ end
+
+ context 'assignee with other filters' do
+ it 'filters issues by searched assignee and text' do
+ search = "assignee:@#{user.username} searchTerm"
+ input_filtered_search(search)
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched assignee, author and text' do
+ search = "assignee:@#{user.username} author:@#{user.username} searchTerm"
+ input_filtered_search(search)
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched assignee, author, label, text' do
+ search = "assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} searchTerm"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched assignee, author, label, milestone and text' do
+ search = "assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} searchTerm"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+ end
+
+ context 'sorting' do
+ it 'sorts' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+ end
+ end
+
+ describe 'filter issues by label' do
+ context 'only label' do
+ it 'filters issues by searched label' do
+ search = "label:~#{bug_label.title}"
+ input_filtered_search(search)
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by no label' do
+ search = "label:none"
+ input_filtered_search(search)
+
+ expect_issues_list_count(9, 1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by invalid label' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+
+ it 'filters issues by multiple labels' do
+ search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title}"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by label 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
+
+ search = "label:~#{special_label.title}"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'does not show issues' do
+ new_label = create(:label, project: project, title: "new_label")
+
+ search = "label:~#{new_label.title}"
+ input_filtered_search(search)
+
+ expect_no_issues_list()
+ expect_filtered_search_input(search)
+ end
+ end
+
+ context 'label with multiple words' do
+ it 'special characters' do
+ special_multiple_label = create(:label, project: project, title: "Utmost |mp0rt@nce")
+ special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project)
+ special_multiple_issue.labels << special_multiple_label
+
+ search = "label:~'#{special_multiple_label.title}'"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+
+ # filtered search defaults quotations to double quotes
+ expect_filtered_search_input("label:~\"#{special_multiple_label.title}\"")
+ end
+
+ it 'single quotes' do
+ search = "label:~'#{multiple_words_label.title}'"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input("label:~\"#{multiple_words_label.title}\"")
+ end
+
+ it 'double quotes' do
+ search = "label:~\"#{multiple_words_label.title}\""
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'single quotes containing double quotes' do
+ double_quotes_label = create(:label, project: project, title: 'won"t fix')
+ double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project)
+ double_quotes_label_issue.labels << double_quotes_label
+
+ search = "label:~'#{double_quotes_label.title}'"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'double quotes containing single quotes' do
+ single_quotes_label = create(:label, project: project, title: "won't fix")
+ single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project)
+ single_quotes_label_issue.labels << single_quotes_label
+
+ search = "label:~\"#{single_quotes_label.title}\""
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+ end
+
+ context 'label with other filters' do
+ it 'filters issues by searched label and text' do
+ search = "label:~#{caps_sensitive_label.title} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched label, author and text' do
+ search = "label:~#{caps_sensitive_label.title} author:@#{user.username} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched label, author, assignee and text' do
+ search = "label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched label, author, assignee, milestone and text' do
+ search = "label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+ end
+
+ context 'multiple labels with other filters' do
+ it 'filters issues by searched label, label2, and text' do
+ search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched label, label2, author and text' do
+ search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched label, label2, author, assignee and text' do
+ search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched label, label2, author, assignee, milestone and text' do
+ search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+ end
+
+ context 'issue label clicked' do
+ before do
+ find('.issues-list .issue .issue-info a .label', text: multiple_words_label.title).click
+ sleep 1
+ end
+
+ it 'filters' do
+ expect_issues_list_count(1)
+ end
+
+ it 'displays in search bar' do
+ expect(find('.filtered-search').value).to eq("label:~\"#{multiple_words_label.title}\"")
+ end
+ end
+
+ context 'sorting' do
+ it 'sorts' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+ end
+ end
+
+ describe 'filter issues by milestone' do
+ context 'only milestone' do
+ it 'filters issues by searched milestone' do
+ input_filtered_search("milestone:%#{milestone.title}")
+
+ expect_issues_list_count(5)
+ end
+
+ it 'filters issues by no milestone' do
+ input_filtered_search("milestone:none")
+
+ expect_issues_list_count(7, 1)
+ end
+
+ it 'filters issues by upcoming milestones' do
+ input_filtered_search("milestone:upcoming")
+
+ expect_issues_list_count(1)
+ end
+
+ it 'filters issues by invalid milestones' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+
+ it 'filters issues by multiple milestones' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+
+ it 'filters issues by milestone containing special characters' do
+ special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project)
+ create(:issue, title: "Issue with special character milestone", project: project, milestone: special_milestone)
+
+ search = "milestone:%#{special_milestone.title}"
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'does not show issues' do
+ new_milestone = create(:milestone, title: "new", project: project)
+
+ search = "milestone:%#{new_milestone.title}"
+ input_filtered_search(search)
+
+ expect_no_issues_list()
+ expect_filtered_search_input(search)
+ end
+ end
+
+ context 'milestone with other filters' do
+ it 'filters issues by searched milestone and text' do
+ search = "milestone:%#{milestone.title} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched milestone, author and text' do
+ search = "milestone:%#{milestone.title} author:@#{user.username} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched milestone, author, assignee and text' do
+ search = "milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched milestone, author, assignee, label and text' do
+ search = "milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug"
+ input_filtered_search(search)
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input(search)
+ end
+ end
+
+ context 'sorting' do
+ it 'sorts' do
+ pending('to be tested, issue #26546')
+ expect(true).to be(false)
+ end
+ end
+ end
+
+ describe 'filter issues by text' do
+ context 'only text' do
+ it 'filters issues by searched text' do
+ search = 'Bug'
+ input_filtered_search(search)
+
+ expect_issues_list_count(4, 1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by multiple searched text' do
+ search = 'Bug report'
+ input_filtered_search(search)
+
+ expect_issues_list_count(3)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by case insensitive searched text' do
+ search = 'bug report'
+ input_filtered_search(search)
+
+ expect_issues_list_count(3)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched text containing single quotes' do
+ search = '\'single quotes\''
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched text containing double quotes' do
+ search = '"double quotes"'
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'filters issues by searched text containing special characters' do
+ search = '!@#{$%^&*()-+'
+ input_filtered_search(search)
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input(search)
+ end
+
+ it 'does not show any issues' do
+ search = 'testing'
+ input_filtered_search(search)
+
+ expect_no_issues_list()
+ expect_filtered_search_input(search)
+ end
+ end
+
+ context 'searched text with other filters' do
+ it 'filters issues by searched text and author' do
+ input_filtered_search("bug author:@#{user.username}")
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input("author:@#{user.username} bug")
+ end
+
+ it 'filters issues by searched text, author and more text' do
+ input_filtered_search("bug author:@#{user.username} report")
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input("author:@#{user.username} bug report")
+ end
+
+ it 'filters issues by searched text, author and assignee' do
+ input_filtered_search("bug author:@#{user.username} assignee:@#{user.username}")
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug")
+ end
+
+ it 'filters issues by searched text, author, more text and assignee' do
+ input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username}")
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug report")
+ end
+
+ it 'filters issues by searched text, author, more text, assignee and even more text' do
+ input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with")
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug report with")
+ end
+
+ it 'filters issues by searched text, author, assignee and label' do
+ input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title}")
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug")
+ end
+
+ it 'filters issues by searched text, author, text, assignee, text, label and text' do
+ input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything")
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug report with everything")
+ end
+
+ it 'filters issues by searched text, author, assignee, label and milestone' do
+ input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title}")
+
+ expect_issues_list_count(2)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title} bug")
+ end
+
+ it 'filters issues by searched text, author, text, assignee, text, label, text, milestone and text' do
+ input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything milestone:%#{milestone.title} you")
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title} bug report with everything you")
+ end
+
+ it 'filters issues by searched text, author, assignee, multiple labels and milestone' do
+ input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title}")
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} bug")
+ end
+
+ 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 assignee:@#{user.username} with label:~#{bug_label.title} everything label:~#{caps_sensitive_label.title} you milestone:%#{milestone.title} thought")
+
+ expect_issues_list_count(1)
+ expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} bug report with everything you thought")
+ end
+ end
+
+ context 'sorting' do
+ it 'sorts by oldest updated' do
+ create(:issue,
+ title: '3 days ago',
+ project: project,
+ author: user,
+ created_at: 3.days.ago,
+ updated_at: 3.days.ago)
+
+ old_issue = create(:issue,
+ title: '5 days ago',
+ project: project,
+ author: user,
+ created_at: 5.days.ago,
+ updated_at: 5.days.ago)
+
+ input_filtered_search('days ago')
+
+ expect_issues_list_count(2)
+
+ sort_toggle = find('.filtered-search-container .dropdown-toggle')
+ sort_toggle.click
+
+ find('.filtered-search-container .dropdown-menu li a', text: 'Oldest updated').click
+ wait_for_ajax
+
+ expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title)
+ end
+ end
+ end
+
+ describe 'retains filter when switching issue states' do
+ before do
+ input_filtered_search('bug')
+
+ # Wait for search results to load
+ sleep 2
+ end
+
+ it 'open state' do
+ find('.issues-state-filters a', text: 'Closed').click
+ wait_for_ajax
+
+ find('.issues-state-filters a', text: 'Open').click
+ wait_for_ajax
+
+ expect(page).to have_selector('.issues-list .issue', count: 4)
+ end
+
+ it 'closed state' do
+ find('.issues-state-filters a', text: 'Closed').click
+ wait_for_ajax
+
+ expect(page).to have_selector('.issues-list .issue', count: 1)
+ expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(closed_issue.title)
+ end
+
+ it 'all state' do
+ find('.issues-state-filters a', text: 'All').click
+ wait_for_ajax
+
+ expect(page).to have_selector('.issues-list .issue', count: 5)
+ end
+ end
+
+ describe 'RSS feeds' do
+ it 'updates atom feed link for project issues' do
+ visit namespace_project_issues_path(project.namespace, project, milestone_title: milestone.title, assignee_id: user.id)
+ link = find('.nav-controls a', text: 'Subscribe')
+ params = CGI.parse(URI.parse(link[:href]).query)
+ auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
+ auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
+
+ expect(params).to include('private_token' => [user.private_token])
+ expect(params).to include('milestone_title' => [milestone.title])
+ expect(params).to include('assignee_id' => [user.id.to_s])
+ expect(auto_discovery_params).to include('private_token' => [user.private_token])
+ expect(auto_discovery_params).to include('milestone_title' => [milestone.title])
+ expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
+ end
+
+ it 'updates atom feed link for group issues' do
+ visit issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id)
+ link = find('.nav-controls a', text: 'Subscribe')
+ params = CGI.parse(URI.parse(link[:href]).query)
+ auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
+ auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
+
+ expect(params).to include('private_token' => [user.private_token])
+ expect(params).to include('milestone_title' => [milestone.title])
+ expect(params).to include('assignee_id' => [user.id.to_s])
+ expect(auto_discovery_params).to include('private_token' => [user.private_token])
+ expect(auto_discovery_params).to include('milestone_title' => [milestone.title])
+ expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
+ end
+ end
+end
diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb
new file mode 100644
index 00000000000..56b1d354eb0
--- /dev/null
+++ b/spec/features/issues/filtered_search/search_bar_spec.rb
@@ -0,0 +1,88 @@
+require 'rails_helper'
+
+describe 'Search bar', js: true, feature: true do
+ include WaitForAjax
+
+ let!(:project) { create(:empty_project) }
+ let!(:user) { create(:user) }
+ let(:filtered_search) { find('.filtered-search') }
+
+ before do
+ project.team << [user, :master]
+ login_as(user)
+ create(:issue, project: project)
+
+ visit namespace_project_issues_path(project.namespace, project)
+ end
+
+ def get_left_style(style)
+ left_style = /left:\s\d*[.]\d*px/.match(style)
+ left_style.to_s.gsub('left: ', '').to_f
+ end
+
+ describe 'clear search button' do
+ it 'clears text' do
+ search_text = 'search_text'
+ filtered_search.set(search_text)
+
+ expect(filtered_search.value).to eq(search_text)
+ find('.filtered-search-input-container .clear-search').click
+
+ expect(filtered_search.value).to eq('')
+ end
+
+ it 'hides by default' do
+ expect(page).to have_css('.clear-search', visible: false)
+ end
+
+ it 'hides after clicked' do
+ filtered_search.set('a')
+ find('.filtered-search-input-container .clear-search').click
+
+ expect(page).to have_css('.clear-search', visible: false)
+ end
+
+ it 'hides when there is no text' do
+ filtered_search.set('a')
+ filtered_search.set('')
+
+ expect(page).to have_css('.clear-search', visible: false)
+ end
+
+ it 'shows when there is text' do
+ filtered_search.set('a')
+
+ expect(page).to have_css('.clear-search', visible: true)
+ end
+
+ it 'resets the dropdown hint filter' do
+ filtered_search.click
+ original_size = page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size
+
+ filtered_search.set('author')
+
+ expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(1)
+
+ find('.filtered-search-input-container .clear-search').click
+ filtered_search.click
+
+ expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(original_size)
+ end
+
+ it 'resets the dropdown filters' do
+ filtered_search.set('a')
+ hint_style = page.find('#js-dropdown-hint')['style']
+ hint_offset = get_left_style(hint_style)
+
+ filtered_search.set('author:')
+
+ expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(0)
+
+ find('.filtered-search-input-container .clear-search').click
+ filtered_search.click
+
+ expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to be > 0
+ expect(get_left_style(page.find('#js-dropdown-hint')['style'])).to eq(hint_offset)
+ end
+ end
+end
diff --git a/spec/features/issues/reset_filters_spec.rb b/spec/features/issues/reset_filters_spec.rb
deleted file mode 100644
index c9a3ecf16ea..00000000000
--- a/spec/features/issues/reset_filters_spec.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-require 'rails_helper'
-
-feature 'Issues filter reset button', feature: true, js: true do
- include WaitForAjax
- include IssueHelpers
-
- let!(:project) { create(:project, :public) }
- let!(:user) { create(:user)}
- let!(:milestone) { create(:milestone, project: project) }
- let!(:bug) { create(:label, project: project, name: 'bug')}
- let!(:issue1) { create(:issue, project: project, milestone: milestone, author: user, assignee: user, title: 'Feature')}
- let!(:issue2) { create(:labeled_issue, project: project, labels: [bug], title: 'Bugfix1')}
-
- before do
- project.team << [user, :developer]
- end
-
- context 'when a milestone filter has been applied' do
- it 'resets the milestone filter' do
- visit_issues(project, milestone_title: milestone.title)
- expect(page).to have_css('.issue', count: 1)
-
- reset_filters
- expect(page).to have_css('.issue', count: 2)
- end
- end
-
- context 'when a label filter has been applied' do
- it 'resets the label filter' do
- visit_issues(project, label_name: bug.name)
- expect(page).to have_css('.issue', count: 1)
-
- reset_filters
- expect(page).to have_css('.issue', count: 2)
- end
- end
-
- context 'when a text search has been conducted' do
- it 'resets the text search filter' do
- visit_issues(project, search: 'Bug')
- expect(page).to have_css('.issue', count: 1)
-
- reset_filters
- expect(page).to have_css('.issue', count: 2)
- end
- end
-
- context 'when author filter has been applied' do
- it 'resets the author filter' do
- visit_issues(project, author_id: user.id)
- expect(page).to have_css('.issue', count: 1)
-
- reset_filters
- expect(page).to have_css('.issue', count: 2)
- end
- end
-
- context 'when assignee filter has been applied' do
- it 'resets the assignee filter' do
- visit_issues(project, assignee_id: user.id)
- expect(page).to have_css('.issue', count: 1)
-
- reset_filters
- expect(page).to have_css('.issue', count: 2)
- end
- end
-
- context 'when all filters have been applied' do
- it 'resets all filters' do
- visit_issues(project, assignee_id: user.id, author_id: user.id, milestone_title: milestone.title, label_name: bug.name, search: 'Bug')
- expect(page).to have_css('.issue', count: 0)
-
- reset_filters
- expect(page).to have_css('.issue', count: 2)
- end
- end
-
- context 'when no filters have been applied' do
- it 'the reset link should not be visible' do
- visit_issues(project)
- expect(page).to have_css('.issue', count: 2)
- expect(page).not_to have_css '.reset_filters'
- end
- end
-
- def reset_filters
- find('.reset-filters').click
- end
-end
diff --git a/spec/features/issues/filter_by_labels_spec.rb b/spec/features/merge_requests/filter_by_labels_spec.rb
index 0253629f753..4c60329865c 100644
--- a/spec/features/issues/filter_by_labels_spec.rb
+++ b/spec/features/merge_requests/filter_by_labels_spec.rb
@@ -7,25 +7,27 @@ feature 'Issue filtering by Labels', feature: true, js: true do
let!(:user) { create(:user) }
let!(:label) { create(:label, project: project) }
- before do
- bug = create(:label, project: project, title: 'bug')
- feature = create(:label, project: project, title: 'feature')
- enhancement = create(:label, project: project, title: 'enhancement')
+ let!(:bug) { create(:label, project: project, title: 'bug') }
+ let!(:feature) { create(:label, project: project, title: 'feature') }
+ let!(:enhancement) { create(:label, project: project, title: 'enhancement') }
+
+ let!(:mr1) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "bugfix1") }
+ let!(:mr2) { create(:merge_request, title: "Bugfix2", source_project: project, target_project: project, source_branch: "bugfix2") }
+ let!(:mr3) { create(:merge_request, title: "Feature1", source_project: project, target_project: project, source_branch: "feature1") }
- issue1 = create(:issue, title: "Bugfix1", project: project)
- issue1.labels << bug
+ before do
+ mr1.labels << bug
- issue2 = create(:issue, title: "Bugfix2", project: project)
- issue2.labels << bug
- issue2.labels << enhancement
+ mr2.labels << bug
+ mr2.labels << enhancement
- issue3 = create(:issue, title: "Feature1", project: project)
- issue3.labels << feature
+ mr3.title = "Feature1"
+ mr3.labels << feature
project.team << [user, :master]
login_as(user)
- visit namespace_project_issues_path(project.namespace, project)
+ visit namespace_project_merge_requests_path(project.namespace, project)
end
context 'filter by label bug' do
diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/merge_requests/filter_merge_requests_spec.rb
index 0d19563d628..4642b5a530d 100644
--- a/spec/features/issues/filter_issues_spec.rb
+++ b/spec/features/merge_requests/filter_merge_requests_spec.rb
@@ -1,10 +1,10 @@
require 'rails_helper'
-describe 'Filter issues', feature: true do
+describe 'Filter merge requests', feature: true do
include WaitForAjax
+ let!(:project) { create(:project) }
let!(:group) { create(:group) }
- let!(:project) { create(:project, group: group) }
let!(:user) { create(:user)}
let!(:milestone) { create(:milestone, project: project) }
let!(:label) { create(:label, project: project) }
@@ -14,12 +14,12 @@ describe 'Filter issues', feature: true do
project.team << [user, :master]
group.add_developer(user)
login_as(user)
- create(:issue, project: project)
+ create(:merge_request, source_project: project, target_project: project)
end
- describe 'for assignee from issues#index' do
+ describe 'for assignee from mr#index' do
before do
- visit namespace_project_issues_path(project.namespace, project)
+ visit namespace_project_merge_requests_path(project.namespace, project)
find('.js-assignee-search').click
@@ -47,9 +47,9 @@ describe 'Filter issues', feature: true do
end
end
- describe 'for milestone from issues#index' do
+ describe 'for milestone from mr#index' do
before do
- visit namespace_project_issues_path(project.namespace, project)
+ visit namespace_project_merge_requests_path(project.namespace, project)
find('.js-milestone-select').click
@@ -77,9 +77,9 @@ describe 'Filter issues', feature: true do
end
end
- describe 'for label from issues#index', js: true do
+ describe 'for label from mr#index', js: true do
before do
- visit namespace_project_issues_path(project.namespace, project)
+ visit namespace_project_merge_requests_path(project.namespace, project)
find('.js-label-select').click
wait_for_ajax
end
@@ -127,7 +127,7 @@ describe 'Filter issues', feature: true do
expect(page).to have_content wontfix.title
end
- find('.dropdown-menu-close-icon').click
+ find('body').click
expect(find('.filtered-labels')).to have_content(wontfix.title)
@@ -135,7 +135,7 @@ describe 'Filter issues', feature: true do
wait_for_ajax
find('.dropdown-menu-labels a', text: label.title).click
- find('.dropdown-menu-close-icon').click
+ find('body').click
expect(find('.filtered-labels')).to have_content(wontfix.title)
expect(find('.filtered-labels')).to have_content(label.title)
@@ -150,21 +150,21 @@ describe 'Filter issues', feature: true do
it "selects and unselects `won't fix`" do
find('.dropdown-menu-labels a', text: wontfix.title).click
find('.dropdown-menu-labels a', text: wontfix.title).click
-
- find('.dropdown-menu-close-icon').click
+ # Close label dropdown to load
+ find('body').click
expect(page).not_to have_css('.filtered-labels')
end
end
describe 'for assignee and label from issues#index' do
before do
- visit namespace_project_issues_path(project.namespace, project)
+ visit namespace_project_merge_requests_path(project.namespace, project)
find('.js-assignee-search').click
find('.dropdown-menu-user-link', text: user.username).click
- expect(page).not_to have_selector('.issues-list .issue')
+ expect(page).not_to have_selector('.mr-list .merge-request')
find('.js-label-select').click
@@ -196,38 +196,40 @@ describe 'Filter issues', feature: true do
end
end
- describe 'filter issues by text' do
+ describe 'filter merge requests by text' do
before do
- create(:issue, title: "Bug", project: project)
+ create(:merge_request, title: "Bug", source_project: project, target_project: project, source_branch: "bug")
bug_label = create(:label, project: project, title: 'bug')
milestone = create(:milestone, title: "8", project: project)
- issue = create(:issue,
- title: "Bug 2",
- project: project,
+ mr = create(:merge_request,
+ title: "Bug 2",
+ source_project: project,
+ target_project: project,
+ source_branch: "bug2",
milestone: milestone,
author: user,
assignee: user)
- issue.labels << bug_label
+ mr.labels << bug_label
- visit namespace_project_issues_path(project.namespace, project)
+ visit namespace_project_merge_requests_path(project.namespace, project)
end
context 'only text', js: true do
- it 'filters issues by searched text' do
+ it 'filters merge requests by searched text' do
fill_in 'issuable_search', with: 'Bug'
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 2)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 2)
end
end
- it 'does not show any issues' do
+ it 'does not show any merge requests' do
fill_in 'issuable_search', with: 'testing'
- page.within '.issues-list' do
- expect(page).not_to have_selector('.issue')
+ page.within '.mr-list' do
+ expect(page).not_to have_selector('.merge-request')
end
end
end
@@ -237,8 +239,8 @@ describe 'Filter issues', feature: true do
fill_in 'issuable_search', with: 'Bug'
expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 2)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 2)
end
click_button 'Label'
@@ -248,8 +250,8 @@ describe 'Filter issues', feature: true do
find('.dropdown-menu-close-icon').click
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 1)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 1)
end
end
@@ -257,8 +259,8 @@ describe 'Filter issues', feature: true do
fill_in 'issuable_search', with: 'Bug'
expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 2)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 2)
end
click_button 'Milestone'
@@ -267,8 +269,8 @@ describe 'Filter issues', feature: true do
end
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 1)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 1)
end
end
@@ -276,8 +278,8 @@ describe 'Filter issues', feature: true do
fill_in 'issuable_search', with: 'Bug'
expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 2)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 2)
end
click_button 'Assignee'
@@ -286,8 +288,8 @@ describe 'Filter issues', feature: true do
end
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 1)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 1)
end
end
@@ -295,8 +297,8 @@ describe 'Filter issues', feature: true do
fill_in 'issuable_search', with: 'Bug'
expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 2)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 2)
end
click_button 'Author'
@@ -305,26 +307,27 @@ describe 'Filter issues', feature: true do
end
expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 1)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 1)
end
end
end
end
- describe 'filter issues and sort', js: true do
+ describe 'filter merge requests and sort', js: true do
before do
bug_label = create(:label, project: project, title: 'bug')
- bug_one = create(:issue, title: "Frontend", project: project)
- bug_two = create(:issue, title: "Bug 2", project: project)
- bug_one.labels << bug_label
- bug_two.labels << bug_label
+ mr1 = create(:merge_request, title: "Frontend", source_project: project, target_project: project, source_branch: "Frontend")
+ mr2 = create(:merge_request, title: "Bug 2", source_project: project, target_project: project, source_branch: "bug2")
- visit namespace_project_issues_path(project.namespace, project)
+ mr1.labels << bug_label
+ mr2.labels << bug_label
+
+ visit namespace_project_merge_requests_path(project.namespace, project)
end
- it 'is able to filter and sort issues' do
+ it 'is able to filter and sort merge requests' do
click_button 'Label'
wait_for_ajax
page.within '.labels-filter' do
@@ -334,8 +337,8 @@ describe 'Filter issues', feature: true do
wait_for_ajax
expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2)
- page.within '.issues-list' do
- expect(page).to have_selector('.issue', count: 2)
+ page.within '.mr-list' do
+ expect(page).to have_selector('.merge-request', count: 2)
end
click_button 'Last created'
@@ -344,41 +347,9 @@ describe 'Filter issues', feature: true do
end
wait_for_ajax
- page.within '.issues-list' do
+ page.within '.mr-list' do
expect(page).to have_content('Frontend')
end
end
end
-
- it 'updates atom feed link for project issues' do
- visit namespace_project_issues_path(project.namespace, project, milestone_title: '', assignee_id: user.id)
-
- link = find('.nav-controls a', text: 'Subscribe')
- params = CGI::parse(URI.parse(link[:href]).query)
- auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
- auto_discovery_params = CGI::parse(URI.parse(auto_discovery_link[:href]).query)
-
- expect(params).to include('private_token' => [user.private_token])
- expect(params).to include('milestone_title' => [''])
- expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('private_token' => [user.private_token])
- expect(auto_discovery_params).to include('milestone_title' => [''])
- expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
- end
-
- it 'updates atom feed link for group issues' do
- visit issues_group_path(group, milestone_title: '', assignee_id: user.id)
-
- link = find('.nav-controls a', text: 'Subscribe')
- params = CGI::parse(URI.parse(link[:href]).query)
- auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
- auto_discovery_params = CGI::parse(URI.parse(auto_discovery_link[:href]).query)
-
- expect(params).to include('private_token' => [user.private_token])
- expect(params).to include('milestone_title' => [''])
- expect(params).to include('assignee_id' => [user.id.to_s])
- expect(auto_discovery_params).to include('private_token' => [user.private_token])
- expect(auto_discovery_params).to include('milestone_title' => [''])
- expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s])
- end
end
diff --git a/spec/features/merge_requests/reset_filters_spec.rb b/spec/features/merge_requests/reset_filters_spec.rb
new file mode 100644
index 00000000000..3a7ece7e1d6
--- /dev/null
+++ b/spec/features/merge_requests/reset_filters_spec.rb
@@ -0,0 +1,96 @@
+require 'rails_helper'
+
+feature 'Issues filter reset button', feature: true, js: true do
+ include WaitForAjax
+ include IssueHelpers
+
+ let!(:project) { create(:project, :public) }
+ let!(:user) { create(:user)}
+ let!(:milestone) { create(:milestone, project: project) }
+ let!(:bug) { create(:label, project: project, name: 'bug')}
+ let!(:mr1) { create(:merge_request, title: "Feature", source_project: project, target_project: project, source_branch: "Feature", milestone: milestone, author: user, assignee: user) }
+ let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") }
+
+ let(:merge_request_css) { '.merge-request' }
+
+ before do
+ mr2.labels << bug
+ project.team << [user, :developer]
+ end
+
+ context 'when a milestone filter has been applied' do
+ it 'resets the milestone filter' do
+ visit_merge_requests(project, milestone_title: milestone.title)
+ expect(page).to have_css(merge_request_css, count: 1)
+
+ reset_filters
+ expect(page).to have_css(merge_request_css, count: 2)
+ end
+ end
+
+ context 'when a label filter has been applied' do
+ it 'resets the label filter' do
+ visit_merge_requests(project, label_name: bug.name)
+ expect(page).to have_css(merge_request_css, count: 1)
+
+ reset_filters
+ expect(page).to have_css(merge_request_css, count: 2)
+ end
+ end
+
+ context 'when a text search has been conducted' do
+ it 'resets the text search filter' do
+ visit_merge_requests(project, search: 'Bug')
+ expect(page).to have_css(merge_request_css, count: 1)
+
+ reset_filters
+ expect(page).to have_css(merge_request_css, count: 2)
+ end
+ end
+
+ context 'when author filter has been applied' do
+ it 'resets the author filter' do
+ visit_merge_requests(project, author_id: user.id)
+ expect(page).to have_css(merge_request_css, count: 1)
+
+ reset_filters
+ expect(page).to have_css(merge_request_css, count: 2)
+ end
+ end
+
+ context 'when assignee filter has been applied' do
+ it 'resets the assignee filter' do
+ visit_merge_requests(project, assignee_id: user.id)
+ expect(page).to have_css(merge_request_css, count: 1)
+
+ reset_filters
+ expect(page).to have_css(merge_request_css, count: 2)
+ end
+ end
+
+ context 'when all filters have been applied' do
+ it 'resets all filters' do
+ visit_merge_requests(project, assignee_id: user.id, author_id: user.id, milestone_title: milestone.title, label_name: bug.name, search: 'Bug')
+ expect(page).to have_css(merge_request_css, count: 0)
+
+ reset_filters
+ expect(page).to have_css(merge_request_css, count: 2)
+ end
+ end
+
+ context 'when no filters have been applied' do
+ it 'the reset link should not be visible' do
+ visit_merge_requests(project)
+ expect(page).to have_css(merge_request_css, count: 2)
+ expect(page).not_to have_css '.reset_filters'
+ end
+ end
+
+ def visit_merge_requests(project, opts = {})
+ visit namespace_project_merge_requests_path project.namespace, project, opts
+ end
+
+ def reset_filters
+ find('.reset-filters').click
+ end
+end
diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb
index caecd027aaa..a05b83959fb 100644
--- a/spec/features/search_spec.rb
+++ b/spec/features/search_spec.rb
@@ -169,16 +169,16 @@ describe "Search", feature: true do
find('.dropdown-menu').click_link 'Issues assigned to me'
sleep 2
- expect(page).to have_selector('.issues-holder')
- expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name)
+ expect(page).to have_selector('.filtered-search')
+ expect(find('.filtered-search').value).to eq("assignee:@#{user.username}")
end
it 'takes user to her issues page when issues authored is clicked' do
find('.dropdown-menu').click_link "Issues I've created"
sleep 2
- expect(page).to have_selector('.issues-holder')
- expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name)
+ expect(page).to have_selector('.filtered-search')
+ expect(find('.filtered-search').value).to eq("author:@#{user.username}")
end
it 'takes user to her MR page when MR assigned is clicked' do
diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 b/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6
new file mode 100644
index 00000000000..ce61b73aa8a
--- /dev/null
+++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6
@@ -0,0 +1,107 @@
+//= require extensions/array
+//= require filtered_search/dropdown_utils
+//= require filtered_search/filtered_search_tokenizer
+//= require filtered_search/filtered_search_dropdown_manager
+
+(() => {
+ describe('Dropdown Utils', () => {
+ describe('getEscapedText', () => {
+ it('should return same word when it has no space', () => {
+ const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace');
+ expect(escaped).toBe('textWithoutSpace');
+ });
+
+ it('should escape with double quotes', () => {
+ let escaped = gl.DropdownUtils.getEscapedText('text with space');
+ expect(escaped).toBe('"text with space"');
+
+ escaped = gl.DropdownUtils.getEscapedText('won\'t fix');
+ expect(escaped).toBe('"won\'t fix"');
+ });
+
+ it('should escape with single quotes', () => {
+ const escaped = gl.DropdownUtils.getEscapedText('won"t fix');
+ expect(escaped).toBe('\'won"t fix\'');
+ });
+
+ it('should escape with single quotes by default', () => {
+ const escaped = gl.DropdownUtils.getEscapedText('won"t\' fix');
+ expect(escaped).toBe('\'won"t\' fix\'');
+ });
+ });
+
+ describe('filterWithSymbol', () => {
+ const item = {
+ title: '@root',
+ };
+
+ it('should filter without symbol', () => {
+ const updatedItem = gl.DropdownUtils.filterWithSymbol('@', item, ':roo');
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with symbol', () => {
+ const updatedItem = gl.DropdownUtils.filterWithSymbol('@', item, ':@roo');
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+
+ it('should filter with colon', () => {
+ const updatedItem = gl.DropdownUtils.filterWithSymbol('@', item, ':');
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+ });
+
+ describe('filterHint', () => {
+ it('should filter', () => {
+ let updatedItem = gl.DropdownUtils.filterHint({
+ hint: 'label',
+ }, 'l');
+ expect(updatedItem.droplab_hidden).toBe(false);
+
+ updatedItem = gl.DropdownUtils.filterHint({
+ hint: 'label',
+ }, 'o');
+ expect(updatedItem.droplab_hidden).toBe(true);
+ });
+
+ it('should return droplab_hidden false when item has no hint', () => {
+ const updatedItem = gl.DropdownUtils.filterHint({}, '');
+ expect(updatedItem.droplab_hidden).toBe(false);
+ });
+ });
+
+ describe('setDataValueIfSelected', () => {
+ beforeEach(() => {
+ spyOn(gl.FilteredSearchDropdownManager, 'addWordToInput')
+ .and.callFake(() => {});
+ });
+
+ it('calls addWordToInput when dataValue exists', () => {
+ const selected = {
+ getAttribute: () => 'value',
+ };
+
+ gl.DropdownUtils.setDataValueIfSelected(null, selected);
+ expect(gl.FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1);
+ });
+
+ it('returns true when dataValue exists', () => {
+ const selected = {
+ getAttribute: () => 'value',
+ };
+
+ const result = gl.DropdownUtils.setDataValueIfSelected(null, selected);
+ expect(result).toBe(true);
+ });
+
+ it('returns false when dataValue does not exist', () => {
+ const selected = {
+ getAttribute: () => null,
+ };
+
+ const result = gl.DropdownUtils.setDataValueIfSelected(null, selected);
+ expect(result).toBe(false);
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6
new file mode 100644
index 00000000000..d0d27ceb4a6
--- /dev/null
+++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6
@@ -0,0 +1,59 @@
+//= require extensions/array
+//= require filtered_search/filtered_search_tokenizer
+//= require filtered_search/filtered_search_dropdown_manager
+
+(() => {
+ describe('Filtered Search Dropdown Manager', () => {
+ describe('addWordToInput', () => {
+ function getInputValue() {
+ return document.querySelector('.filtered-search').value;
+ }
+
+ function setInputValue(value) {
+ document.querySelector('.filtered-search').value = value;
+ }
+
+ beforeEach(() => {
+ const input = document.createElement('input');
+ input.classList.add('filtered-search');
+ document.body.appendChild(input);
+ });
+
+ afterEach(() => {
+ document.querySelector('.filtered-search').outerHTML = '';
+ });
+
+ describe('input has no existing value', () => {
+ it('should add just tokenName', () => {
+ gl.FilteredSearchDropdownManager.addWordToInput('milestone');
+ expect(getInputValue()).toBe('milestone:');
+ });
+
+ it('should add tokenName and tokenValue', () => {
+ gl.FilteredSearchDropdownManager.addWordToInput('label', 'none');
+ expect(getInputValue()).toBe('label:none');
+ });
+ });
+
+ describe('input has existing value', () => {
+ it('should be able to just add tokenName', () => {
+ setInputValue('a');
+ gl.FilteredSearchDropdownManager.addWordToInput('author');
+ expect(getInputValue()).toBe('author:');
+ });
+
+ it('should replace tokenValue', () => {
+ setInputValue('author:roo');
+ gl.FilteredSearchDropdownManager.addWordToInput('author', '@root');
+ expect(getInputValue()).toBe('author:@root');
+ });
+
+ it('should add tokenValues containing spaces', () => {
+ setInputValue('label:~"test');
+ gl.FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\'');
+ expect(getInputValue()).toBe('label:~\'"test me"\'');
+ });
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6
new file mode 100644
index 00000000000..6df7c0e44ef
--- /dev/null
+++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6
@@ -0,0 +1,104 @@
+//= require extensions/array
+//= require filtered_search/filtered_search_token_keys
+
+(() => {
+ describe('Filtered Search Token Keys', () => {
+ describe('get', () => {
+ let tokenKeys;
+
+ beforeEach(() => {
+ tokenKeys = gl.FilteredSearchTokenKeys.get();
+ });
+
+ it('should return tokenKeys', () => {
+ expect(tokenKeys !== null).toBe(true);
+ });
+
+ it('should return tokenKeys as an array', () => {
+ expect(tokenKeys instanceof Array).toBe(true);
+ });
+ });
+
+ describe('getConditions', () => {
+ let conditions;
+
+ beforeEach(() => {
+ conditions = gl.FilteredSearchTokenKeys.getConditions();
+ });
+
+ it('should return conditions', () => {
+ expect(conditions !== null).toBe(true);
+ });
+
+ it('should return conditions as an array', () => {
+ expect(conditions instanceof Array).toBe(true);
+ });
+ });
+
+ describe('searchByKey', () => {
+ it('should return null when key not found', () => {
+ const tokenKey = gl.FilteredSearchTokenKeys.searchByKey('notakey');
+ expect(tokenKey === null).toBe(true);
+ });
+
+ it('should return tokenKey when found by key', () => {
+ const tokenKeys = gl.FilteredSearchTokenKeys.get();
+ const result = gl.FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key);
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchBySymbol', () => {
+ it('should return null when symbol not found', () => {
+ const tokenKey = gl.FilteredSearchTokenKeys.searchBySymbol('notasymbol');
+ expect(tokenKey === null).toBe(true);
+ });
+
+ it('should return tokenKey when found by symbol', () => {
+ const tokenKeys = gl.FilteredSearchTokenKeys.get();
+ const result = gl.FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol);
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchByKeyParam', () => {
+ it('should return null when key param not found', () => {
+ const tokenKey = gl.FilteredSearchTokenKeys.searchByKeyParam('notakeyparam');
+ expect(tokenKey === null).toBe(true);
+ });
+
+ it('should return tokenKey when found by key param', () => {
+ const tokenKeys = gl.FilteredSearchTokenKeys.get();
+ const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchByConditionUrl', () => {
+ it('should return null when condition url not found', () => {
+ const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(null);
+ expect(condition === null).toBe(true);
+ });
+
+ it('should return condition when found by url', () => {
+ const conditions = gl.FilteredSearchTokenKeys.getConditions();
+ const result = gl.FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url);
+ expect(result).toBe(conditions[0]);
+ });
+ });
+
+ describe('searchByConditionKeyValue', () => {
+ it('should return null when condition tokenKey and value not found', () => {
+ const condition = gl.FilteredSearchTokenKeys.searchByConditionKeyValue(null, null);
+ expect(condition === null).toBe(true);
+ });
+
+ it('should return condition when found by tokenKey and value', () => {
+ const conditions = gl.FilteredSearchTokenKeys.getConditions();
+ const result = gl.FilteredSearchTokenKeys
+ .searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value);
+ expect(result).toEqual(conditions[0]);
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6
new file mode 100644
index 00000000000..ac7f8e9cbcd
--- /dev/null
+++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6
@@ -0,0 +1,104 @@
+//= require extensions/array
+//= require filtered_search/filtered_search_token_keys
+//= require filtered_search/filtered_search_tokenizer
+
+(() => {
+ describe('Filtered Search Tokenizer', () => {
+ describe('processTokens', () => {
+ it('returns for input containing only search value', () => {
+ const results = gl.FilteredSearchTokenizer.processTokens('searchTerm');
+ expect(results.searchToken).toBe('searchTerm');
+ expect(results.tokens.length).toBe(0);
+ expect(results.lastToken).toBe(results.searchToken);
+ });
+
+ it('returns for input containing only tokens', () => {
+ const results = gl.FilteredSearchTokenizer
+ .processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none');
+ expect(results.searchToken).toBe('');
+ expect(results.tokens.length).toBe(4);
+ expect(results.tokens[3]).toBe(results.lastToken);
+
+ expect(results.tokens[0].key).toBe('author');
+ expect(results.tokens[0].value).toBe('root');
+ expect(results.tokens[0].symbol).toBe('@');
+
+ expect(results.tokens[1].key).toBe('label');
+ expect(results.tokens[1].value).toBe('"Very Important"');
+ expect(results.tokens[1].symbol).toBe('~');
+
+ expect(results.tokens[2].key).toBe('milestone');
+ expect(results.tokens[2].value).toBe('v1.0');
+ expect(results.tokens[2].symbol).toBe('%');
+
+ expect(results.tokens[3].key).toBe('assignee');
+ expect(results.tokens[3].value).toBe('none');
+ expect(results.tokens[3].symbol).toBe('');
+ });
+
+ it('returns for input starting with search value and ending with tokens', () => {
+ const results = gl.FilteredSearchTokenizer
+ .processTokens('searchTerm anotherSearchTerm milestone:none');
+ expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
+ expect(results.tokens.length).toBe(1);
+ expect(results.tokens[0]).toBe(results.lastToken);
+ expect(results.tokens[0].key).toBe('milestone');
+ expect(results.tokens[0].value).toBe('none');
+ expect(results.tokens[0].symbol).toBe('');
+ });
+
+ it('returns for input starting with tokens and ending with search value', () => {
+ const results = gl.FilteredSearchTokenizer
+ .processTokens('assignee:@user searchTerm');
+
+ expect(results.searchToken).toBe('searchTerm');
+ expect(results.tokens.length).toBe(1);
+ expect(results.tokens[0].key).toBe('assignee');
+ expect(results.tokens[0].value).toBe('user');
+ expect(results.tokens[0].symbol).toBe('@');
+ expect(results.lastToken).toBe(results.searchToken);
+ });
+
+ it('returns for input containing search value wrapped between tokens', () => {
+ const results = gl.FilteredSearchTokenizer
+ .processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none');
+
+ expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
+ expect(results.tokens.length).toBe(3);
+ expect(results.tokens[2]).toBe(results.lastToken);
+
+ expect(results.tokens[0].key).toBe('author');
+ expect(results.tokens[0].value).toBe('root');
+ expect(results.tokens[0].symbol).toBe('@');
+
+ expect(results.tokens[1].key).toBe('label');
+ expect(results.tokens[1].value).toBe('"Won\'t fix"');
+ expect(results.tokens[1].symbol).toBe('~');
+
+ expect(results.tokens[2].key).toBe('milestone');
+ expect(results.tokens[2].value).toBe('none');
+ expect(results.tokens[2].symbol).toBe('');
+ });
+
+ it('returns for input containing search value in between tokens', () => {
+ const results = gl.FilteredSearchTokenizer
+ .processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing');
+ expect(results.searchToken).toBe('searchTerm anotherSearchTerm');
+ expect(results.tokens.length).toBe(3);
+ expect(results.tokens[2]).toBe(results.lastToken);
+
+ expect(results.tokens[0].key).toBe('author');
+ expect(results.tokens[0].value).toBe('root');
+ expect(results.tokens[0].symbol).toBe('@');
+
+ expect(results.tokens[1].key).toBe('assignee');
+ expect(results.tokens[1].value).toBe('none');
+ expect(results.tokens[1].symbol).toBe('');
+
+ expect(results.tokens[2].key).toBe('label');
+ expect(results.tokens[2].value).toBe('Doing');
+ expect(results.tokens[2].symbol).toBe('~');
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/lib/utils/common_utils_spec.js.es6 b/spec/javascripts/lib/utils/common_utils_spec.js.es6
index ef75f600898..031f9ca03c9 100644
--- a/spec/javascripts/lib/utils/common_utils_spec.js.es6
+++ b/spec/javascripts/lib/utils/common_utils_spec.js.es6
@@ -15,6 +15,7 @@
expect(gl.utils.parseUrl('" test="asf"').pathname).toEqual('/teaspoon/%22%20test=%22asf%22');
});
});
+
describe('gl.utils.parseUrlPathname', () => {
beforeEach(() => {
spyOn(gl.utils, 'parseUrl').and.callFake(url => ({
@@ -28,5 +29,28 @@
expect(gl.utils.parseUrlPathname('some/relative/url')).toEqual('/some/relative/url');
});
});
+
+ describe('gl.utils.getUrlParamsArray', () => {
+ it('should return params array', () => {
+ expect(gl.utils.getUrlParamsArray() instanceof Array).toBe(true);
+ });
+
+ it('should remove the question mark from the search params', () => {
+ const paramsArray = gl.utils.getUrlParamsArray();
+ expect(paramsArray[0][0] !== '?').toBe(true);
+ });
+ });
+
+ describe('gl.utils.getParameterByName', () => {
+ it('should return valid parameter', () => {
+ const value = gl.utils.getParameterByName('reporter');
+ expect(value).toBe('Console');
+ });
+
+ it('should return invalid parameter', () => {
+ const value = gl.utils.getParameterByName('fakeParameter');
+ expect(value).toBe(null);
+ });
+ });
});
})();
diff --git a/spec/javascripts/lib/utils/text_utility_spec.js.es6 b/spec/javascripts/lib/utils/text_utility_spec.js.es6
new file mode 100644
index 00000000000..e97356b65d5
--- /dev/null
+++ b/spec/javascripts/lib/utils/text_utility_spec.js.es6
@@ -0,0 +1,25 @@
+//= require lib/utils/text_utility
+
+(() => {
+ describe('text_utility', () => {
+ describe('gl.text.getTextWidth', () => {
+ it('returns zero width when no text is passed', () => {
+ expect(gl.text.getTextWidth('')).toBe(0);
+ });
+
+ it('returns zero width when no text is passed and font is passed', () => {
+ expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0);
+ });
+
+ it('returns width when text is passed', () => {
+ expect(gl.text.getTextWidth('foo') > 0).toBe(true);
+ });
+
+ it('returns bigger width when font is larger', () => {
+ const largeFont = gl.text.getTextWidth('foo', '100px sans-serif');
+ const regular = gl.text.getTextWidth('foo', '10px sans-serif');
+ expect(largeFont > regular).toBe(true);
+ });
+ });
+ });
+})();
diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js
index e13c4ad772c..2d3f44e7980 100644
--- a/spec/javascripts/search_autocomplete_spec.js
+++ b/spec/javascripts/search_autocomplete_spec.js
@@ -11,6 +11,7 @@
(function() {
var addBodyAttributes, assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget;
+ var userName = 'root';
widget = null;
@@ -19,6 +20,7 @@
window.gon || (window.gon = {});
window.gon.current_user_id = userId;
+ window.gon.current_username = userName;
dashboardIssuesPath = '/dashboard/issues';
@@ -93,8 +95,8 @@
assertLinks = function(list, issuesPath, mrsPath) {
var a1, a2, a3, a4, issuesAssignedToMeLink, issuesIHaveCreatedLink, mrsAssignedToMeLink, mrsIHaveCreatedLink;
- issuesAssignedToMeLink = issuesPath + "/?assignee_id=" + userId;
- issuesIHaveCreatedLink = issuesPath + "/?author_id=" + userId;
+ issuesAssignedToMeLink = issuesPath + "/?assignee_username=" + userName;
+ issuesIHaveCreatedLink = issuesPath + "/?author_username=" + userName;
mrsAssignedToMeLink = mrsPath + "/?assignee_id=" + userId;
mrsIHaveCreatedLink = mrsPath + "/?author_id=" + userId;
a1 = "a[href='" + issuesAssignedToMeLink + "']";