summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile25
-rw-r--r--Gemfile.lock3
-rw-r--r--app/assets/javascripts/application.js1
-rw-r--r--app/assets/javascripts/build.js15
-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/layout.scss15
-rw-r--r--app/assets/stylesheets/framework/mobile.scss6
-rw-r--r--app/assets/stylesheets/framework/variables.scss5
-rw-r--r--app/assets/stylesheets/pages/issuable.scss49
-rw-r--r--app/controllers/admin/application_settings_controller.rb99
-rw-r--r--app/controllers/admin/groups_controller.rb8
-rw-r--r--app/controllers/admin/users_controller.rb41
-rw-r--r--app/finders/issuable_finder.rb54
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/models/application_setting.rb6
-rw-r--r--app/models/concerns/valid_attribute.rb10
-rw-r--r--app/models/project.rb20
-rw-r--r--app/views/admin/application_settings/_form.html.haml17
-rw-r--r--app/views/projects/issues/index.html.haml6
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/_show.html.haml4
-rw-r--r--app/views/shared/_no_password.html.haml4
-rw-r--r--app/views/shared/_no_ssh.html.haml4
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml127
-rw-r--r--changelogs/unreleased/25776-alerts-should-be-responsive.yml4
-rw-r--r--changelogs/unreleased/26109-preserve-scroll-position-on-autoreload.yml4
-rw-r--r--changelogs/unreleased/asciidoctor-plantuml.yml4
-rw-r--r--changelogs/unreleased/fix-project-delete-tooltip.yml4
-rw-r--r--changelogs/unreleased/fix-user-api-confirm-param.yml4
-rw-r--r--config/application.rb1
-rw-r--r--db/migrate/20161201001911_add_plant_uml_url_to_application_settings.rb12
-rw-r--r--db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb12
-rw-r--r--db/schema.rb2
-rw-r--r--doc/administration/img/integration/plantuml-example.pngbin0 -> 33034 bytes
-rw-r--r--doc/administration/integration/plantuml.md87
-rw-r--r--doc/api/settings.md10
-rw-r--r--doc/integration/README.md1
-rw-r--r--doc/raketasks/backup_restore.md4
-rw-r--r--features/project/issues/filter_labels.feature28
-rw-r--r--features/project/issues/issues.feature56
-rw-r--r--lib/api/entities.rb2
-rw-r--r--lib/api/settings.rb6
-rw-r--r--lib/api/users.rb2
-rw-r--r--lib/gitlab/asciidoc.rb12
-rw-r--r--lib/gitlab/current_settings.rb1
-rw-r--r--lib/gitlab/gon_helper.rb1
-rw-r--r--lib/tasks/gitlab/update_templates.rake2
-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
-rw-r--r--spec/lib/gitlab/asciidoc_spec.rb4
-rw-r--r--spec/models/project_spec.rb30
-rw-r--r--spec/requests/api/settings_spec.rb16
-rw-r--r--spec/requests/api/users_spec.rb9
89 files changed, 4987 insertions, 475 deletions
diff --git a/Gemfile b/Gemfile
index a8141abafc2..b43604bd253 100644
--- a/Gemfile
+++ b/Gemfile
@@ -99,18 +99,19 @@ gem 'unf', '~> 0.1.4'
gem 'seed-fu', '~> 2.3.5'
# Markdown and HTML processing
-gem 'html-pipeline', '~> 1.11.0'
-gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie'
-gem 'gitlab-markup', '~> 1.5.1'
-gem 'redcarpet', '~> 3.3.3'
-gem 'RedCloth', '~> 4.3.2'
-gem 'rdoc', '~> 4.2'
-gem 'org-ruby', '~> 0.9.12'
-gem 'creole', '~> 0.5.0'
-gem 'wikicloth', '0.8.1'
-gem 'asciidoctor', '~> 1.5.2'
-gem 'rouge', '~> 2.0'
-gem 'truncato', '~> 0.7.8'
+gem 'html-pipeline', '~> 1.11.0'
+gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie'
+gem 'gitlab-markup', '~> 1.5.0'
+gem 'redcarpet', '~> 3.3.3'
+gem 'RedCloth', '~> 4.3.2'
+gem 'rdoc', '~> 4.2'
+gem 'org-ruby', '~> 0.9.12'
+gem 'creole', '~> 0.5.0'
+gem 'wikicloth', '0.8.1'
+gem 'asciidoctor', '~> 1.5.2'
+gem 'asciidoctor-plantuml', '0.0.6'
+gem 'rouge', '~> 2.0'
+gem 'truncato', '~> 0.7.8'
# See https://groups.google.com/forum/#!topic/ruby-security-ann/aSbgDiwb24s
# and https://groups.google.com/forum/#!topic/ruby-security-ann/Dy7YiKb_pMM
diff --git a/Gemfile.lock b/Gemfile.lock
index bdd591e008a..e2d7f94e571 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -54,6 +54,8 @@ GEM
faraday_middleware-multi_json (~> 0.0)
oauth2 (~> 1.0)
asciidoctor (1.5.3)
+ asciidoctor-plantuml (0.0.6)
+ asciidoctor (~> 1.5)
ast (2.3.0)
attr_encrypted (3.0.3)
encryptor (~> 3.0.0)
@@ -841,6 +843,7 @@ DEPENDENCIES
allocations (~> 1.0)
asana (~> 0.4.0)
asciidoctor (~> 1.5.2)
+ asciidoctor-plantuml (= 0.0.6)
attr_encrypted (~> 3.0.0)
awesome_print (~> 1.2.0)
babosa (~> 1.0.2)
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/build.js b/app/assets/javascripts/build.js
index bc13c46443a..fca47002870 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -5,6 +5,7 @@
(function() {
var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
var AUTO_SCROLL_OFFSET = 75;
+ var DOWN_BUILD_TRACE = '#down-build-trace';
this.Build = (function() {
Build.interval = null;
@@ -26,7 +27,7 @@
this.$autoScrollStatus = $('#autoscroll-status');
this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text');
this.$upBuildTrace = $('#up-build-trace');
- this.$downBuildTrace = $('#down-build-trace');
+ this.$downBuildTrace = $(DOWN_BUILD_TRACE);
this.$scrollTopBtn = $('#scroll-top');
this.$scrollBottomBtn = $('#scroll-bottom');
this.$buildRefreshAnimation = $('.js-build-refresh');
@@ -91,6 +92,9 @@
dataType: 'json',
success: function(buildData) {
$('.js-build-output').html(buildData.trace_html);
+ if (window.location.hash === DOWN_BUILD_TRACE) {
+ $("html,body").scrollTop(this.$buildTrace.height());
+ }
if (removeRefreshStatuses.indexOf(buildData.status) >= 0) {
this.$buildRefreshAnimation.remove();
return this.initScrollMonitor();
@@ -105,6 +109,8 @@
dataType: "json",
success: (function(_this) {
return function(log) {
+ var pageUrl;
+
if (log.state) {
_this.state = log.state;
}
@@ -116,7 +122,12 @@
}
return _this.checkAutoscroll();
} else if (log.status !== _this.buildStatus) {
- return Turbolinks.visit(_this.pageUrl);
+ pageUrl = _this.pageUrl;
+ if (_this.$autoScrollStatus.data('state') === 'enabled') {
+ pageUrl += DOWN_BUILD_TRACE;
+ }
+
+ return Turbolinks.visit(pageUrl);
}
};
})(this)
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/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 5365b62e456..29d55c44699 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -41,6 +41,21 @@ body {
}
}
+ .alert-link-group {
+ float: right;
+ }
+
+ /* Center alert text and alert action links on smaller screens */
+ @media (max-width: $screen-sm-max) {
+ .alert {
+ text-align: center;
+ }
+
+ .alert-link-group {
+ float: none;
+ }
+ }
+
/* Stripe the background colors so that adjacent alert-warnings are distinct from one another */
.alert-warning {
transition: background-color 0.15s, border-color 0.15s;
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/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 0a8c037c402..3272a862b85 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -1,3 +1,52 @@
+// Limit MR description for side-by-side diff view
+.fixed-width-container {
+ max-width: $limited-layout-width - ($gl-padding * 2);
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.limit-container-width {
+ .detail-page-header {
+ @extend .fixed-width-container;
+ }
+
+ .issuable-details {
+ .detail-page-description,
+ .mr-source-target,
+ .mr-state-widget,
+ .merge-manually {
+ @extend .fixed-width-container;
+ }
+
+ .merge-request-tabs-holder {
+ &.affix {
+ border-bottom: 1px solid $border-color;
+
+ .nav-links {
+ border: 0;
+ }
+ }
+
+ .container-fluid {
+ @extend .fixed-width-container;
+ }
+ }
+ }
+
+ .merge-request-details {
+ .emoji-list-container {
+ @extend .fixed-width-container;
+ }
+ }
+
+ .diffs {
+ .mr-version-controls,
+ .files-changed {
+ @extend .fixed-width-container;
+ }
+ }
+}
+
.issuable-details {
section {
.issuable-discussion {
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index c2bb8464824..1b4987dd738 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -67,69 +67,78 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file]
params.require(:application_setting).permit(
- :default_projects_limit,
- :default_branch_protection,
- :signup_enabled,
- :signin_enabled,
- :require_two_factor_authentication,
- :two_factor_grace_period,
- :gravatar_enabled,
- :sign_in_text,
- :after_sign_up_text,
- :help_page_text,
- :home_page_url,
+ application_setting_params_ce
+ )
+ end
+
+ def application_setting_params_ce
+ [
+ :admin_notification_email,
:after_sign_out_path,
- :max_attachment_size,
- :session_expire_delay,
+ :after_sign_up_text,
+ :akismet_api_key,
+ :akismet_enabled,
+ :container_registry_token_expire_delay,
+ :default_branch_protection,
+ :default_group_visibility,
:default_project_visibility,
+ :default_projects_limit,
:default_snippet_visibility,
- :default_group_visibility,
- :domain_whitelist_raw,
:domain_blacklist_enabled,
- :domain_blacklist_raw,
:domain_blacklist_file,
- :version_check_enabled,
- :admin_notification_email,
- :user_oauth_applications,
- :user_default_external,
- :shared_runners_enabled,
- :shared_runners_text,
+ :domain_blacklist_raw,
+ :domain_whitelist_raw,
+ :email_author_in_body,
+ :enabled_git_access_protocol,
+ :gravatar_enabled,
+ :help_page_text,
+ :home_page_url,
+ :housekeeping_bitmaps_enabled,
+ :housekeeping_enabled,
+ :housekeeping_full_repack_period,
+ :housekeeping_gc_period,
+ :housekeeping_incremental_repack_period,
+ :html_emails_enabled,
+ :koding_enabled,
+ :koding_url,
+ :plantuml_enabled,
+ :plantuml_url,
:max_artifacts_size,
+ :max_attachment_size,
:metrics_enabled,
:metrics_host,
- :metrics_port,
- :metrics_pool_size,
- :metrics_timeout,
:metrics_method_call_threshold,
+ :metrics_packet_size,
+ :metrics_pool_size,
+ :metrics_port,
:metrics_sample_interval,
+ :metrics_timeout,
:recaptcha_enabled,
- :recaptcha_site_key,
:recaptcha_private_key,
- :sentry_enabled,
- :sentry_dsn,
- :akismet_enabled,
- :akismet_api_key,
- :koding_enabled,
- :koding_url,
- :email_author_in_body,
- :html_emails_enabled,
+ :recaptcha_site_key,
:repository_checks_enabled,
- :metrics_packet_size,
+ :require_two_factor_authentication,
+ :session_expire_delay,
+ :sign_in_text,
+ :signin_enabled,
+ :signup_enabled,
+ :sentry_dsn,
+ :sentry_enabled,
:send_user_confirmation_email,
- :container_registry_token_expire_delay,
- :enabled_git_access_protocol,
+ :shared_runners_enabled,
+ :shared_runners_text,
:sidekiq_throttling_enabled,
:sidekiq_throttling_factor,
- :housekeeping_enabled,
- :housekeeping_bitmaps_enabled,
- :housekeeping_incremental_repack_period,
- :housekeeping_full_repack_period,
- :housekeeping_gc_period,
+ :two_factor_grace_period,
+ :user_default_external,
+ :user_oauth_applications,
+ :version_check_enabled,
+
+ disabled_oauth_sign_in_sources: [],
+ import_sources: [],
repository_storages: [],
restricted_visibility_levels: [],
- import_sources: [],
- disabled_oauth_sign_in_sources: [],
sidekiq_throttling_queues: []
- )
+ ]
end
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index add1c819adf..b7722a1d15d 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -61,7 +61,11 @@ class Admin::GroupsController < Admin::ApplicationController
end
def group_params
- params.require(:group).permit(
+ params.require(:group).permit(group_params_ce)
+ end
+
+ def group_params_ce
+ [
:avatar,
:description,
:lfs_enabled,
@@ -69,6 +73,6 @@ class Admin::GroupsController < Admin::ApplicationController
:path,
:request_access_enabled,
:visibility_level
- )
+ ]
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index df9039b16b2..aa0f8d434dc 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -161,15 +161,6 @@ class Admin::UsersController < Admin::ApplicationController
@user ||= User.find_by!(username: params[:id])
end
- def user_params
- params.require(:user).permit(
- :email, :remember_me, :bio, :name, :username,
- :skype, :linkedin, :twitter, :website_url, :color_scheme_id, :theme_id, :force_random_password,
- :extern_uid, :provider, :password_expires_at, :avatar, :hide_no_ssh_key, :hide_no_password,
- :projects_limit, :can_create_group, :admin, :key_id, :external
- )
- end
-
def redirect_back_or_admin_user(options = {})
redirect_back_or_default(default: default_route, options: options)
end
@@ -177,4 +168,36 @@ class Admin::UsersController < Admin::ApplicationController
def default_route
[:admin, @user]
end
+
+ def user_params
+ params.require(:user).permit(user_params_ce)
+ end
+
+ def user_params_ce
+ [
+ :admin,
+ :avatar,
+ :bio,
+ :can_create_group,
+ :color_scheme_id,
+ :email,
+ :extern_uid,
+ :external,
+ :force_random_password,
+ :hide_no_password,
+ :hide_no_ssh_key,
+ :key_id,
+ :linkedin,
+ :name,
+ :password_expires_at,
+ :projects_limit,
+ :provider,
+ :remember_me,
+ :skype,
+ :theme_id,
+ :twitter,
+ :username,
+ :website_url
+ ]
+ end
end
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/models/application_setting.rb b/app/models/application_setting.rb
index bf463a3b6bb..8fab77cda0a 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -68,6 +68,10 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
if: :koding_enabled
+ validates :plantuml_url,
+ presence: true,
+ if: :plantuml_enabled
+
validates :max_attachment_size,
presence: true,
numericality: { only_integer: true, greater_than: 0 }
@@ -184,6 +188,8 @@ class ApplicationSetting < ActiveRecord::Base
akismet_enabled: false,
koding_enabled: false,
koding_url: nil,
+ plantuml_enabled: false,
+ plantuml_url: nil,
repository_checks_enabled: true,
disabled_oauth_sign_in_sources: [],
send_user_confirmation_email: false,
diff --git a/app/models/concerns/valid_attribute.rb b/app/models/concerns/valid_attribute.rb
new file mode 100644
index 00000000000..8c35cea8d58
--- /dev/null
+++ b/app/models/concerns/valid_attribute.rb
@@ -0,0 +1,10 @@
+module ValidAttribute
+ extend ActiveSupport::Concern
+
+ # Checks whether an attribute has failed validation or not
+ #
+ # +attribute+ The symbolised name of the attribute i.e :name
+ def valid_attribute?(attribute)
+ self.errors.empty? || self.errors.messages[attribute].nil?
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 94a6f3ba799..c22386c84e9 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -12,6 +12,7 @@ class Project < ActiveRecord::Base
include AfterCommitQueue
include CaseSensitivity
include TokenAuthenticatable
+ include ValidAttribute
include ProjectFeaturesCompatibility
include SelectForProjectAuthorization
include Routable
@@ -65,6 +66,8 @@ class Project < ActiveRecord::Base
end
end
+ after_validation :check_pending_delete
+
ActsAsTaggableOn.strict_case_match = true
acts_as_taggable_on :tags
@@ -1320,4 +1323,21 @@ class Project < ActiveRecord::Base
stats = statistics || build_statistics
stats.update(namespace_id: namespace_id)
end
+
+ def check_pending_delete
+ return if valid_attribute?(:name) && valid_attribute?(:path)
+ return unless pending_delete_twin
+
+ %i[route route.path name path].each do |error|
+ errors.delete(error)
+ end
+
+ errors.add(:base, "The project is still being deleted. Please try again later.")
+ end
+
+ def pending_delete_twin
+ return false unless path
+
+ Project.unscoped.where(pending_delete: true).find_with_namespace(path_with_namespace)
+ end
end
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index 4612a7a058a..558bbe07b16 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -421,6 +421,23 @@
= link_to "Koding administration documentation", help_page_path("administration/integration/koding")
%fieldset
+ %legend PlantUML
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :plantuml_enabled do
+ = f.check_box :plantuml_enabled
+ Enable PlantUML
+ .form-group
+ = f.label :plantuml_url, 'PlantUML URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080'
+ .help-block
+ Allow rendering of
+ = link_to "PlantUML", "http://plantuml.com"
+ diagrams in Asciidoc documents using an external PlantUML service.
+
+ %fieldset
%legend Usage statistics
.form-group
.col-sm-offset-2.col-sm-10
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/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 981bf640a6b..43141971231 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -1,4 +1,4 @@
-- @content_class = "limit-container-width"
+- @content_class = "limit-container-width" unless fluid_layout
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description
- page_card_attributes @issue.card_attributes
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
index 1f63803c24e..110dd11d1ce 100644
--- a/app/views/projects/merge_requests/_show.html.haml
+++ b/app/views/projects/merge_requests/_show.html.haml
@@ -1,4 +1,4 @@
-- @content_class = "limit-container-width"
+- @content_class = "limit-container-width" unless fluid_layout
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
- page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes
@@ -47,7 +47,7 @@
= succeed '.' do
= link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal"
- .content-block.content-block-small
+ .content-block.content-block-small.emoji-list-container
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml
index a43bf33751a..ed6fc76c61e 100644
--- a/app/views/shared/_no_password.html.haml
+++ b/app/views/shared/_no_password.html.haml
@@ -1,8 +1,8 @@
- if cookies[:hide_no_password_message].blank? && !current_user.hide_no_password && current_user.require_password?
- .no-password-message.alert.alert-warning.hidden-xs
+ .no-password-message.alert.alert-warning
You won't be able to pull or push project code via #{gitlab_config.protocol.upcase} until you #{link_to 'set a password', edit_profile_password_path} on your account
- .pull-right
+ .alert-link-group
= link_to "Don't show again", profile_path(user: {hide_no_password: true}), method: :put
|
= link_to 'Remind later', '#', class: 'hide-no-password-message'
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index bb5fff2d3bb..d663fa13d10 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -1,8 +1,8 @@
- if cookies[:hide_no_ssh_message].blank? && !current_user.hide_no_ssh_key && current_user.require_ssh_key?
- .no-ssh-key-message.alert.alert-warning.hidden-xs
+ .no-ssh-key-message.alert.alert-warning
You won't be able to pull or push project code via SSH until you #{link_to 'add an SSH key', profile_keys_path, class: 'alert-link'} to your profile
- .pull-right
+ .alert-link-group
= link_to "Don't show again", profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link'
|
= link_to 'Remind later', '#', class: 'hide-no-ssh-message alert-link'
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/changelogs/unreleased/25776-alerts-should-be-responsive.yml b/changelogs/unreleased/25776-alerts-should-be-responsive.yml
new file mode 100644
index 00000000000..15006523d3e
--- /dev/null
+++ b/changelogs/unreleased/25776-alerts-should-be-responsive.yml
@@ -0,0 +1,4 @@
+---
+title: Changed alerts to be responsive, centered text on smaller viewports
+merge_request: 8424
+author: Connor Smallman
diff --git a/changelogs/unreleased/26109-preserve-scroll-position-on-autoreload.yml b/changelogs/unreleased/26109-preserve-scroll-position-on-autoreload.yml
new file mode 100644
index 00000000000..cde0d114d7c
--- /dev/null
+++ b/changelogs/unreleased/26109-preserve-scroll-position-on-autoreload.yml
@@ -0,0 +1,4 @@
+---
+title: Scroll to bottom on build completion if autoscroll was active
+merge_request: 8391
+author:
diff --git a/changelogs/unreleased/asciidoctor-plantuml.yml b/changelogs/unreleased/asciidoctor-plantuml.yml
new file mode 100644
index 00000000000..ba6ef7c0800
--- /dev/null
+++ b/changelogs/unreleased/asciidoctor-plantuml.yml
@@ -0,0 +1,4 @@
+---
+title: Add support for PlantUML diagrams in AsciiDoc documents.
+merge_request: 7810
+author: Horacio Sanson
diff --git a/changelogs/unreleased/fix-project-delete-tooltip.yml b/changelogs/unreleased/fix-project-delete-tooltip.yml
new file mode 100644
index 00000000000..42fd9c32519
--- /dev/null
+++ b/changelogs/unreleased/fix-project-delete-tooltip.yml
@@ -0,0 +1,4 @@
+---
+title: Fix project queued for deletion re-creation tooltip
+merge_request:
+author:
diff --git a/changelogs/unreleased/fix-user-api-confirm-param.yml b/changelogs/unreleased/fix-user-api-confirm-param.yml
new file mode 100644
index 00000000000..42642576634
--- /dev/null
+++ b/changelogs/unreleased/fix-user-api-confirm-param.yml
@@ -0,0 +1,4 @@
+---
+title: Fix 500 error when POSTing to Users API with optional confirm param
+merge_request:
+author:
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/db/migrate/20161201001911_add_plant_uml_url_to_application_settings.rb b/db/migrate/20161201001911_add_plant_uml_url_to_application_settings.rb
new file mode 100644
index 00000000000..b8d8742ae40
--- /dev/null
+++ b/db/migrate/20161201001911_add_plant_uml_url_to_application_settings.rb
@@ -0,0 +1,12 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddPlantUmlUrlToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :plantuml_url, :string
+ end
+end
diff --git a/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb b/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb
new file mode 100644
index 00000000000..3677f978cc2
--- /dev/null
+++ b/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb
@@ -0,0 +1,12 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddPlantUmlEnabledToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :plantuml_enabled, :boolean
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f3bf7ced393..c58a886b0fa 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -107,6 +107,8 @@ ActiveRecord::Schema.define(version: 20170106172224) do
t.integer "housekeeping_full_repack_period", default: 50, null: false
t.integer "housekeeping_gc_period", default: 200, null: false
t.boolean "html_emails_enabled", default: true
+ t.string "plantuml_url"
+ t.boolean "plantuml_enabled"
end
create_table "audit_events", force: :cascade do |t|
diff --git a/doc/administration/img/integration/plantuml-example.png b/doc/administration/img/integration/plantuml-example.png
new file mode 100644
index 00000000000..cb64eca1a8a
--- /dev/null
+++ b/doc/administration/img/integration/plantuml-example.png
Binary files differ
diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md
new file mode 100644
index 00000000000..e5cf592e0a6
--- /dev/null
+++ b/doc/administration/integration/plantuml.md
@@ -0,0 +1,87 @@
+# PlantUML & GitLab
+
+> [Introduced][ce-7810] in GitLab 8.16.
+
+When [PlantUML](http://plantuml.com) integration is enabled and configured in
+GitLab we are able to create simple diagrams in AsciiDoc documents created in
+snippets, wikis, and repos.
+
+## PlantUML Server
+
+Before you can enable PlantUML in GitLab; you need to set up your own PlantUML
+server that will generate the diagrams. Installing and configuring your
+own PlantUML server is easy in Debian/Ubuntu distributions using Tomcat.
+
+First you need to create a `plantuml.war` file from the source code:
+
+```
+sudo apt-get install graphviz openjdk-7-jdk git-core maven
+git clone https://github.com/plantuml/plantuml-server.git
+cd plantuml-server
+mvn package
+```
+
+The above sequence of commands will generate a WAR file that can be deployed
+using Tomcat:
+
+```
+sudo apt-get install tomcat7
+sudo cp target/plantuml.war /var/lib/tomcat7/webapps/plantuml.war
+sudo chown tomcat7:tomcat7 /var/lib/tomcat7/webapps/plantuml.war
+sudo service restart tomcat7
+```
+
+Once the Tomcat service restarts the PlantUML service will be ready and
+listening for requests on port 8080:
+
+```
+http://localhost:8080/plantuml
+```
+
+you can change these defaults by editing the `/etc/tomcat7/server.xml` file.
+
+
+## GitLab
+
+You need to enable PlantUML integration from Settings under Admin Area. To do
+that, login with an Admin account and do following:
+
+ - in GitLab go to **Admin Area** and then **Settings**
+ - scroll to bottom of the page until PlantUML section
+ - check **Enable PlantUML** checkbox
+ - set the PlantUML instance as **PlantUML URL**
+
+## Creating Diagrams
+
+With PlantUML integration enabled and configured, we can start adding diagrams to
+our AsciiDoc snippets, wikis and repos using blocks:
+
+```
+[plantuml, format="png", id="myDiagram", width="200px"]
+--
+Bob->Alice : hello
+Alice -> Bob : Go Away
+--
+```
+
+The above block will be converted to an HTML img tag with source pointing to the
+PlantUML instance. If the PlantUML server is correctly configured, this should
+render a nice diagram instead of the block:
+
+![PlantUML Integration](../img/integration/plantuml-example.png)
+
+Inside the block you can add any of the supported diagrams by PlantUML such as
+[Sequence](http://plantuml.com/sequence-diagram), [Use Case](http://plantuml.com/use-case-diagram),
+[Class](http://plantuml.com/class-diagram), [Activity](http://plantuml.com/activity-diagram-legacy),
+[Component](http://plantuml.com/component-diagram), [State](http://plantuml.com/state-diagram),
+and [Object](http://plantuml.com/object-diagram) diagrams. You do not need to use the PlantUML
+diagram delimiters `@startuml`/`@enduml` as these are replaced by the AsciiDoc `plantuml` block.
+
+Some parameters can be added to the block definition:
+
+ - *format*: Can be either `png` or `svg`. Note that `svg` is not supported by
+ all browsers so use with care. The default is `png`.
+ - *id*: A CSS id added to the diagram HTML tag.
+ - *width*: Width attribute added to the img tag.
+ - *height*: Height attribute added to the img tag.
+
diff --git a/doc/api/settings.md b/doc/api/settings.md
index 0bd38a6e664..f86c7cc2f94 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -44,7 +44,9 @@ Example response:
"repository_storage": "default",
"repository_storages": ["default"],
"koding_enabled": false,
- "koding_url": null
+ "koding_url": null,
+ "plantuml_enabled": false,
+ "plantuml_url": null
}
```
@@ -80,6 +82,8 @@ PUT /application/settings
| `koding_enabled` | boolean | no | Enable Koding integration. Default is `false`. |
| `koding_url` | string | yes (if `koding_enabled` is `true`) | The Koding instance URL for integration. |
| `disabled_oauth_sign_in_sources` | Array of strings | no | Disabled OAuth sign-in sources |
+| `plantuml_enabled` | boolean | no | Enable PlantUML integration. Default is `false`. |
+| `plantuml_url` | string | yes (if `plantuml_enabled` is `true`) | The PlantUML instance URL for integration. |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/application/settings?signup_enabled=false&default_project_visibility=1
@@ -112,6 +116,8 @@ Example response:
"container_registry_token_expire_delay": 5,
"repository_storage": "default",
"koding_enabled": false,
- "koding_url": null
+ "koding_url": null,
+ "plantuml_enabled": false,
+ "plantuml_url": null
}
```
diff --git a/doc/integration/README.md b/doc/integration/README.md
index ed843c0bfa9..e97430feb57 100644
--- a/doc/integration/README.md
+++ b/doc/integration/README.md
@@ -16,6 +16,7 @@ See the documentation below for details on how to configure these services.
- [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users
- [Akismet](akismet.md) Configure Akismet to stop spam
- [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration
+- [PlantUML](../administration/integration/plantuml.md) Configure PlantUML to use diagrams in AsciiDoc documents.
GitLab Enterprise Edition contains [advanced Jenkins support][jenkins].
diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md
index 51f7454610d..f6b4db71b44 100644
--- a/doc/raketasks/backup_restore.md
+++ b/doc/raketasks/backup_restore.md
@@ -94,7 +94,7 @@ In the example below we use Amazon S3 for storage, but Fog also lets you use
for AWS, Google, OpenStack Swift and Rackspace as well. A local driver is
[also available](#uploading-to-locally-mounted-shares).
-For omnibus packages:
+For omnibus packages, add the following to `/etc/gitlab/gitlab.rb`:
```ruby
gitlab_rails['backup_upload_connection'] = {
@@ -109,6 +109,8 @@ gitlab_rails['backup_upload_connection'] = {
gitlab_rails['backup_upload_remote_directory'] = 'my.s3.bucket'
```
+Make sure to run `sudo gitlab-ctl reconfigure` after editing `/etc/gitlab/gitlab.rb` to reflect the changes.
+
For installations from source:
```yaml
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/api/entities.rb b/lib/api/entities.rb
index d2fadf6a3d0..885ce7d44bc 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -565,6 +565,8 @@ module API
expose :repository_storages
expose :koding_enabled
expose :koding_url
+ expose :plantuml_enabled
+ expose :plantuml_url
end
class Release < Grape::Entity
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index 9eb9a105bde..c5eff16a5de 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -93,6 +93,10 @@ module API
given koding_enabled: ->(val) { val } do
requires :koding_url, type: String, desc: 'The Koding team URL'
end
+ optional :plantuml_enabled, type: Boolean, desc: 'Enable PlantUML'
+ given plantuml_enabled: ->(val) { val } do
+ requires :plantuml_url, type: String, desc: 'The PlantUML server URL'
+ end
optional :version_check_enabled, type: Boolean, desc: 'Let GitLab inform you when an update is available.'
optional :email_author_in_body, type: Boolean, desc: 'Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead.'
optional :html_emails_enabled, type: Boolean, desc: 'By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format.'
@@ -114,7 +118,7 @@ module API
:shared_runners_enabled, :max_artifacts_size, :container_registry_token_expire_delay,
:metrics_enabled, :sidekiq_throttling_enabled, :recaptcha_enabled,
:akismet_enabled, :admin_notification_email, :sentry_enabled,
- :repository_storage, :repository_checks_enabled, :koding_enabled,
+ :repository_storage, :repository_checks_enabled, :koding_enabled, :plantuml_enabled,
:version_check_enabled, :email_author_in_body, :html_emails_enabled,
:housekeeping_enabled
end
diff --git a/lib/api/users.rb b/lib/api/users.rb
index 0db76ec7877..11a7368b4c0 100644
--- a/lib/api/users.rb
+++ b/lib/api/users.rb
@@ -93,7 +93,7 @@ module API
# Filter out params which are used later
user_params = declared_params(include_missing: false)
identity_attrs = user_params.slice(:provider, :extern_uid)
- confirm = params.delete(:confirm)
+ confirm = user_params.delete(:confirm)
user = User.new(user_params.except(:extern_uid, :provider))
user.skip_confirmation! unless confirm
diff --git a/lib/gitlab/asciidoc.rb b/lib/gitlab/asciidoc.rb
index fa234284361..0618107e2c3 100644
--- a/lib/gitlab/asciidoc.rb
+++ b/lib/gitlab/asciidoc.rb
@@ -1,5 +1,6 @@
require 'asciidoctor'
require 'asciidoctor/converter/html5'
+require "asciidoctor-plantuml"
module Gitlab
# Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters
@@ -29,6 +30,8 @@ module Gitlab
)
asciidoc_opts[:attributes].unshift(*DEFAULT_ADOC_ATTRS)
+ plantuml_setup
+
html = ::Asciidoctor.convert(input, asciidoc_opts)
html = Banzai.post_process(html, context)
@@ -36,6 +39,15 @@ module Gitlab
html.html_safe
end
+ def self.plantuml_setup
+ Asciidoctor::PlantUml.configure do |conf|
+ conf.url = ApplicationSetting.current.plantuml_url
+ conf.svg_enable = ApplicationSetting.current.plantuml_enabled
+ conf.png_enable = ApplicationSetting.current.plantuml_enabled
+ conf.txt_enable = false
+ end
+ end
+
class Html5Converter < Asciidoctor::Converter::Html5Converter
extend Asciidoctor::Converter::Config
diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb
index 9d142f1b82e..2ff27e46d64 100644
--- a/lib/gitlab/current_settings.rb
+++ b/lib/gitlab/current_settings.rb
@@ -35,6 +35,7 @@ module Gitlab
signin_enabled: Settings.gitlab['signin_enabled'],
gravatar_enabled: Settings.gravatar['enabled'],
koding_enabled: false,
+ plantuml_enabled: false,
sign_in_text: nil,
after_sign_up_text: nil,
help_page_text: nil,
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/lib/tasks/gitlab/update_templates.rake b/lib/tasks/gitlab/update_templates.rake
index 4f76dad7286..b77a5bb62d1 100644
--- a/lib/tasks/gitlab/update_templates.rake
+++ b/lib/tasks/gitlab/update_templates.rake
@@ -44,7 +44,7 @@ namespace :gitlab do
),
Template.new(
"https://gitlab.com/gitlab-org/gitlab-ci-yml.git",
- /(\.{1,2}|LICENSE|Pages|\.gitlab-ci.yml)\z/
+ /(\.{1,2}|LICENSE|Pages|autodeploy|\.gitlab-ci.yml)\z/
)
]
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 + "']";
diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb
index f3843ca64ff..ba199917f5c 100644
--- a/spec/lib/gitlab/asciidoc_spec.rb
+++ b/spec/lib/gitlab/asciidoc_spec.rb
@@ -8,6 +8,10 @@ module Gitlab
let(:html) { 'H<sub>2</sub>O' }
context "without project" do
+ before do
+ allow_any_instance_of(ApplicationSetting).to receive(:current).and_return(::ApplicationSetting.create_from_defaults)
+ end
+
it "converts the input using Asciidoctor and default options" do
expected_asciidoc_opts = {
safe: :secure,
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 3ec7bb46686..32779eb92ef 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -190,34 +190,54 @@ describe Project, models: true do
end
it 'does not allow an invalid URI as import_url' do
- project2 = build(:project, import_url: 'invalid://')
+ project2 = build(:empty_project, import_url: 'invalid://')
expect(project2).not_to be_valid
end
it 'does allow a valid URI as import_url' do
- project2 = build(:project, import_url: 'ssh://test@gitlab.com/project.git')
+ project2 = build(:empty_project, import_url: 'ssh://test@gitlab.com/project.git')
expect(project2).to be_valid
end
it 'allows an empty URI' do
- project2 = build(:project, import_url: '')
+ project2 = build(:empty_project, import_url: '')
expect(project2).to be_valid
end
it 'does not produce import data on an empty URI' do
- project2 = build(:project, import_url: '')
+ project2 = build(:empty_project, import_url: '')
expect(project2.import_data).to be_nil
end
it 'does not produce import data on an invalid URI' do
- project2 = build(:project, import_url: 'test://')
+ project2 = build(:empty_project, import_url: 'test://')
expect(project2.import_data).to be_nil
end
+
+ describe 'project pending deletion' do
+ let!(:project_pending_deletion) do
+ create(:empty_project,
+ pending_delete: true)
+ end
+ let(:new_project) do
+ build(:empty_project,
+ name: project_pending_deletion.name,
+ namespace: project_pending_deletion.namespace)
+ end
+
+ before do
+ new_project.validate
+ end
+
+ it 'contains errors related to the project being deleted' do
+ expect(new_project.errors.full_messages.first).to eq('The project is still being deleted. Please try again later.')
+ end
+ end
end
describe 'default_scope' do
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index ad9d8a25af4..91e3c333a02 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -16,6 +16,8 @@ describe API::Settings, 'Settings', api: true do
expect(json_response['repository_storage']).to eq('default')
expect(json_response['koding_enabled']).to be_falsey
expect(json_response['koding_url']).to be_nil
+ expect(json_response['plantuml_enabled']).to be_falsey
+ expect(json_response['plantuml_url']).to be_nil
end
end
@@ -28,7 +30,8 @@ describe API::Settings, 'Settings', api: true do
it "updates application settings" do
put api("/application/settings", admin),
- default_projects_limit: 3, signin_enabled: false, repository_storage: 'custom', koding_enabled: true, koding_url: 'http://koding.example.com'
+ default_projects_limit: 3, signin_enabled: false, repository_storage: 'custom', koding_enabled: true, koding_url: 'http://koding.example.com',
+ plantuml_enabled: true, plantuml_url: 'http://plantuml.example.com'
expect(response).to have_http_status(200)
expect(json_response['default_projects_limit']).to eq(3)
expect(json_response['signin_enabled']).to be_falsey
@@ -36,6 +39,8 @@ describe API::Settings, 'Settings', api: true do
expect(json_response['repository_storages']).to eq(['custom'])
expect(json_response['koding_enabled']).to be_truthy
expect(json_response['koding_url']).to eq('http://koding.example.com')
+ expect(json_response['plantuml_enabled']).to be_truthy
+ expect(json_response['plantuml_url']).to eq('http://plantuml.example.com')
end
end
@@ -47,5 +52,14 @@ describe API::Settings, 'Settings', api: true do
expect(json_response['error']).to eq('koding_url is missing')
end
end
+
+ context "missing plantuml_url value when plantuml_enabled is true" do
+ it "returns a blank parameter error message" do
+ put api("/application/settings", admin), plantuml_enabled: true
+
+ expect(response).to have_http_status(400)
+ expect(json_response['error']).to eq('plantuml_url is missing')
+ end
+ end
end
end
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 2c2e17eddb0..5bf5bf0739e 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -137,6 +137,15 @@ describe API::Users, api: true do
expect(new_user.can_create_group).to eq(true)
end
+ it "creates user with optional attributes" do
+ optional_attributes = { confirm: true }
+ attributes = attributes_for(:user).merge(optional_attributes)
+
+ post api('/users', admin), attributes
+
+ expect(response).to have_http_status(201)
+ end
+
it "creates non-admin user" do
post api('/users', admin), attributes_for(:user, admin: false, can_create_group: false)
expect(response).to have_http_status(201)