Commit ec1556ca authored by Martin Cabrera's avatar Martin Cabrera

Merge branch 'master' into i-#25814-500-error

parents e1302007 3eebca7b
...@@ -101,7 +101,7 @@ gem 'seed-fu', '~> 2.3.5' ...@@ -101,7 +101,7 @@ gem 'seed-fu', '~> 2.3.5'
# Markdown and HTML processing # Markdown and HTML processing
gem 'html-pipeline', '~> 1.11.0' gem 'html-pipeline', '~> 1.11.0'
gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie'
gem 'gitlab-markup', '~> 1.5.1' gem 'gitlab-markup', '~> 1.5.0'
gem 'redcarpet', '~> 3.3.3' gem 'redcarpet', '~> 3.3.3'
gem 'RedCloth', '~> 4.3.2' gem 'RedCloth', '~> 4.3.2'
gem 'rdoc', '~> 4.2' gem 'rdoc', '~> 4.2'
...@@ -109,6 +109,7 @@ gem 'org-ruby', '~> 0.9.12' ...@@ -109,6 +109,7 @@ gem 'org-ruby', '~> 0.9.12'
gem 'creole', '~> 0.5.0' gem 'creole', '~> 0.5.0'
gem 'wikicloth', '0.8.1' gem 'wikicloth', '0.8.1'
gem 'asciidoctor', '~> 1.5.2' gem 'asciidoctor', '~> 1.5.2'
gem 'asciidoctor-plantuml', '0.0.6'
gem 'rouge', '~> 2.0' gem 'rouge', '~> 2.0'
gem 'truncato', '~> 0.7.8' gem 'truncato', '~> 0.7.8'
......
...@@ -54,6 +54,8 @@ GEM ...@@ -54,6 +54,8 @@ GEM
faraday_middleware-multi_json (~> 0.0) faraday_middleware-multi_json (~> 0.0)
oauth2 (~> 1.0) oauth2 (~> 1.0)
asciidoctor (1.5.3) asciidoctor (1.5.3)
asciidoctor-plantuml (0.0.6)
asciidoctor (~> 1.5)
ast (2.3.0) ast (2.3.0)
attr_encrypted (3.0.3) attr_encrypted (3.0.3)
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
...@@ -841,6 +843,7 @@ DEPENDENCIES ...@@ -841,6 +843,7 @@ DEPENDENCIES
allocations (~> 1.0) allocations (~> 1.0)
asana (~> 0.4.0) asana (~> 0.4.0)
asciidoctor (~> 1.5.2) asciidoctor (~> 1.5.2)
asciidoctor-plantuml (= 0.0.6)
attr_encrypted (~> 3.0.0) attr_encrypted (~> 3.0.0)
awesome_print (~> 1.2.0) awesome_print (~> 1.2.0)
babosa (~> 1.0.2) babosa (~> 1.0.2)
......
...@@ -58,6 +58,7 @@ ...@@ -58,6 +58,7 @@
/*= require_directory ./extensions */ /*= require_directory ./extensions */
/*= require_directory ./lib/utils */ /*= require_directory ./lib/utils */
/*= require_directory ./u2f */ /*= require_directory ./u2f */
/*= require_directory ./droplab */
/*= require_directory . */ /*= require_directory . */
/*= require fuzzaldrin-plus */ /*= require fuzzaldrin-plus */
/*= require es6-promise.auto */ /*= require es6-promise.auto */
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
(function() { (function() {
var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
var AUTO_SCROLL_OFFSET = 75; var AUTO_SCROLL_OFFSET = 75;
var DOWN_BUILD_TRACE = '#down-build-trace';
this.Build = (function() { this.Build = (function() {
Build.interval = null; Build.interval = null;
...@@ -26,7 +27,7 @@ ...@@ -26,7 +27,7 @@
this.$autoScrollStatus = $('#autoscroll-status'); this.$autoScrollStatus = $('#autoscroll-status');
this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text'); this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text');
this.$upBuildTrace = $('#up-build-trace'); this.$upBuildTrace = $('#up-build-trace');
this.$downBuildTrace = $('#down-build-trace'); this.$downBuildTrace = $(DOWN_BUILD_TRACE);
this.$scrollTopBtn = $('#scroll-top'); this.$scrollTopBtn = $('#scroll-top');
this.$scrollBottomBtn = $('#scroll-bottom'); this.$scrollBottomBtn = $('#scroll-bottom');
this.$buildRefreshAnimation = $('.js-build-refresh'); this.$buildRefreshAnimation = $('.js-build-refresh');
...@@ -91,6 +92,9 @@ ...@@ -91,6 +92,9 @@
dataType: 'json', dataType: 'json',
success: function(buildData) { success: function(buildData) {
$('.js-build-output').html(buildData.trace_html); $('.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) { if (removeRefreshStatuses.indexOf(buildData.status) >= 0) {
this.$buildRefreshAnimation.remove(); this.$buildRefreshAnimation.remove();
return this.initScrollMonitor(); return this.initScrollMonitor();
...@@ -105,6 +109,8 @@ ...@@ -105,6 +109,8 @@
dataType: "json", dataType: "json",
success: (function(_this) { success: (function(_this) {
return function(log) { return function(log) {
var pageUrl;
if (log.state) { if (log.state) {
_this.state = log.state; _this.state = log.state;
} }
...@@ -116,7 +122,12 @@ ...@@ -116,7 +122,12 @@
} }
return _this.checkAutoscroll(); return _this.checkAutoscroll();
} else if (log.status !== _this.buildStatus) { } 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) })(this)
......
...@@ -84,6 +84,9 @@ ...@@ -84,6 +84,9 @@
break; break;
case 'projects:merge_requests:index': case 'projects:merge_requests:index':
case 'projects:issues:index': case 'projects:issues:index':
if (gl.FilteredSearchManager) {
new gl.FilteredSearchManager();
}
Issuable.init(); Issuable.init();
new gl.IssuableBulkActions({ new gl.IssuableBulkActions({
prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_',
......
This diff is collapsed.
/* 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
/* 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
/* 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
/*= 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;
})();
/*= 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;
})();
/*= 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;
})();
(() => {
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;
})();
// 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 . */
(() => {
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;
})();
/* 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;
})();
/* 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;
})();
(() => {
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;
})();
(() => {
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;
})();
...@@ -124,6 +124,12 @@ ...@@ -124,6 +124,12 @@
return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : '/' + parsedUrl.pathname; 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) { gl.utils.isMetaKey = function(e) {
return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
}; };
......
...@@ -17,6 +17,21 @@ ...@@ -17,6 +17,21 @@
gl.text.replaceRange = function(s, start, end, substitute) { gl.text.replaceRange = function(s, start, end, substitute) {
return s.substring(0, start) + substitute + s.substring(end); 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) { gl.text.selectedText = function(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd); return text.substring(textarea.selectionStart, textarea.selectionEnd);
}; };
......
...@@ -142,8 +142,9 @@ ...@@ -142,8 +142,9 @@
} }
getCategoryContents() { 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; userId = gon.current_user_id;
userName = gon.current_username;
utils = gl.utils, projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions; utils = gl.utils, projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions;
if (utils.isInGroupsPage() && groupOptions) { if (utils.isInGroupsPage() && groupOptions) {
options = groupOptions[utils.getGroupSlug()]; options = groupOptions[utils.getGroupSlug()];
...@@ -158,10 +159,10 @@ ...@@ -158,10 +159,10 @@
header: "" + name header: "" + name
}, { }, {
text: 'Issues assigned to me', text: 'Issues assigned to me',
url: issuesPath + "/?assignee_id=" + userId url: issuesPath + "/?assignee_username=" + userName
}, { }, {
text: "Issues I've created", text: "Issues I've created",
url: issuesPath + "/?author_id=" + userId url: issuesPath + "/?author_username=" + userName
}, 'separator', { }, 'separator', {
text: 'Merge requests assigned to me', text: 'Merge requests assigned to me',
url: mrPath + "/?assignee_id=" + userId url: mrPath + "/?assignee_id=" + userId
......
...@@ -23,3 +23,118 @@ ...@@ -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;
}
...@@ -23,12 +23,16 @@ ...@@ -23,12 +23,16 @@
margin-right: 0; margin-right: 0;
} }
.issues-details-filters, .issues-details-filters:not(.filtered-search-block),
.dash-projects-filters, .dash-projects-filters,
.check-all-holder { .check-all-holder {
display: none; display: none;
} }
.issues-holder .issue-check {
display: none;
}
.rss-btn { .rss-btn {
display: none; display: none;
} }
......
...@@ -263,6 +263,11 @@ $dropdown-chevron-size: 10px; ...@@ -263,6 +263,11 @@ $dropdown-chevron-size: 10px;
$dropdown-toggle-active-border-color: darken($border-color, 14%); $dropdown-toggle-active-border-color: darken($border-color, 14%);
/*
* Filtered Search
*/
$dropdown-hover-color: #3b86ff;
/* /*
* Buttons * Buttons
*/ */
......
...@@ -67,69 +67,78 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController ...@@ -67,69 +67,78 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file] params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file]
params.require(:application_setting).permit( params.require(:application_setting).permit(
:default_projects_limit, application_setting_params_ce
:default_branch_protection, )
:signup_enabled, end
:signin_enabled,
:require_two_factor_authentication, def application_setting_params_ce
:two_factor_grace_period, [
:gravatar_enabled, :admin_notification_email,
:sign_in_text,
:after_sign_up_text,
:help_page_text,
:home_page_url,
:after_sign_out_path, :after_sign_out_path,
:max_attachment_size, :after_sign_up_text,
:session_expire_delay, :akismet_api_key,
:akismet_enabled,
:container_registry_token_expire_delay,
:default_branch_protection,
:default_group_visibility,
:default_project_visibility, :default_project_visibility,
:default_projects_limit,
:default_snippet_visibility, :default_snippet_visibility,
:default_group_visibility,
:domain_whitelist_raw,
:domain_blacklist_enabled, :domain_blacklist_enabled,
:domain_blacklist_raw,
:domain_blacklist_file, :domain_blacklist_file,
:version_check_enabled, :domain_blacklist_raw,
:admin_notification_email, :domain_whitelist_raw,
:user_oauth_applications, :email_author_in_body,
:user_default_external, :enabled_git_access_protocol,
:shared_runners_enabled, :gravatar_enabled,
:shared_runners_text, :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_artifacts_size,
:max_attachment_size,
:metrics_enabled, :metrics_enabled,
:metrics_host, :metrics_host,
:metrics_port,
:metrics_pool_size,
:metrics_timeout,
:metrics_method_call_threshold, :metrics_method_call_threshold,
:metrics_packet_size,
:metrics_pool_size,
:metrics_port,
:metrics_sample_interval, :metrics_sample_interval,
:metrics_timeout,
:recaptcha_enabled, :recaptcha_enabled,
:recaptcha_site_key,
:recaptcha_private_key, :recaptcha_private_key,
:sentry_enabled, :recaptcha_site_key,
:sentry_dsn,
:akismet_enabled,
:akismet_api_key,
:koding_enabled,
:koding_url,
:email_author_in_body,
:html_emails_enabled,
:repository_checks_enabled, :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, :send_user_confirmation_email,
:container_registry_token_expire_delay, :shared_runners_enabled,
:enabled_git_access_protocol, :shared_runners_text,
:sidekiq_throttling_enabled, :sidekiq_throttling_enabled,
:sidekiq_throttling_factor, :sidekiq_throttling_factor,
:housekeeping_enabled, :two_factor_grace_period,
:housekeeping_bitmaps_enabled, :user_default_external,
:housekeeping_incremental_repack_period, :user_oauth_applications,
:housekeeping_full_repack_period, :version_check_enabled,
:housekeeping_gc_period,
disabled_oauth_sign_in_sources: [],
import_sources: [],
repository_storages: [], repository_storages: [],
restricted_visibility_levels: [], restricted_visibility_levels: [],
import_sources: [],
disabled_oauth_sign_in_sources: [],
sidekiq_throttling_queues: [] sidekiq_throttling_queues: []
) ]
end end
end end
...@@ -61,7 +61,11 @@ class Admin::GroupsController < Admin::ApplicationController ...@@ -61,7 +61,11 @@ class Admin::GroupsController < Admin::ApplicationController
end end
def group_params def group_params
params.require(:group).permit( params.require(:group).permit(group_params_ce)
end
def group_params_ce
[
:avatar, :avatar,
:description, :description,
:lfs_enabled, :lfs_enabled,
...@@ -69,6 +73,6 @@ class Admin::GroupsController < Admin::ApplicationController ...@@ -69,6 +73,6 @@ class Admin::GroupsController < Admin::ApplicationController
:path, :path,
:request_access_enabled, :request_access_enabled,
:visibility_level :visibility_level
) ]
end end
end end
...@@ -161,15 +161,6 @@ class Admin::UsersController < Admin::ApplicationController ...@@ -161,15 +161,6 @@ class Admin::UsersController < Admin::ApplicationController
@user ||= User.find_by!(username: params[:id]) @user ||= User.find_by!(username: params[:id])
end 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 = {}) def redirect_back_or_admin_user(options = {})
redirect_back_or_default(default: default_route, options: options) redirect_back_or_default(default: default_route, options: options)
end end
...@@ -177,4 +168,36 @@ class Admin::UsersController < Admin::ApplicationController ...@@ -177,4 +168,36 @@ class Admin::UsersController < Admin::ApplicationController
def default_route def default_route
[:admin, @user] [:admin, @user]
end 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 end
...@@ -165,31 +165,53 @@ class IssuableFinder ...@@ -165,31 +165,53 @@ class IssuableFinder
end end
end end
def assignee? def assignee_id?
params[:assignee_id].present? 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 end
def assignee def assignee
return @assignee if defined?(@assignee) return @assignee if defined?(@assignee)
@assignee = @assignee =
if assignee? && params[:assignee_id] != NONE if assignee_id?
User.find(params[:assignee_id]) User.find_by(id: params[:assignee_id])
elsif assignee_username?
User.find_by(username: params[:assignee_username])
else else
nil nil
end end
end end
def author? def author_id?
params[:author_id].present? 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 end
def author def author
return @author if defined?(@author) return @author if defined?(@author)
@author = @author =
if author? && params[:author_id] != NONE if author_id?
User.find(params[:author_id]) User.find_by(id: params[:author_id])
elsif author_username?
User.find_by(username: params[:author_username])
else else
nil nil
end end
...@@ -263,16 +285,24 @@ class IssuableFinder ...@@ -263,16 +285,24 @@ class IssuableFinder
end end
def by_assignee(items) def by_assignee(items)
if assignee? if assignee
items = items.where(assignee_id: assignee.try(:id)) 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 end
items items
end end
def by_author(items) def by_author(items)
if author? if author
items = items.where(author_id: author.try(:id)) 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 end
items items
......
...@@ -244,7 +244,9 @@ module ApplicationHelper ...@@ -244,7 +244,9 @@ module ApplicationHelper
scope: params[:scope], scope: params[:scope],
milestone_title: params[:milestone_title], milestone_title: params[:milestone_title],
assignee_id: params[:assignee_id], assignee_id: params[:assignee_id],
assignee_username: params[:assignee_username],
author_id: params[:author_id], author_id: params[:author_id],
author_username: params[:author_username],
search: params[:search], search: params[:search],
label_name: params[:label_name] label_name: params[:label_name]
} }
......
...@@ -68,6 +68,10 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -68,6 +68,10 @@ class ApplicationSetting < ActiveRecord::Base
presence: true, presence: true,
if: :koding_enabled if: :koding_enabled
validates :plantuml_url,
presence: true,
if: :plantuml_enabled
validates :max_attachment_size, validates :max_attachment_size,
presence: true, presence: true,
numericality: { only_integer: true, greater_than: 0 } numericality: { only_integer: true, greater_than: 0 }
...@@ -184,6 +188,8 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -184,6 +188,8 @@ class ApplicationSetting < ActiveRecord::Base
akismet_enabled: false, akismet_enabled: false,
koding_enabled: false, koding_enabled: false,
koding_url: nil, koding_url: nil,
plantuml_enabled: false,
plantuml_url: nil,
repository_checks_enabled: true, repository_checks_enabled: true,
disabled_oauth_sign_in_sources: [], disabled_oauth_sign_in_sources: [],
send_user_confirmation_email: false, send_user_confirmation_email: false,
......
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
...@@ -31,7 +31,7 @@ class CycleAnalytics ...@@ -31,7 +31,7 @@ class CycleAnalytics
repository = @project.repository.raw_repository repository = @project.repository.raw_repository
sha = @project.repository.commit(ref).sha sha = @project.repository.commit(ref).sha
cmd = %W(git --git-dir=#{repository.path} log) cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{repository.path} log)
cmd << '--format=%H' cmd << '--format=%H'
cmd << "--after=#{@from.iso8601}" cmd << "--after=#{@from.iso8601}"
cmd << sha cmd << sha
......
...@@ -12,6 +12,7 @@ class Project < ActiveRecord::Base ...@@ -12,6 +12,7 @@ class Project < ActiveRecord::Base
include AfterCommitQueue include AfterCommitQueue
include CaseSensitivity include CaseSensitivity
include TokenAuthenticatable include TokenAuthenticatable
include ValidAttribute
include ProjectFeaturesCompatibility include ProjectFeaturesCompatibility
include SelectForProjectAuthorization include SelectForProjectAuthorization
include Routable include Routable
...@@ -65,6 +66,8 @@ class Project < ActiveRecord::Base ...@@ -65,6 +66,8 @@ class Project < ActiveRecord::Base
end end
end end
after_validation :check_pending_delete
ActsAsTaggableOn.strict_case_match = true ActsAsTaggableOn.strict_case_match = true
acts_as_taggable_on :tags acts_as_taggable_on :tags
...@@ -1320,4 +1323,21 @@ class Project < ActiveRecord::Base ...@@ -1320,4 +1323,21 @@ class Project < ActiveRecord::Base
stats = statistics || build_statistics stats = statistics || build_statistics
stats.update(namespace_id: namespace_id) stats.update(namespace_id: namespace_id)
end 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 end
...@@ -420,6 +420,23 @@ ...@@ -420,6 +420,23 @@
= succeed "." do = succeed "." do
= link_to "Koding administration documentation", help_page_path("administration/integration/koding") = 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 %fieldset
%legend Usage statistics %legend Usage statistics
.form-group .form-group
......
...@@ -6,6 +6,9 @@ ...@@ -6,6 +6,9 @@
= content_for :sub_nav do = content_for :sub_nav do
= render "projects/issues/head" = 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 = content_for :meta_tags do
- if current_user - if current_user
= auto_discovery_link_tag(:atom, url_for(params.merge(format: :atom, private_token: current_user.private_token)), title: "#{@project.name} issues") = 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 @@ ...@@ -20,7 +23,6 @@
= icon('rss') = icon('rss')
%span.icon-label %span.icon-label
Subscribe Subscribe
= render 'shared/issuable/search_form', path: namespace_project_issues_path(@project.namespace, @project)
- if can? current_user, :create_issue, @project - if can? current_user, :create_issue, @project
= link_to new_namespace_project_issue_path(@project.namespace, = link_to new_namespace_project_issue_path(@project.namespace,
@project, @project,
...@@ -30,7 +32,7 @@ ...@@ -30,7 +32,7 @@
title: "New Issue", title: "New Issue",
id: "new_issue_link" do id: "new_issue_link" do
New Issue New Issue
= render 'shared/issuable/filter', type: :issues = render 'shared/issuable/search_bar', type: :issues
.issues-holder .issues-holder
= render 'issues' = render 'issues'
......
- 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());
});
---
title: Scroll to bottom on build completion if autoscroll was active
merge_request: 8391
author:
---
title: Add support for PlantUML diagrams in AsciiDoc documents.
merge_request: 7810
author: Horacio Sanson
---
title: Fill missing authorized projects rows
merge_request:
author:
---
title: Remove extra orphaned rows when removing stray namespaces
merge_request: 7841
author:
---
title: Fix project queued for deletion re-creation tooltip
merge_request:
author:
---
title: Don't validate environment urls on .gitlab-ci.yml
merge_request:
author:
...@@ -106,6 +106,7 @@ module Gitlab ...@@ -106,6 +106,7 @@ module Gitlab
config.assets.precompile << "blob_edit/blob_edit_bundle.js" config.assets.precompile << "blob_edit/blob_edit_bundle.js"
config.assets.precompile << "snippet/snippet_bundle.js" config.assets.precompile << "snippet/snippet_bundle.js"
config.assets.precompile << "terminal/terminal_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/utils/*.js"
config.assets.precompile << "lib/*.js" config.assets.precompile << "lib/*.js"
config.assets.precompile << "u2f.js" config.assets.precompile << "u2f.js"
......
...@@ -5,47 +5,87 @@ class RemoveUndeletedGroups < ActiveRecord::Migration ...@@ -5,47 +5,87 @@ class RemoveUndeletedGroups < ActiveRecord::Migration
DOWNTIME = false DOWNTIME = false
def up def up
is_ee = defined?(Gitlab::License)
if is_ee
execute <<-EOF.strip_heredoc execute <<-EOF.strip_heredoc
DELETE FROM projects DELETE FROM path_locks
WHERE namespace_id IN ( WHERE project_id IN (
SELECT id FROM ( SELECT project_id
FROM projects
WHERE namespace_id IN (#{namespaces_pending_removal})
);
EOF
execute <<-EOF.strip_heredoc
DELETE FROM remote_mirrors
WHERE project_id IN (
SELECT project_id
FROM projects
WHERE namespace_id IN (#{namespaces_pending_removal})
);
EOF
end
execute <<-EOF.strip_heredoc
DELETE FROM lists
WHERE label_id IN (
SELECT id SELECT id
FROM namespaces FROM labels
WHERE deleted_at IS NOT NULL WHERE group_id IN (#{namespaces_pending_removal})
) namespace_ids );
EOF
execute <<-EOF.strip_heredoc
DELETE FROM lists
WHERE board_id IN (
SELECT id
FROM boards
WHERE project_id IN (
SELECT project_id
FROM projects
WHERE namespace_id IN (#{namespaces_pending_removal})
)
); );
EOF EOF
if defined?(Gitlab::License) execute <<-EOF.strip_heredoc
DELETE FROM labels
WHERE group_id IN (#{namespaces_pending_removal});
EOF
execute <<-EOF.strip_heredoc
DELETE FROM boards
WHERE project_id IN (
SELECT project_id
FROM projects
WHERE namespace_id IN (#{namespaces_pending_removal})
)
EOF
execute <<-EOF.strip_heredoc
DELETE FROM projects
WHERE namespace_id IN (#{namespaces_pending_removal});
EOF
if is_ee
# EE adds these columns but we have to make sure this data is cleaned up # EE adds these columns but we have to make sure this data is cleaned up
# here before we run the DELETE below. An alternative would be patching # here before we run the DELETE below. An alternative would be patching
# this migration in EE but this will only result in a mess and confusing # this migration in EE but this will only result in a mess and confusing
# migrations. # migrations.
execute <<-EOF.strip_heredoc execute <<-EOF.strip_heredoc
DELETE FROM protected_branch_push_access_levels DELETE FROM protected_branch_push_access_levels
WHERE group_id IN ( WHERE group_id IN (#{namespaces_pending_removal});
SELECT id FROM (
SELECT id
FROM namespaces
WHERE deleted_at IS NOT NULL
) namespace_ids
);
EOF EOF
execute <<-EOF.strip_heredoc execute <<-EOF.strip_heredoc
DELETE FROM protected_branch_merge_access_levels DELETE FROM protected_branch_merge_access_levels
WHERE group_id IN ( WHERE group_id IN (#{namespaces_pending_removal});
SELECT id FROM (
SELECT id
FROM namespaces
WHERE deleted_at IS NOT NULL
) namespace_ids
);
EOF EOF
end end
# This removes namespaces that were supposed to be soft deleted but still # This removes namespaces that were supposed to be deleted but still reside
# reside in the database. # in the database.
execute "DELETE FROM namespaces WHERE deleted_at IS NOT NULL;" execute "DELETE FROM namespaces WHERE deleted_at IS NOT NULL;"
end end
...@@ -54,4 +94,12 @@ class RemoveUndeletedGroups < ActiveRecord::Migration ...@@ -54,4 +94,12 @@ class RemoveUndeletedGroups < ActiveRecord::Migration
# If someone is trying to rollback for other reasons, we should not throw an Exception. # If someone is trying to rollback for other reasons, we should not throw an Exception.
# raise ActiveRecord::IrreversibleMigration # raise ActiveRecord::IrreversibleMigration
end end
def namespaces_pending_removal
"SELECT id FROM (
SELECT id
FROM namespaces
WHERE deleted_at IS NOT NULL
) namespace_ids"
end
end end
# 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
# 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
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class FillAuthorizedProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
class User < ActiveRecord::Base
self.table_name = 'users'
end
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# We're not inserting any data so we don't need to start a transaction.
disable_ddl_transaction!
def up
relation = User.select(:id).
where('authorized_projects_populated IS NOT TRUE')
relation.find_in_batches(batch_size: 1_000) do |rows|
args = rows.map { |row| [row.id] }
Sidekiq::Client.push_bulk('class' => 'AuthorizedProjectsWorker', 'args' => args)
end
end
def down
end
end
...@@ -107,6 +107,8 @@ ActiveRecord::Schema.define(version: 20170106172224) do ...@@ -107,6 +107,8 @@ ActiveRecord::Schema.define(version: 20170106172224) do
t.integer "housekeeping_full_repack_period", default: 50, null: false t.integer "housekeeping_full_repack_period", default: 50, null: false
t.integer "housekeeping_gc_period", default: 200, null: false t.integer "housekeeping_gc_period", default: 200, null: false
t.boolean "html_emails_enabled", default: true t.boolean "html_emails_enabled", default: true
t.string "plantuml_url"
t.boolean "plantuml_enabled"
end end
create_table "audit_events", force: :cascade do |t| create_table "audit_events", force: :cascade do |t|
......
# 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.
...@@ -44,7 +44,9 @@ Example response: ...@@ -44,7 +44,9 @@ Example response:
"repository_storage": "default", "repository_storage": "default",
"repository_storages": ["default"], "repository_storages": ["default"],
"koding_enabled": false, "koding_enabled": false,
"koding_url": null "koding_url": null,
"plantuml_enabled": false,
"plantuml_url": null
} }
``` ```
...@@ -80,6 +82,8 @@ PUT /application/settings ...@@ -80,6 +82,8 @@ PUT /application/settings
| `koding_enabled` | boolean | no | Enable Koding integration. Default is `false`. | | `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. | | `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 | | `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 ```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/application/settings?signup_enabled=false&default_project_visibility=1 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: ...@@ -112,6 +116,8 @@ Example response:
"container_registry_token_expire_delay": 5, "container_registry_token_expire_delay": 5,
"repository_storage": "default", "repository_storage": "default",
"koding_enabled": false, "koding_enabled": false,
"koding_url": null "koding_url": null,
"plantuml_enabled": false,
"plantuml_url": null
} }
``` ```
...@@ -16,6 +16,7 @@ See the documentation below for details on how to configure these services. ...@@ -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 - [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users
- [Akismet](akismet.md) Configure Akismet to stop spam - [Akismet](akismet.md) Configure Akismet to stop spam
- [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration - [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]. GitLab Enterprise Edition contains [advanced Jenkins support][jenkins].
......
@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
...@@ -26,12 +26,6 @@ Feature: Project Issues ...@@ -26,12 +26,6 @@ Feature: Project Issues
Given I click link "Release 0.4" Given I click link "Release 0.4"
Then I should see issue "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 Scenario: I submit new unassigned issue
Given I click link "New Issue" Given I click link "New Issue"
And I submit new issue "500 error on profile" And I submit new issue "500 error on profile"
...@@ -84,56 +78,6 @@ Feature: Project Issues ...@@ -84,56 +78,6 @@ Feature: Project Issues
And I sort the list by "Least popular" And I sort the list by "Least popular"
Then The list should be sorted 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 # Markdown
Scenario: Headers inside the description should have ids generated for them. Scenario: Headers inside the description should have ids generated for them.
......
...@@ -565,6 +565,8 @@ module API ...@@ -565,6 +565,8 @@ module API
expose :repository_storages expose :repository_storages
expose :koding_enabled expose :koding_enabled
expose :koding_url expose :koding_url
expose :plantuml_enabled
expose :plantuml_url
end end
class Release < Grape::Entity class Release < Grape::Entity
......
...@@ -93,6 +93,10 @@ module API ...@@ -93,6 +93,10 @@ module API
given koding_enabled: ->(val) { val } do given koding_enabled: ->(val) { val } do
requires :koding_url, type: String, desc: 'The Koding team URL' requires :koding_url, type: String, desc: 'The Koding team URL'
end 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 :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 :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.' 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 ...@@ -114,7 +118,7 @@ module API
:shared_runners_enabled, :max_artifacts_size, :container_registry_token_expire_delay, :shared_runners_enabled, :max_artifacts_size, :container_registry_token_expire_delay,
:metrics_enabled, :sidekiq_throttling_enabled, :recaptcha_enabled, :metrics_enabled, :sidekiq_throttling_enabled, :recaptcha_enabled,
:akismet_enabled, :admin_notification_email, :sentry_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, :version_check_enabled, :email_author_in_body, :html_emails_enabled,
:housekeeping_enabled :housekeeping_enabled
end end
......
require 'asciidoctor' require 'asciidoctor'
require 'asciidoctor/converter/html5' require 'asciidoctor/converter/html5'
require "asciidoctor-plantuml"
module Gitlab module Gitlab
# Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters # Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters
...@@ -29,6 +30,8 @@ module Gitlab ...@@ -29,6 +30,8 @@ module Gitlab
) )
asciidoc_opts[:attributes].unshift(*DEFAULT_ADOC_ATTRS) asciidoc_opts[:attributes].unshift(*DEFAULT_ADOC_ATTRS)
plantuml_setup
html = ::Asciidoctor.convert(input, asciidoc_opts) html = ::Asciidoctor.convert(input, asciidoc_opts)
html = Banzai.post_process(html, context) html = Banzai.post_process(html, context)
...@@ -36,6 +39,15 @@ module Gitlab ...@@ -36,6 +39,15 @@ module Gitlab
html.html_safe html.html_safe
end 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 class Html5Converter < Asciidoctor::Converter::Html5Converter
extend Asciidoctor::Converter::Config extend Asciidoctor::Converter::Config
......
...@@ -33,7 +33,6 @@ module Gitlab ...@@ -33,7 +33,6 @@ module Gitlab
validates :url, validates :url,
length: { maximum: 255 }, length: { maximum: 255 },
addressable_url: true,
allow_nil: true allow_nil: true
validates :action, validates :action,
......
...@@ -35,6 +35,7 @@ module Gitlab ...@@ -35,6 +35,7 @@ module Gitlab
signin_enabled: Settings.gitlab['signin_enabled'], signin_enabled: Settings.gitlab['signin_enabled'],
gravatar_enabled: Settings.gravatar['enabled'], gravatar_enabled: Settings.gravatar['enabled'],
koding_enabled: false, koding_enabled: false,
plantuml_enabled: false,
sign_in_text: nil, sign_in_text: nil,
after_sign_up_text: nil, after_sign_up_text: nil,
help_page_text: nil, help_page_text: nil,
......
...@@ -27,7 +27,7 @@ module Gitlab ...@@ -27,7 +27,7 @@ module Gitlab
private private
def load_blame def load_blame
cmd = %W(git --git-dir=#{@repo.path} blame -p #{@sha} -- #{@path}) cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{@repo.path} blame -p #{@sha} -- #{@path})
# Read in binary mode to ensure ASCII-8BIT # Read in binary mode to ensure ASCII-8BIT
raw_output = IO.popen(cmd, 'rb') {|io| io.read } raw_output = IO.popen(cmd, 'rb') {|io| io.read }
output = encode_utf8(raw_output) output = encode_utf8(raw_output)
......
...@@ -332,7 +332,7 @@ module Gitlab ...@@ -332,7 +332,7 @@ module Gitlab
end end
def log_by_shell(sha, options) def log_by_shell(sha, options)
cmd = %W(git --git-dir=#{path} log) cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} log)
cmd += %W(-n #{options[:limit].to_i}) cmd += %W(-n #{options[:limit].to_i})
cmd += %w(--format=%H) cmd += %w(--format=%H)
cmd += %W(--skip=#{options[:offset].to_i}) cmd += %W(--skip=#{options[:offset].to_i})
...@@ -913,7 +913,7 @@ module Gitlab ...@@ -913,7 +913,7 @@ module Gitlab
return [] return []
end end
cmd = %W(git --git-dir=#{path} ls-tree) cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} ls-tree)
cmd += %w(-r) cmd += %w(-r)
cmd += %w(--full-tree) cmd += %w(--full-tree)
cmd += %w(--full-name) cmd += %w(--full-name)
...@@ -1108,7 +1108,7 @@ module Gitlab ...@@ -1108,7 +1108,7 @@ module Gitlab
end end
def archive_to_file(treeish = 'master', filename = 'archive.tar.gz', format = nil, compress_cmd = %w(gzip -n)) def archive_to_file(treeish = 'master', filename = 'archive.tar.gz', format = nil, compress_cmd = %w(gzip -n))
git_archive_cmd = %W(git --git-dir=#{path} archive) git_archive_cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} archive)
# Put files into a directory before archiving # Put files into a directory before archiving
prefix = "#{archive_name(treeish)}/" prefix = "#{archive_name(treeish)}/"
......
...@@ -13,6 +13,7 @@ module Gitlab ...@@ -13,6 +13,7 @@ module Gitlab
if current_user if current_user
gon.current_user_id = current_user.id gon.current_user_id = current_user.id
gon.current_username = current_user.username
end end
end end
end end
......
...@@ -3,7 +3,7 @@ namespace :gitlab do ...@@ -3,7 +3,7 @@ namespace :gitlab do
desc "GitLab | Git | Repack" desc "GitLab | Git | Repack"
task repack: :environment do task repack: :environment do
failures = perform_git_cmd(%W(git repack -a --quiet), "Repacking repo") failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} repack -a --quiet), "Repacking repo")
if failures.empty? if failures.empty?
puts "Done".color(:green) puts "Done".color(:green)
else else
...@@ -13,7 +13,7 @@ namespace :gitlab do ...@@ -13,7 +13,7 @@ namespace :gitlab do
desc "GitLab | Git | Run garbage collection on all repos" desc "GitLab | Git | Run garbage collection on all repos"
task gc: :environment do task gc: :environment do
failures = perform_git_cmd(%W(git gc --auto --quiet), "Garbage Collecting") failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} gc --auto --quiet), "Garbage Collecting")
if failures.empty? if failures.empty?
puts "Done".color(:green) puts "Done".color(:green)
else else
...@@ -23,7 +23,7 @@ namespace :gitlab do ...@@ -23,7 +23,7 @@ namespace :gitlab do
desc "GitLab | Git | Prune all repos" desc "GitLab | Git | Prune all repos"
task prune: :environment do task prune: :environment do
failures = perform_git_cmd(%W(git prune), "Git Prune") failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} prune), "Git Prune")
if failures.empty? if failures.empty?
puts "Done".color(:green) puts "Done".color(:green)
else else
......
...@@ -44,7 +44,7 @@ namespace :gitlab do ...@@ -44,7 +44,7 @@ namespace :gitlab do
), ),
Template.new( Template.new(
"https://gitlab.com/gitlab-org/gitlab-ci-yml.git", "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/
) )
] ]
......
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
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
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
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
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
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
This diff is collapsed.
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
...@@ -7,25 +7,27 @@ feature 'Issue filtering by Labels', feature: true, js: true do ...@@ -7,25 +7,27 @@ feature 'Issue filtering by Labels', feature: true, js: true do
let!(:user) { create(:user) } let!(:user) { create(:user) }
let!(:label) { create(:label, project: project) } let!(:label) { create(:label, project: project) }
before do let!(:bug) { create(:label, project: project, title: 'bug') }
bug = create(:label, project: project, title: 'bug') let!(:feature) { create(:label, project: project, title: 'feature') }
feature = create(:label, project: project, title: 'feature') let!(:enhancement) { create(:label, project: project, title: 'enhancement') }
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) before do
issue1.labels << bug mr1.labels << bug
issue2 = create(:issue, title: "Bugfix2", project: project) mr2.labels << bug
issue2.labels << bug mr2.labels << enhancement
issue2.labels << enhancement
issue3 = create(:issue, title: "Feature1", project: project) mr3.title = "Feature1"
issue3.labels << feature mr3.labels << feature
project.team << [user, :master] project.team << [user, :master]
login_as(user) login_as(user)
visit namespace_project_issues_path(project.namespace, project) visit namespace_project_merge_requests_path(project.namespace, project)
end end
context 'filter by label bug' do context 'filter by label bug' do
......
...@@ -8,81 +8,88 @@ feature 'Issues filter reset button', feature: true, js: true do ...@@ -8,81 +8,88 @@ feature 'Issues filter reset button', feature: true, js: true do
let!(:user) { create(:user)} let!(:user) { create(:user)}
let!(:milestone) { create(:milestone, project: project) } let!(:milestone) { create(:milestone, project: project) }
let!(:bug) { create(:label, project: project, name: 'bug')} let!(:bug) { create(:label, project: project, name: 'bug')}
let!(:issue1) { create(:issue, project: project, milestone: milestone, author: user, assignee: user, title: 'Feature')} let!(:mr1) { create(:merge_request, title: "Feature", source_project: project, target_project: project, source_branch: "Feature", milestone: milestone, author: user, assignee: user) }
let!(:issue2) { create(:labeled_issue, project: project, labels: [bug], title: 'Bugfix1')} let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") }
let(:merge_request_css) { '.merge-request' }
before do before do
mr2.labels << bug
project.team << [user, :developer] project.team << [user, :developer]
end end
context 'when a milestone filter has been applied' do context 'when a milestone filter has been applied' do
it 'resets the milestone filter' do it 'resets the milestone filter' do
visit_issues(project, milestone_title: milestone.title) visit_merge_requests(project, milestone_title: milestone.title)
expect(page).to have_css('.issue', count: 1) expect(page).to have_css(merge_request_css, count: 1)
reset_filters reset_filters
expect(page).to have_css('.issue', count: 2) expect(page).to have_css(merge_request_css, count: 2)
end end
end end
context 'when a label filter has been applied' do context 'when a label filter has been applied' do
it 'resets the label filter' do it 'resets the label filter' do
visit_issues(project, label_name: bug.name) visit_merge_requests(project, label_name: bug.name)
expect(page).to have_css('.issue', count: 1) expect(page).to have_css(merge_request_css, count: 1)
reset_filters reset_filters
expect(page).to have_css('.issue', count: 2) expect(page).to have_css(merge_request_css, count: 2)
end end
end end
context 'when a text search has been conducted' do context 'when a text search has been conducted' do
it 'resets the text search filter' do it 'resets the text search filter' do
visit_issues(project, search: 'Bug') visit_merge_requests(project, search: 'Bug')
expect(page).to have_css('.issue', count: 1) expect(page).to have_css(merge_request_css, count: 1)
reset_filters reset_filters
expect(page).to have_css('.issue', count: 2) expect(page).to have_css(merge_request_css, count: 2)
end end
end end
context 'when author filter has been applied' do context 'when author filter has been applied' do
it 'resets the author filter' do it 'resets the author filter' do
visit_issues(project, author_id: user.id) visit_merge_requests(project, author_id: user.id)
expect(page).to have_css('.issue', count: 1) expect(page).to have_css(merge_request_css, count: 1)
reset_filters reset_filters
expect(page).to have_css('.issue', count: 2) expect(page).to have_css(merge_request_css, count: 2)
end end
end end
context 'when assignee filter has been applied' do context 'when assignee filter has been applied' do
it 'resets the assignee filter' do it 'resets the assignee filter' do
visit_issues(project, assignee_id: user.id) visit_merge_requests(project, assignee_id: user.id)
expect(page).to have_css('.issue', count: 1) expect(page).to have_css(merge_request_css, count: 1)
reset_filters reset_filters
expect(page).to have_css('.issue', count: 2) expect(page).to have_css(merge_request_css, count: 2)
end end
end end
context 'when all filters have been applied' do context 'when all filters have been applied' do
it 'resets all filters' 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') 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('.issue', count: 0) expect(page).to have_css(merge_request_css, count: 0)
reset_filters reset_filters
expect(page).to have_css('.issue', count: 2) expect(page).to have_css(merge_request_css, count: 2)
end end
end end
context 'when no filters have been applied' do context 'when no filters have been applied' do
it 'the reset link should not be visible' do it 'the reset link should not be visible' do
visit_issues(project) visit_merge_requests(project)
expect(page).to have_css('.issue', count: 2) expect(page).to have_css(merge_request_css, count: 2)
expect(page).not_to have_css '.reset_filters' expect(page).not_to have_css '.reset_filters'
end end
end end
def visit_merge_requests(project, opts = {})
visit namespace_project_merge_requests_path project.namespace, project, opts
end
def reset_filters def reset_filters
find('.reset-filters').click find('.reset-filters').click
end end
......
...@@ -169,16 +169,16 @@ describe "Search", feature: true do ...@@ -169,16 +169,16 @@ describe "Search", feature: true do
find('.dropdown-menu').click_link 'Issues assigned to me' find('.dropdown-menu').click_link 'Issues assigned to me'
sleep 2 sleep 2
expect(page).to have_selector('.issues-holder') expect(page).to have_selector('.filtered-search')
expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) expect(find('.filtered-search').value).to eq("assignee:@#{user.username}")
end end
it 'takes user to her issues page when issues authored is clicked' do it 'takes user to her issues page when issues authored is clicked' do
find('.dropdown-menu').click_link "Issues I've created" find('.dropdown-menu').click_link "Issues I've created"
sleep 2 sleep 2
expect(page).to have_selector('.issues-holder') expect(page).to have_selector('.filtered-search')
expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name) expect(find('.filtered-search').value).to eq("author:@#{user.username}")
end end
it 'takes user to her MR page when MR assigned is clicked' do it 'takes user to her MR page when MR assigned is clicked' do
......
//= 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);
});
});
});
})();
//= 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"\'');
});
});
});
});
})();
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
expect(gl.utils.parseUrl('" test="asf"').pathname).toEqual('/teaspoon/%22%20test=%22asf%22'); expect(gl.utils.parseUrl('" test="asf"').pathname).toEqual('/teaspoon/%22%20test=%22asf%22');
}); });
}); });
describe('gl.utils.parseUrlPathname', () => { describe('gl.utils.parseUrlPathname', () => {
beforeEach(() => { beforeEach(() => {
spyOn(gl.utils, 'parseUrl').and.callFake(url => ({ spyOn(gl.utils, 'parseUrl').and.callFake(url => ({
...@@ -28,5 +29,28 @@ ...@@ -28,5 +29,28 @@
expect(gl.utils.parseUrlPathname('some/relative/url')).toEqual('/some/relative/url'); 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);
});
});
}); });
})(); })();
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment