diff --git a/Gemfile b/Gemfile index f27d6363e3d5a68913b494a6cc0f7e62f7644075..2cc7764e6b8f048d3338790e3611e3bb14994ae6 100644 --- a/Gemfile +++ b/Gemfile @@ -132,7 +132,7 @@ gem 'after_commit_queue', '~> 1.3.0' gem 'acts-as-taggable-on', '~> 4.0' # Background jobs -gem 'sidekiq', '~> 4.2' +gem 'sidekiq', '~> 4.2.7' gem 'sidekiq-cron', '~> 0.4.4' gem 'redis-namespace', '~> 1.5.2' gem 'sidekiq-limit_fetch', '~> 3.4' diff --git a/Gemfile.lock b/Gemfile.lock index c464ff70587ad80ec6be9d43603780d52928dadf..3de1a7cbf262178febf56fcfbfaee170cd7cd9a3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,7 +126,7 @@ GEM coffee-script-source (1.10.0) colorize (0.7.7) concurrent-ruby (1.0.2) - connection_pool (2.2.0) + connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) creole (0.5.0) @@ -648,10 +648,10 @@ GEM rack shoulda-matchers (2.8.0) activesupport (>= 3.0.0) - sidekiq (4.2.1) + sidekiq (4.2.7) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) - rack-protection (~> 1.5) + rack-protection (>= 1.5.0) redis (~> 3.2, >= 3.2.1) sidekiq-cron (0.4.4) redis-namespace (>= 1.5.2) @@ -928,7 +928,7 @@ DEPENDENCIES settingslogic (~> 2.0.9) sham_rack (~> 1.3.6) shoulda-matchers (~> 2.8.0) - sidekiq (~> 4.2) + sidekiq (~> 4.2.7) sidekiq-cron (~> 0.4.4) sidekiq-limit_fetch (~> 3.4) simplecov (= 0.12.0) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index b7c4673c8e39f8a8dacc2a6e88ab15e0c71e9634..779cb2eb1d91a4daf8e530a51e1efe29166b6ac3 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -51,6 +51,7 @@ /*= require_directory ./extensions */ /*= require_directory ./lib/utils */ /*= require_directory ./u2f */ +/*= require_directory ./droplab */ /*= require_directory . */ /*= require fuzzaldrin-plus */ /*= require es6-promise.auto */ diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 index 413117c22268317a4b4ba9fdff2832b55a061545..5d4262bc341f0463abb71f414d30135415d331fa 100644 --- a/app/assets/javascripts/dispatcher.js.es6 +++ b/app/assets/javascripts/dispatcher.js.es6 @@ -35,6 +35,9 @@ break; case 'projects:merge_requests:index': case 'projects:issues:index': + if(document.querySelector('.filtered-search') && gl.FilteredSearchManager) { + new gl.FilteredSearchManager(); + } Issuable.init(); new gl.IssuableBulkActions(); shortcut_handler = new ShortcutsNavigation(); diff --git a/app/assets/javascripts/droplab/droplab.js b/app/assets/javascripts/droplab/droplab.js new file mode 100644 index 0000000000000000000000000000000000000000..ed545ec8748629a54cbb66e68aa2aaec91b8aed2 --- /dev/null +++ b/app/assets/javascripts/droplab/droplab.js @@ -0,0 +1,701 @@ +/* eslint-disable */ +// Determine where to place this +if (typeof Object.assign != 'function') { + Object.assign = function (target, varArgs) { // .length of function is 2 + 'use strict'; + if (target == null) { // TypeError if undefined or null + throw new TypeError('Cannot convert undefined or null to object'); + } + + var to = Object(target); + + for (var index = 1; index < arguments.length; index++) { + var nextSource = arguments[index]; + + if (nextSource != null) { // Skip over if undefined or null + for (var nextKey in nextSource) { + // Avoid bugs when hasOwnProperty is shadowed + if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { + to[nextKey] = nextSource[nextKey]; + } + } + } + } + return to; + }; +} + +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.droplab = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o0){ + if(!listItems[currentIndex-1]){ + currentIndex = currentIndex-1; + } + listItems[currentIndex-1].classList.add('dropdown-active'); + } + }; + + var mousedown = function mousedown(e) { + var list = e.detail.hook.list; + removeHighlight(list); + list.show(); + currentIndex = 0; + isUpArrow = false; + isDownArrow = false; + }; + var selectItem = function selectItem(list) { + var listItems = removeHighlight(list); + var currentItem = listItems[currentIndex-1]; + var listEvent = new CustomEvent('click.dl', { + detail: { + list: list, + selected: currentItem, + data: currentItem.dataset, + }, + }); + list.list.dispatchEvent(listEvent); + list.hide(); + } + + var keydown = function keydown(e){ + var typedOn = e.target; + isUpArrow = false; + isDownArrow = false; + + if(e.detail.which){ + currentKey = e.detail.which; + if(currentKey === 13){ + selectItem(e.detail.hook.list); + return; + } + if(currentKey === 38) { + isUpArrow = true; + } + if(currentKey === 40) { + isDownArrow = true; + } + } else if(e.detail.key) { + currentKey = e.detail.key; + if(currentKey === 'Enter'){ + selectItem(e.detail.hook.list); + return; + } + if(currentKey === 'ArrowUp') { + isUpArrow = true; + } + if(currentKey === 'ArrowDown') { + isDownArrow = true; + } + } + if(isUpArrow){ currentIndex--; } + if(isDownArrow){ currentIndex++; } + if(currentIndex < 0){ currentIndex = 0; } + setMenuForArrows(e.detail.hook.list); + }; + + w.addEventListener('mousedown.dl', mousedown); + w.addEventListener('keydown.dl', keydown); + }; +}); +},{"./window":11}],10:[function(require,module,exports){ +var DATA_TRIGGER = require('./constants').DATA_TRIGGER; +var DATA_DROPDOWN = require('./constants').DATA_DROPDOWN; + +var toDataCamelCase = function(attr){ + return this.camelize(attr.split('-').slice(1).join(' ')); +}; + +// the tiniest damn templating I can do +var t = function(s,d){ + for(var p in d) + s=s.replace(new RegExp('{{'+p+'}}','g'), d[p]); + return s; +}; + +var camelize = function(str) { + return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(letter, index) { + return index == 0 ? letter.toLowerCase() : letter.toUpperCase(); + }).replace(/\s+/g, ''); +}; + +var closest = function(thisTag, stopTag) { + while(thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML'){ + thisTag = thisTag.parentNode; + } + return thisTag; +}; + +var isDropDownParts = function(target) { + if(target.tagName === 'HTML') { return false; } + return ( + target.hasAttribute(DATA_TRIGGER) || + target.hasAttribute(DATA_DROPDOWN) + ); +}; + +module.exports = { + toDataCamelCase: toDataCamelCase, + t: t, + camelize: camelize, + closest: closest, + isDropDownParts: isDropDownParts, +}; + +},{"./constants":1}],11:[function(require,module,exports){ +module.exports = function(callback) { + return (function() { + callback(this); + }).call(null); +}; + +},{}]},{},[8])(8) +}); diff --git a/app/assets/javascripts/droplab/droplab_ajax.js b/app/assets/javascripts/droplab/droplab_ajax.js new file mode 100644 index 0000000000000000000000000000000000000000..ebb518eeef46954fe986c1c8b992f46bfa1fe9ab --- /dev/null +++ b/app/assets/javascripts/droplab/droplab_ajax.js @@ -0,0 +1,78 @@ +/* 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 -1; + var focusEvent = false; + if (invalidKeyPressed || this.loading) { + return; + } + + if (this.timeout) { + clearTimeout(this.timeout); + } + + if (e.type === 'focus') { + focusEvent = true; + } + + 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 (searchValue === config.searchKey) { + return this.list.show(); + } + + this.loading = true; + + var params = config.params || {}; + params[config.searchKey] = searchValue; + var self = this; + this._loadUrlData(config.endpoint + this.buildParams(params)).then(function(data) { + if (config.loadingTemplate && self.hook.list.data === undefined || + self.hook.list.data.length === 0) { + const dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]'); + + if (dataLoadingTemplate) { + dataLoadingTemplate.outerHTML = self.listTemplate; + } + } + + if (!self.destroyed) { + var hookListChildren = self.hook.list.list.children; + var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic'); + + if (onlyDynamicList && data.length === 0) { + self.hook.list.hide(); + } + + self.hook.list.setData.call(self.hook.list, data); + } + self.notLoading(); + }); + }, + + _loadUrlData: function _loadUrlData(url) { + return new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest; + xhr.open('GET', url, true); + xhr.onreadystatechange = function () { + if(xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + var data = JSON.parse(xhr.responseText); + return resolve(data); + } else { + return reject([xhr.responseText, xhr.status]); + } + } + }; + xhr.send(); + }); + }, + + buildParams: function(params) { + if (!params) return ''; + var paramsArray = Object.keys(params).map(function(param) { + return param + '=' + (params[param] || ''); + }); + return '?' + paramsArray.join('&'); + }, + + destroy: function destroy() { + if (this.timeout) { + clearTimeout(this.timeout); + } + + this.destroyed = true; + + this.hook.trigger.removeEventListener('keydown.dl', this.debounceTriggerWrapper); + this.hook.trigger.removeEventListener('focus', this.debounceTriggerWrapper); + } + }; +}); +},{"../window":2}],2:[function(require,module,exports){ +module.exports = function(callback) { + return (function() { + callback(this); + }).call(null); +}; + +},{}]},{},[1])(1) +}); \ No newline at end of file diff --git a/app/assets/javascripts/droplab/droplab_filter.js b/app/assets/javascripts/droplab/droplab_filter.js new file mode 100644 index 0000000000000000000000000000000000000000..41a220831f96f753788238df1e1b7d2cadd866cf --- /dev/null +++ b/app/assets/javascripts/droplab/droplab_filter.js @@ -0,0 +1,60 @@ +/* eslint-disable */ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.filter||(g.filter = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o { + const dropdownData = [{ + icon: 'fa-pencil', + hint: 'author:', + tag: '<author>', + }, { + icon: 'fa-user', + hint: 'assignee:', + tag: '<assignee>', + }, { + icon: 'fa-clock-o', + hint: 'milestone:', + tag: '<milestone>', + }, { + icon: 'fa-tag', + hint: 'label:', + tag: '<label>', + }]; + + class DropdownHint extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input) { + super(droplab, dropdown, input); + this.config = { + droplabFilter: { + template: 'hint', + filterFunction: gl.DropdownUtils.filterMethod, + }, + }; + } + + 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(this.getSelectedTextWithoutEscaping(token)); + } + this.dismissDropdown(); + this.dispatchInputEvent(); + } + } + } + + getSelectedTextWithoutEscaping(selectedToken) { + const lastWord = this.input.value.split(' ').last(); + const lastWordIndex = selectedToken.indexOf(lastWord); + + return lastWordIndex === -1 ? selectedToken : selectedToken.slice(lastWord.length); + } + + renderContent() { + this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config); + + // Clone dropdownData to prevent it from being + // changed due to pass by reference + const data = []; + dropdownData.forEach((item) => { + data.push(Object.assign({}, item)); + }); + + this.droplab.setData(this.hookId, data); + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownHint = DropdownHint; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..54090375c5c21484da0dca8e525329150857e7d4 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 @@ -0,0 +1,44 @@ +/*= require filtered_search/filtered_search_dropdown */ + +/* global droplabAjax */ +/* global droplabFilter */ + +(() => { + class DropdownNonUser extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, endpoint, symbol) { + super(droplab, dropdown, input); + this.symbol = symbol; + this.config = { + droplabAjax: { + endpoint, + method: 'setData', + loadingTemplate: this.loadingTemplate, + }, + droplabFilter: { + filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol), + }, + }; + } + + itemClicked(e) { + super.itemClicked(e, (selected) => { + const title = selected.querySelector('.js-data-value').innerText.trim(); + return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`; + }); + } + + renderContent(forceShowList = false) { + this.droplab + .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config); + super.renderContent(forceShowList); + } + + init() { + this.droplab + .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownNonUser = DropdownNonUser; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_user.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..7a5669073120fc3a6a046ca827d12c1adb5c4d17 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_user.js.es6 @@ -0,0 +1,56 @@ +/*= require filtered_search/filtered_search_dropdown */ + +/* global droplabAjaxFilter */ + +(() => { + class DropdownUser extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input) { + super(droplab, dropdown, input); + 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; + const { value } = gl.FilteredSearchTokenizer.getLastTokenObject(query); + const valueWithoutColon = value.slice(1); + const hasPrefix = valueWithoutColon[0] === '@'; + const valueWithoutPrefix = valueWithoutColon.slice(1); + + return hasPrefix ? valueWithoutPrefix : valueWithoutColon; + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownUser = DropdownUser; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..3837b020fd3453e3eb30cd99ba79511c36147774 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 @@ -0,0 +1,68 @@ +(() => { + 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 { value } = gl.FilteredSearchTokenizer.getLastTokenObject(query); + const valueWithoutColon = value.slice(1).toLowerCase(); + const prefix = valueWithoutColon[0]; + const valueWithoutPrefix = valueWithoutColon.slice(1); + + const title = updatedItem.title.toLowerCase(); + + // Eg. filterSymbol = ~ for labels + const matchWithoutPrefix = + prefix === filterSymbol && title.indexOf(valueWithoutPrefix) !== -1; + const match = title.indexOf(valueWithoutColon) !== -1; + + updatedItem.droplab_hidden = !match && !matchWithoutPrefix; + return updatedItem; + } + + static filterMethod(item, query) { + const updatedItem = item; + const { value } = gl.FilteredSearchTokenizer.getLastTokenObject(query); + + if (value === '') { + updatedItem.droplab_hidden = false; + } else { + updatedItem.droplab_hidden = updatedItem.hint.indexOf(value) === -1; + } + + return updatedItem; + } + + static setDataValueIfSelected(selected) { + const dataValue = selected.getAttribute('data-value'); + + if (dataValue) { + gl.FilteredSearchDropdownManager.addWordToInput(dataValue); + } + + // Return boolean based on whether it was set + return dataValue !== null; + } + } + + window.gl = window.gl || {}; + gl.DropdownUtils = DropdownUtils; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..d188718c5f3c1de24e9f23f1f43fb82caa0b9292 --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -0,0 +1,7 @@ + // This is a manifest file that'll be compiled into including all the files listed below. + // Add new JavaScript code in separate files in this directory and they'll automatically + // be included in the compiled file accessible from http://example.com/assets/application.js + // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the + // the compiled file. + // + /*= require_tree . */ diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..68014e27462012f3e843a225e2488081698383fb --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 @@ -0,0 +1,101 @@ +(() => { + const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; + + class FilteredSearchDropdown { + constructor(droplab, dropdown, input) { + this.droplab = droplab; + this.hookId = input.getAttribute('data-id'); + this.input = input; + this.dropdown = dropdown; + this.loadingTemplate = `
+ +
`; + 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') { + const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(selected); + + if (!dataValueSet) { + const value = getValueFunction(selected); + gl.FilteredSearchDropdownManager.addWordToInput(value); + } + + this.dismissDropdown(); + } + } + + setAsDropdown() { + this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`); + } + + setOffset(offset = 0) { + this.dropdown.style.left = `${offset}px`; + } + + renderContent(forceShowList = false) { + if (forceShowList && this.getCurrentHook().list.hidden) { + this.getCurrentHook().list.show(); + } + } + + render(forceRenderContent = false, forceShowList = false) { + this.setAsDropdown(); + + const currentHook = this.getCurrentHook(); + const firstTimeInitialized = currentHook === null; + + if (firstTimeInitialized || forceRenderContent) { + this.renderContent(forceShowList); + } else if (currentHook.list.list.id !== this.dropdown.id) { + this.renderContent(forceShowList); + } + } + + dismissDropdown() { + // Focusing on the input will dismiss dropdown + // (default droplab functionality) + this.input.focus(); + } + + dispatchInputEvent() { + // Propogate input change to FilteredSearchDropdownManager + // so that it can determine which dropdowns to open + this.input.dispatchEvent(new Event('input')); + } + + hideDropdown() { + this.getCurrentHook().list.hide(); + } + + resetFilters() { + const hook = this.getCurrentHook(); + const data = hook.list.data; + const results = data.map((o) => { + const updated = o; + updated.droplab_hidden = false; + return updated; + }); + hook.list.render(results); + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchDropdown = FilteredSearchDropdown; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..ac71b5e44342449cb1213c741877553565bb87bc --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 @@ -0,0 +1,181 @@ +/* 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(word, addSpace = false) { + const input = document.querySelector('.filtered-search'); + const value = input.value; + const hasExistingValue = value.length !== 0; + const { lastToken } = gl.FilteredSearchTokenizer.processTokens(value); + + if ({}.hasOwnProperty.call(lastToken, 'key')) { + // Spaces inside the token means that the token value will be escaped by quotes + const hasQuotes = lastToken.value.indexOf(' ') !== -1; + + // Add 2 length to account for the length of the front and back quotes + const lengthToRemove = hasQuotes ? lastToken.value.length + 2 : lastToken.value.length; + input.value = value.slice(0, -1 * (lengthToRemove)); + } + + input.value += hasExistingValue && addSpace ? ` ${word}` : 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]; + const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); + + // Passing glArguments to `new gl[glClass]()` + 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 + && {}.hasOwnProperty.call(this.mapping, match.key); + const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; + + if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { + // `hint` is not listed as a tokenKey (since it is not a real `filter`) + const key = match && {}.hasOwnProperty.call(match, 'key') ? match.key : 'hint'; + this.load(key, firstLoad); + } + + gl.droplab = this.droplab; + } + + setDropdown() { + const { lastToken } = this.tokenizer.processTokens(this.filteredSearchInput.value); + + if (typeof lastToken === 'string') { + // Token is not fully initialized yet because it has no value + // Eg. token = 'label:' + const { tokenKey } = this.tokenizer.parseToken(lastToken); + this.loadDropdown(tokenKey); + } else if ({}.hasOwnProperty.call(lastToken, 'key')) { + // Token has been initialized into an object because it has a value + this.loadDropdown(lastToken.key); + } else { + this.loadDropdown('hint'); + } + } + + resetDropdowns() { + // Force current dropdown to hide + this.mapping[this.currentDropdown].reference.hideDropdown(); + + // Re-Load dropdown + this.setDropdown(); + + // Reset filters for current dropdown + this.mapping[this.currentDropdown].reference.resetFilters(); + + // Reposition dropdown so that it is aligned with cursor + this.updateDropdownOffset(this.currentDropdown); + } + + destroyDroplab() { + this.droplab.destroy(); + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..e5b37f1e6910be172436b8ad412e8a758dc6400b --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 @@ -0,0 +1,163 @@ +/* global Turbolinks */ + +(() => { + class FilteredSearchManager { + constructor() { + this.tokenizer = gl.FilteredSearchTokenizer; + this.filteredSearchInput = document.querySelector('.filtered-search'); + this.clearSearchButton = document.querySelector('.clear-search'); + 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 sanitizedKey = keyParam.slice(0, keyParam.indexOf('_')); + 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); + let tokenPath = ''; + + if (token.wildcard && condition) { + tokenPath = condition.url; + } else if (token.wildcard) { + // wildcard means that the token does not have a symbol + tokenPath = `${token.key}_${param}=${encodeURIComponent(token.value)}`; + } else { + // Remove the token symbol + tokenPath = `${token.key}_${param}=${encodeURIComponent(token.value.slice(1))}`; + } + + paths.push(tokenPath); + }); + + if (searchToken) { + paths.push(`search=${encodeURIComponent(searchToken)}`); + } + + Turbolinks.visit(`?scope=all&utf8=✓&${paths.join('&')}`); + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchManager = FilteredSearchManager; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..6bd9cb06362678112a603b019659bff65a5d4e0a --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 @@ -0,0 +1,75 @@ +(() => { + 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 => keyParam === `${tokenKey.key}_${tokenKey.param}`) || null; + } + + static searchByConditionUrl(url) { + return conditions.find(condition => condition.url === url) || null; + } + + static searchByConditionKeyValue(key, value) { + return conditions + .find(condition => condition.tokenKey === key && condition.value === value) || null; + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..57c0e8fc359ca10d444e771a6d3edb40d6bfd871 --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 @@ -0,0 +1,169 @@ +(() => { + class FilteredSearchTokenizer { + static parseToken(input) { + const colonIndex = input.indexOf(':'); + let tokenKey; + let tokenValue; + let tokenSymbol; + + if (colonIndex !== -1) { + tokenKey = input.slice(0, colonIndex).toLowerCase(); + tokenValue = input.slice(colonIndex + 1); + tokenSymbol = tokenValue[0]; + } + + return { + tokenKey, + tokenValue, + tokenSymbol, + }; + } + + static getLastTokenObject(input) { + const token = FilteredSearchTokenizer.getLastToken(input); + const colonIndex = token.indexOf(':'); + + const key = colonIndex !== -1 ? token.slice(0, colonIndex) : ''; + const value = colonIndex !== -1 ? token.slice(colonIndex) : token; + + return { + key, + value, + }; + } + + static getLastToken(input) { + let completeToken = false; + let completeQuotation = true; + let lastQuotation = ''; + let i = input.length; + + const doubleQuote = '"'; + const singleQuote = '\''; + while (!completeToken && i >= 0) { + const isDoubleQuote = input[i] === doubleQuote; + const isSingleQuote = input[i] === singleQuote; + + // If the second quotation is found + if ((lastQuotation === doubleQuote && isDoubleQuote) || + (lastQuotation === singleQuote && isSingleQuote)) { + completeQuotation = true; + } + + // Save the first quotation + if ((isDoubleQuote && lastQuotation === '') || + (isSingleQuote && lastQuotation === '')) { + lastQuotation = input[i]; + completeQuotation = false; + } + + if (completeQuotation && input[i] === ' ') { + completeToken = true; + } else { + i -= 1; + } + } + + // Adjust by 1 because of empty space + return input.slice(i + 1); + } + + static processTokens(input) { + const tokens = []; + let searchToken = ''; + let lastToken = ''; + + const inputs = input.split(' '); + let searchTerms = ''; + let lastQuotation = ''; + let incompleteToken = false; + + // Iterate through each word (broken up by spaces) + inputs.forEach((i) => { + if (incompleteToken) { + // Continue previous token as it had an escaped + // quote in the beginning + const prevToken = tokens.last(); + prevToken.value += ` ${i}`; + + // Remove last quotation from the value + const lastQuotationRegex = new RegExp(lastQuotation, 'g'); + prevToken.value = prevToken.value.replace(lastQuotationRegex, ''); + tokens[tokens.length - 1] = prevToken; + + // Check to see if this quotation completes the token value + if (i.indexOf(lastQuotation) !== -1) { + lastToken = tokens.last(); + incompleteToken = !incompleteToken; + } + + return; + } + + const colonIndex = i.indexOf(':'); + + if (colonIndex !== -1) { + const { tokenKey, tokenValue, tokenSymbol } = gl.FilteredSearchTokenizer.parseToken(i); + + const keyMatch = gl.FilteredSearchTokenKeys.searchByKey(tokenKey); + const symbolMatch = gl.FilteredSearchTokenKeys.searchBySymbol(tokenSymbol); + + const doubleQuoteOccurrences = tokenValue.split('"').length - 1; + const singleQuoteOccurrences = tokenValue.split('\'').length - 1; + + const doubleQuoteIndex = tokenValue.indexOf('"'); + const singleQuoteIndex = tokenValue.indexOf('\''); + + const doubleQuoteExist = doubleQuoteIndex !== -1; + const singleQuoteExist = singleQuoteIndex !== -1; + + const doubleQuoteExistOnly = doubleQuoteExist && !singleQuoteExist; + const doubleQuoteIsBeforeSingleQuote = + doubleQuoteExist && singleQuoteExist && doubleQuoteIndex < singleQuoteIndex; + + const singleQuoteExistOnly = singleQuoteExist && !doubleQuoteExist; + const singleQuoteIsBeforeDoubleQuote = + doubleQuoteExist && singleQuoteExist && singleQuoteIndex < doubleQuoteIndex; + + if ((doubleQuoteExistOnly || doubleQuoteIsBeforeSingleQuote) + && doubleQuoteOccurrences % 2 !== 0) { + // " is found and is in front of ' (if any) + lastQuotation = '"'; + incompleteToken = true; + } else if ((singleQuoteExistOnly || singleQuoteIsBeforeDoubleQuote) + && singleQuoteOccurrences % 2 !== 0) { + // ' is found and is in front of " (if any) + lastQuotation = '\''; + incompleteToken = true; + } + + if (keyMatch && tokenValue.length > 0) { + tokens.push({ + key: keyMatch.key, + value: tokenValue, + wildcard: !symbolMatch, + }); + lastToken = tokens.last(); + + return; + } + } + + // Add space for next term + searchTerms += `${i} `; + lastToken = i; + }, this); + + searchToken = searchTerms.trim(); + + return { + tokens, + searchToken, + lastToken, + }; + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchTokenizer = FilteredSearchTokenizer; +})(); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8fa80502d922df524805d5bf4445ce80e3b33af4..7e5d6077a1c585bd4dbc200f439670086500d614 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -110,6 +110,28 @@ 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.getParameterByName = function(name) { + var url = window.location.href; + var param = name.replace(/[[\]]/g, '\\$&'); + var regex = new RegExp('[?&]' + param + '(=([^&#]*)|&|#|$)'); + var results = regex.exec(url); + + if (!results) { + return null; + } + + if (!results[2]) { + return ''; + } + return decodeURIComponent(results[2].replace(/\+/g, ' ')); + }; + gl.utils.isMetaKey = function(e) { return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; }; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index ac44b81ee22e33b7e693547456a99d8b48da8e70..1d5273621217b740449cc9afcfecf1a55a457a4b 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -17,6 +17,21 @@ gl.text.replaceRange = function(s, start, end, substitute) { return s.substring(0, start) + substitute + s.substring(end); }; + gl.text.getTextWidth = function(text, font) { + /** + * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. + * + * @param {String} text The text to be rendered. + * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana"). + * + * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 + */ + // re-use canvas object for better performance + var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas')); + var context = canvas.getContext('2d'); + context.font = font; + return context.measureText(text).width; + }; gl.text.selectedText = function(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); }; diff --git a/app/assets/javascripts/merge_request_widget.js.es6 b/app/assets/javascripts/merge_request_widget.js.es6 index d9495e503888ac4af03e7a0c5a724c3110bc8b6c..7022aa1263b8ee55b57c2dd12c754c137d9316c3 100644 --- a/app/assets/javascripts/merge_request_widget.js.es6 +++ b/app/assets/javascripts/merge_request_widget.js.es6 @@ -40,19 +40,26 @@ $('#modal_merge_info').modal({ show: false }); - this.firstCICheck = true; - this.readyForCICheck = false; - this.readyForCIEnvironmentCheck = false; - this.cancel = false; - clearInterval(this.fetchBuildStatusInterval); - clearInterval(this.fetchBuildEnvironmentStatusInterval); this.clearEventListeners(); this.addEventListeners(); this.getCIStatus(false); - this.getCIEnvironmentsStatus(); this.retrieveSuccessIcon(); - this.pollCIStatus(); - this.pollCIEnvironmentsStatus(); + + this.ciStatusInterval = new global.SmartInterval({ + callback: this.getCIStatus.bind(this, true), + startingInterval: 10000, + maxInterval: 30000, + hiddenInterval: 120000, + incrementByFactorOf: 5000, + }); + this.ciEnvironmentStatusInterval = new global.SmartInterval({ + callback: this.getCIEnvironmentsStatus.bind(this), + startingInterval: 30000, + maxInterval: 120000, + hiddenInterval: 240000, + incrementByFactorOf: 15000, + immediateExecution: true, + }); notifyPermissions(); } @@ -60,10 +67,6 @@ return $(document).off('page:change.merge_request'); }; - MergeRequestWidget.prototype.cancelPolling = function() { - return this.cancel = true; - }; - MergeRequestWidget.prototype.addEventListeners = function() { var allowedPages; allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes']; @@ -72,9 +75,6 @@ var page; page = $('body').data('page').split(':').last(); if (allowedPages.indexOf(page) < 0) { - clearInterval(_this.fetchBuildStatusInterval); - clearInterval(_this.fetchBuildEnvironmentStatusInterval); - _this.cancelPolling(); return _this.clearEventListeners(); } }; @@ -114,6 +114,11 @@ }); }; + MergeRequestWidget.prototype.cancelPolling = function () { + this.ciStatusInterval.cancel(); + this.ciEnvironmentStatusInterval.cancel(); + }; + MergeRequestWidget.prototype.getMergeStatus = function() { return $.get(this.opts.merge_check_url, function(data) { return $('.mr-state-widget').replaceWith(data); @@ -131,18 +136,6 @@ } }; - MergeRequestWidget.prototype.pollCIStatus = function() { - return this.fetchBuildStatusInterval = setInterval(((function(_this) { - return function() { - if (!_this.readyForCICheck) { - return; - } - _this.getCIStatus(true); - return _this.readyForCICheck = false; - }; - })(this)), 10000); - }; - MergeRequestWidget.prototype.getCIStatus = function(showNotification) { var _this; _this = this; @@ -150,23 +143,17 @@ return $.getJSON(this.opts.ci_status_url, (function(_this) { return function(data) { var message, status, title; - if (_this.cancel) { - return; - } - _this.readyForCICheck = true; if (data.status === '') { return; } if (data.environments && data.environments.length) _this.renderEnvironments(data.environments); - if (_this.firstCICheck || data.status !== _this.opts.ci_status && (data.status != null)) { + if (data.status !== _this.opts.ci_status && (data.status != null)) { _this.opts.ci_status = data.status; _this.showCIStatus(data.status); if (data.coverage) { _this.showCICoverage(data.coverage); } - // The first check should only update the UI, a notification - // should only be displayed on status changes - if (showNotification && !_this.firstCICheck) { + if (showNotification) { status = _this.ciLabelForStatus(data.status); if (status === "preparing") { title = _this.opts.ci_title.preparing; @@ -184,24 +171,13 @@ return Turbolinks.visit(_this.opts.builds_path); }); } - return _this.firstCICheck = false; } }; })(this)); }; - MergeRequestWidget.prototype.pollCIEnvironmentsStatus = function() { - this.fetchBuildEnvironmentStatusInterval = setInterval(() => { - if (!this.readyForCIEnvironmentCheck) return; - this.getCIEnvironmentsStatus(); - this.readyForCIEnvironmentCheck = false; - }, 300000); - }; - MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() { $.getJSON(this.opts.ci_environments_status_url, (environments) => { - if (this.cancel) return; - this.readyForCIEnvironmentCheck = true; if (environments && environments.length) this.renderEnvironments(environments); }); }; @@ -212,11 +188,11 @@ if ($(`.mr-state-widget #${ environment.id }`).length) return; const $template = $(DEPLOYMENT_TEMPLATE); if (!environment.external_url || !environment.external_url_formatted) $('.js-environment-link', $template).remove(); - + if (!environment.stop_url) { $('.js-stop-env-link', $template).remove(); } - + if (environment.deployed_at && environment.deployed_at_formatted) { environment.deployed_at = gl.utils.getTimeago().format(environment.deployed_at, 'gl_en') + '.'; } else { diff --git a/app/assets/javascripts/pipelines.js.es6 b/app/assets/javascripts/pipelines.js.es6 index 72c6c4a1fcd393847262ee7322a8cec5a9a109ab..fb95648e1c74cf5432796f484fc461215ade89c8 100644 --- a/app/assets/javascripts/pipelines.js.es6 +++ b/app/assets/javascripts/pipelines.js.es6 @@ -4,7 +4,7 @@ ((global) => { class Pipelines { - constructor(options) { + constructor(options = {}) { if (options.initTabs && options.tabsOptions) { new global.LinkedTabs(options.tabsOptions); @@ -14,9 +14,11 @@ } addMarginToBuildColumns() { - this.pipelineGraph = document.querySelector('.pipeline-graph'); - const secondChildBuildNodes = document.querySelector('.pipeline-graph').querySelectorAll('.build:nth-child(2)'); - for (buildNodeIndex in secondChildBuildNodes) { + this.pipelineGraph = document.querySelector('.js-pipeline-graph'); + + const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)'); + + for (const buildNodeIndex in secondChildBuildNodes) { const buildNode = secondChildBuildNodes[buildNodeIndex]; const firstChildBuildNode = buildNode.previousElementSibling; if (!firstChildBuildNode || !firstChildBuildNode.matches('.build')) continue; @@ -28,6 +30,7 @@ const columnBuilds = previousColumn.querySelectorAll('.build'); if (columnBuilds.length === 1) previousColumn.classList.add('no-margin'); } + this.pipelineGraph.classList.remove('hidden'); } } diff --git a/app/assets/javascripts/search_autocomplete.js.es6 b/app/assets/javascripts/search_autocomplete.js.es6 index 5fa94556501560a74c0fa081a2bee0f5a8654cea..a226b7ca0cb5884d68f8990b64082e953ae92b81 100644 --- a/app/assets/javascripts/search_autocomplete.js.es6 +++ b/app/assets/javascripts/search_autocomplete.js.es6 @@ -141,8 +141,9 @@ } getCategoryContents() { - var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, utils; + var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, userName, utils; userId = gon.current_user_id; + userName = gon.current_username; utils = gl.utils, projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions; if (utils.isInGroupsPage() && groupOptions) { options = groupOptions[utils.getGroupSlug()]; @@ -157,10 +158,10 @@ header: "" + name }, { text: 'Issues assigned to me', - url: issuesPath + "/?assignee_id=" + userId + url: issuesPath + "/?assignee_username=" + userName }, { text: "Issues I've created", - url: issuesPath + "/?author_id=" + userId + url: issuesPath + "/?author_username=" + userName }, 'separator', { text: 'Merge requests assigned to me', url: mrPath + "/?assignee_id=" + userId diff --git a/app/assets/javascripts/smart_interval.js.es6 b/app/assets/javascripts/smart_interval.js.es6 index 5eb15dba79b78eed41a4c21e2e19fca986554584..40f67637c7c8f6f5a570840a531500d09ec028e4 100644 --- a/app/assets/javascripts/smart_interval.js.es6 +++ b/app/assets/javascripts/smart_interval.js.es6 @@ -7,24 +7,31 @@ (() => { class SmartInterval { /** - * @param { function } callback Function to be called on each iteration (required) - * @param { milliseconds } startingInterval `currentInterval` is set to this initially - * @param { milliseconds } maxInterval `currentInterval` will be incremented to this - * @param { integer } incrementByFactorOf `currentInterval` is incremented by this factor - * @param { boolean } lazyStart Configure if timer is initialized on instantiation or lazily + * @param { function } opts.callback Function to be called on each iteration (required) + * @param { milliseconds } opts.startingInterval `currentInterval` is set to this initially + * @param { milliseconds } opts.maxInterval `currentInterval` will be incremented to this + * @param { milliseconds } opts.hiddenInterval `currentInterval` is set to this + * when the page is hidden + * @param { integer } opts.incrementByFactorOf `currentInterval` is incremented by this factor + * @param { boolean } opts.lazyStart Configure if timer is initialized on + * instantiation or lazily + * @param { boolean } opts.immediateExecution Configure if callback should + * be executed before the first interval. */ - constructor({ callback, startingInterval, maxInterval, incrementByFactorOf, lazyStart }) { + constructor(opts = {}) { this.cfg = { - callback, - startingInterval, - maxInterval, - incrementByFactorOf, - lazyStart, + callback: opts.callback, + startingInterval: opts.startingInterval, + maxInterval: opts.maxInterval, + hiddenInterval: opts.hiddenInterval, + incrementByFactorOf: opts.incrementByFactorOf, + lazyStart: opts.lazyStart, + immediateExecution: opts.immediateExecution, }; this.state = { intervalId: null, - currentInterval: startingInterval, + currentInterval: this.cfg.startingInterval, pageVisibility: 'visible', }; @@ -36,6 +43,11 @@ const cfg = this.cfg; const state = this.state; + if (cfg.immediateExecution) { + cfg.immediateExecution = false; + cfg.callback(); + } + state.intervalId = window.setInterval(() => { cfg.callback(); @@ -54,14 +66,29 @@ this.stopTimer(); } + onVisibilityHidden() { + if (this.cfg.hiddenInterval) { + this.setCurrentInterval(this.cfg.hiddenInterval); + this.resume(); + } else { + this.cancel(); + } + } + // start a timer, using the existing interval resume() { this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped this.start(); } + onVisibilityVisible() { + this.cancel(); + this.start(); + } + destroy() { this.cancel(); + document.removeEventListener('visibilitychange', this.handleVisibilityChange); $(document).off('visibilitychange').off('page:before-unload'); } @@ -80,11 +107,7 @@ initVisibilityChangeHandling() { // cancel interval when tab no longer shown (prevents cached pages from polling) - $(document) - .off('visibilitychange').on('visibilitychange', (e) => { - this.state.pageVisibility = e.target.visibilityState; - this.handleVisibilityChange(); - }); + document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); } initPageUnloadHandling() { @@ -92,10 +115,11 @@ $(document).on('page:before-unload', () => this.cancel()); } - handleVisibilityChange() { - const state = this.state; - - const intervalAction = state.pageVisibility === 'hidden' ? this.cancel : this.resume; + handleVisibilityChange(e) { + this.state.pageVisibility = e.target.visibilityState; + const intervalAction = this.isPageVisible() ? + this.onVisibilityVisible : + this.onVisibilityHidden; intervalAction.apply(this); } @@ -111,6 +135,7 @@ incrementInterval() { const cfg = this.cfg; const currentInterval = this.getCurrentInterval(); + if (cfg.hiddenInterval && !this.isPageVisible()) return; let nextInterval = currentInterval * cfg.incrementByFactorOf; if (nextInterval > cfg.maxInterval) { @@ -120,6 +145,8 @@ this.setCurrentInterval(nextInterval); } + isPageVisible() { return this.state.pageVisibility === 'visible'; } + stopTimer() { const state = this.state; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 19827943385a10ca75db2362a9d2f14eb46cb6e2..8b7cb2454207eff6c4e05b0da9dd492e44bf36d6 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -23,3 +23,114 @@ } } +.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; + + .dropdown-label-box { + border-color: $white-light; + border-style: solid; + border-width: 1px; + width: 17px; + height: 17px; + } + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + text-decoration: none; + + .avatar { + border-color: $white-light; + } + } + } + + .dropdown-light-content { + font-size: 14px; + font-weight: 400; + } + + .dropdown-user { + display: -webkit-flex; + display: flex; + } + + .dropdown-user-details { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + } +} + +.hint-dropdown { + width: 250px; +} + +.filter-dropdown-loading { + padding: 8px 16px; +} diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index abfdd7a759d1e4bb0262aed86545b253bd69930c..b98070925535b7e1d78b97458bbb1d63c6cfc103 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -23,12 +23,16 @@ margin-right: 0; } - .issues-details-filters, + .issues-details-filters:not(.filtered-search-block), .dash-projects-filters, .check-all-holder { display: none; } + .issues-holder .issue-check { + display: none; + } + .rss-btn { display: none; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 18716813c48c10b0b6e44a763accb49a1d51bb2b..27f35301a26961d7d1b39cf245f3fb33746aecb4 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -296,6 +296,11 @@ $dropdown-toggle-active-border-color: darken($dropdown-toggle-border-color, 14%) $dropdown-toggle-icon-color: #c4c4c4; $dropdown-toggle-hover-icon-color: darken($dropdown-toggle-icon-color, 7%); +/* +* Filtered Search +*/ +$dropdown-hover-color: #3b86ff; + /* * Buttons */ diff --git a/app/assets/stylesheets/pages/snippets.scss b/app/assets/stylesheets/pages/snippets.scss index 857eb76131a9696c20a576b1b5f9e2bd7b91b368..ff13b86acf0da338756afe9fc8b728b12aaf211c 100644 --- a/app/assets/stylesheets/pages/snippets.scss +++ b/app/assets/stylesheets/pages/snippets.scss @@ -1,3 +1,13 @@ +.snippet-row { + .title { + margin-bottom: 2px; + } + + .snippet-filename { + padding: 0 2px; + } +} + .snippet-form-holder .file-holder .file-title { padding: 2px; } @@ -24,11 +34,17 @@ padding-bottom: $gl-padding; } +.snippet-header { + padding: $gl-padding 0; +} + .snippet-title { font-size: 24px; font-weight: 600; - padding: $gl-padding; - padding-left: 0; +} + +.snippet-edited-ago { + color: $gray-darkest; } .snippet-actions { diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index e290a0eadda814488d8a3893abbe7f43e3a87f0b..0720be2e55d15ea3dd17e68e56476f2897d83933 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -19,10 +19,12 @@ class Projects::SnippetsController < Projects::ApplicationController respond_to :html def index - @snippets = SnippetsFinder.new.execute(current_user, { + @snippets = SnippetsFinder.new.execute( + current_user, filter: :by_project, - project: @project - }) + project: @project, + scope: params[:scope] + ) @snippets = @snippets.page(params[:page]) end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index b4c14d05eaf989a3a90fb7cfb4406d9698991faa..2afde8ece6507556b644fc2960b117a3f772a14f 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -165,31 +165,43 @@ def labels end end - def assignee? + def assignee_id? params[:assignee_id].present? end + def assignee_username? + params[:assignee_username].present? + end + def assignee return @assignee if defined?(@assignee) @assignee = - if assignee? && params[:assignee_id] != NONE + if assignee_id? && params[:assignee_id] != NONE User.find(params[:assignee_id]) + elsif assignee_username? && params[:assignee_username] != NONE + User.find_by(username: params[:assignee_username]) else nil end end - def author? + def author_id? params[:author_id].present? end + def author_username? + params[:author_username].present? + end + def author return @author if defined?(@author) @author = - if author? && params[:author_id] != NONE + if author_id? && params[:author_id] != NONE User.find(params[:author_id]) + elsif author_username? && params[:author_username] != NONE + User.find_by(username: params[:author_username]) else nil end @@ -263,7 +275,7 @@ def sort(items) end def by_assignee(items) - if assignee? + if assignee_id? || assignee_username? items = items.where(assignee_id: assignee.try(:id)) end @@ -271,7 +283,7 @@ def by_assignee(items) end def by_author(items) - if author? + if author_id? || author_username? items = items.where(author_id: author.try(:id)) end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index 0586a923a74b4be0fc2fe7d1836a783712dc8df6..da6e6e87a6ff336cae9660a6415543220c449f1f 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -11,7 +11,7 @@ def execute(current_user, params = {}) when :by_user then by_user(current_user, user, params[:scope]) when :by_project - by_project(current_user, params[:project]) + by_project(current_user, params[:project], params[:scope]) end end @@ -32,35 +32,35 @@ def snippets(current_user) def by_user(current_user, user, scope) snippets = user.snippets.fresh - return snippets.are_public unless current_user - - if user == current_user - case scope - when 'are_internal' then - snippets.are_internal - when 'are_private' then - snippets.are_private - when 'are_public' then - snippets.are_public - else - snippets - end + if current_user + include_private = user == current_user + by_scope(snippets, scope, include_private) else - snippets.public_and_internal + snippets.are_public end end - def by_project(current_user, project) + def by_project(current_user, project, scope) snippets = project.snippets.fresh if current_user - if project.team.member?(current_user) || current_user.admin? - snippets - else - snippets.public_and_internal - end + include_private = project.team.member?(current_user) || current_user.admin? + by_scope(snippets, scope, include_private) else snippets.are_public end end + + def by_scope(snippets, scope = nil, include_private = false) + case scope.to_s + when 'are_private' + include_private ? snippets.are_private : Snippet.none + when 'are_internal' + snippets.are_internal + when 'are_public' + snippets.are_public + else + include_private ? snippets : snippets.public_and_internal + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c816b616631c7992513bb3399bf93d383206fb86..a112928c6dedfb94d551c464d6aa6cd1c0a6a568 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -244,7 +244,9 @@ def page_filter_path(options = {}) scope: params[:scope], milestone_title: params[:milestone_title], assignee_id: params[:assignee_id], + assignee_username: params[:assignee_username], author_id: params[:author_id], + author_username: params[:author_username], search: params[:search], label_name: params[:label_name] } diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 7e33a5620775641a918eb28c8a3135b5dff82552..8c02b4061ca0476e8f0e7521ee0bc30badeb222e 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -8,6 +8,17 @@ def reliable_snippet_path(snippet, opts = nil) end end + # Return the path of a snippets index for a user or for a project + # + # @returns String, path to snippet index + def subject_snippets_path(subject = nil, opts = nil) + if subject.is_a?(Project) + namespace_project_snippets_path(subject.namespace, subject, opts) + else # assume subject === User + dashboard_snippets_path(opts) + end + end + # Get an array of line numbers surrounding a matching # line, bounded by min/max. # diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index 86a06321e212d48df41b5f48cd011d218f9ff9ff..fe6d7aabb22e45b5ff552e199289b9e367d46c40 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -3,7 +3,8 @@ class BuildkiteService < CiService ENDPOINT = "https://buildkite.com" - prop_accessor :project_url, :token, :enable_ssl_verification + prop_accessor :project_url, :token + boolean_accessor :enable_ssl_verification validates :project_url, presence: true, url: true, if: :activated? validates :token, presence: true, if: :activated? diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb index 5e4dd101c53e6a9713f50168cc4a8fcc10224160..adc78a427ee30e82b446a9c44ab1854127cb561b 100644 --- a/app/models/project_services/drone_ci_service.rb +++ b/app/models/project_services/drone_ci_service.rb @@ -1,5 +1,6 @@ class DroneCiService < CiService - prop_accessor :drone_url, :token, :enable_ssl_verification + prop_accessor :drone_url, :token + boolean_accessor :enable_ssl_verification validates :drone_url, presence: true, url: true, if: :activated? validates :token, presence: true, if: :activated? diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index e0083c43adb260b7bb0765693d9af7aeacb9520a..79285cbd26de1182ea5cb23d25becc3ccdd623dc 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -1,6 +1,6 @@ class EmailsOnPushService < Service - prop_accessor :send_from_committer_email - prop_accessor :disable_diffs + boolean_accessor :send_from_committer_email + boolean_accessor :disable_diffs prop_accessor :recipients validates :recipients, presence: true, if: :activated? @@ -24,20 +24,20 @@ def execute(push_data) return unless supported_events.include?(push_data[:object_kind]) EmailsOnPushWorker.perform_async( - project_id, - recipients, - push_data, - send_from_committer_email: send_from_committer_email?, - disable_diffs: disable_diffs? + project_id, + recipients, + push_data, + send_from_committer_email: send_from_committer_email?, + disable_diffs: disable_diffs? ) end def send_from_committer_email? - self.send_from_committer_email == "1" + Gitlab::Utils.to_boolean(self.send_from_committer_email) end def disable_diffs? - self.disable_diffs == "1" + Gitlab::Utils.to_boolean(self.disable_diffs) end def fields diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index 660a8ae3421ec13b447b270fea5f0184554bf964..915f6fed74c01cf357c81512a7f0341ee63b7bcc 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -8,8 +8,8 @@ class HipchatService < Service ul ol li dl dt dd ] - prop_accessor :token, :room, :server, :notify, :color, :api_version - boolean_accessor :notify_only_broken_builds + prop_accessor :token, :room, :server, :color, :api_version + boolean_accessor :notify_only_broken_builds, :notify validates :token, presence: true, if: :activated? def initialize_properties @@ -75,7 +75,7 @@ def gate end def message_options(data = nil) - { notify: notify.present? && notify == '1', color: message_color(data) } + { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) } end def create_message(data) diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index ce7d1c5d5b136cec34aa69d8004e0dcfdbf102fe..7355918feab407b6c52ad74f54d97e0e1e9b8570 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -2,7 +2,8 @@ class IrkerService < Service prop_accessor :server_host, :server_port, :default_irc_uri - prop_accessor :colorize_messages, :recipients, :channels + prop_accessor :recipients, :channels + boolean_accessor :colorize_messages validates :recipients, presence: true, if: :activated? before_validation :get_channels diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index db5f2bf9b2e5dd5b73e757522a43404a362bd015..4d410f66c55c7227aa767da5ac509363a39d01fe 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -35,7 +35,7 @@ def commit_change(action) success else error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically. - It may have already been #{action.to_s.dasherize}, or a more recent commit may have updated some of its content." + A #{action.to_s.dasherize} may have already been performed with this #{@commit.change_type_title(current_user)}, or a more recent commit may have updated some of its content." raise ChangeError, error_msg end end diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml index b25e8ea1f0cdfb30cef06192024684e9508589a4..02e90bbfa55336e642145df7850e0eb7fd639c72 100644 --- a/app/views/dashboard/_snippets_head.html.haml +++ b/app/views/dashboard/_snippets_head.html.haml @@ -1,7 +1,13 @@ -%ul.nav-links - = nav_link(page: dashboard_snippets_path, html_options: {class: 'home'}) do - = link_to dashboard_snippets_path, title: 'Your snippets', data: {placement: 'right'} do - Your Snippets - = nav_link(page: explore_snippets_path) do - = link_to explore_snippets_path, title: 'Explore snippets', data: {placement: 'right'} do - Explore Snippets +.top-area + %ul.nav-links + = nav_link(page: dashboard_snippets_path, html_options: {class: 'home'}) do + = link_to dashboard_snippets_path, title: 'Your snippets', data: {placement: 'right'} do + Your Snippets + = nav_link(page: explore_snippets_path) do + = link_to explore_snippets_path, title: 'Explore snippets', data: {placement: 'right'} do + Explore Snippets + + - if current_user + .nav-controls.hidden-xs + = link_to new_snippet_path, class: "btn btn-new", title: "New snippet" do + New snippet diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml index b2af438ea576706cc167915984753089f15d87fb..85cbe0bf0e6e7a6e754c8a0b9413c6be95c1ab7b 100644 --- a/app/views/dashboard/snippets/index.html.haml +++ b/app/views/dashboard/snippets/index.html.haml @@ -2,41 +2,11 @@ - header_title "Snippets", dashboard_snippets_path = render 'dashboard/snippets_head' += render partial: 'snippets/snippets_scope_menu', locals: { include_private: true } -.nav-block - .controls.hidden-xs - = link_to new_snippet_path, class: "btn btn-new", title: "New snippet" do - = icon('plus') - New snippet +.visible-xs +   + = link_to new_snippet_path, class: "btn btn-new btn-block", title: "New snippet" do + New snippet - .nav-links.snippet-scope-menu - %li{ class: ("active" unless params[:scope]) } - = link_to dashboard_snippets_path do - All - %span.badge - = current_user.snippets.count - - %li{ class: ("active" if params[:scope] == "are_private") } - = link_to dashboard_snippets_path(scope: 'are_private') do - Private - %span.badge - = current_user.snippets.are_private.count - - %li{ class: ("active" if params[:scope] == "are_internal") } - = link_to dashboard_snippets_path(scope: 'are_internal') do - Internal - %span.badge - = current_user.snippets.are_internal.count - - %li{ class: ("active" if params[:scope] == "are_public") } - = link_to dashboard_snippets_path(scope: 'are_public') do - Public - %span.badge - = current_user.snippets.are_public.count - - .visible-xs - = link_to new_snippet_path, class: "btn btn-new btn-block", title: "New snippet" do - = icon('plus') - New snippet - -= render 'snippets/snippets' += render partial: 'snippets/snippets', locals: { link_project: true } diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml index 7def9eacdc9f3e11d57c26aad69b254e22150dc5..e5706d0473628731828d63d942443b6a12d64cc0 100644 --- a/app/views/explore/snippets/index.html.haml +++ b/app/views/explore/snippets/index.html.haml @@ -6,12 +6,4 @@ - else = render 'explore/head' -.row-content-block - - if current_user - = link_to new_snippet_path, class: "btn btn-new btn-wide-on-sm pull-right", title: "New snippet" do - New snippet - - .oneline - Public snippets created by you and other users are listed here - -= render 'snippets/snippets' += render partial: 'snippets/snippets', locals: { link_project: true } diff --git a/app/views/projects/ci/builds/_build_pipeline.html.haml b/app/views/projects/ci/builds/_build_pipeline.html.haml index 423a1282eb2f6129b2b257b96e838cfb5231f84d..ad1a7360a8b3e827318de965eeee0bb337a81431 100644 --- a/app/views/projects/ci/builds/_build_pipeline.html.haml +++ b/app/views/projects/ci/builds/_build_pipeline.html.haml @@ -1,10 +1,10 @@ - is_playable = subject.playable? && can?(current_user, :update_build, @project) - if is_playable - = link_to play_namespace_project_build_path(subject.project.namespace, subject.project, subject, return_to: request.original_url), method: :post, data: { toggle: 'tooltip', title: "#{subject.name} - play", container: '.pipeline-graph', placement: 'bottom' } do + = link_to play_namespace_project_build_path(subject.project.namespace, subject.project, subject, return_to: request.original_url), method: :post, data: { toggle: 'tooltip', title: "#{subject.name} - play", container: '.js-pipeline-graph', placement: 'bottom' } do = ci_icon_for_status('play') .ci-status-text= subject.name - elsif can?(current_user, :read_build, @project) - = link_to namespace_project_build_path(subject.project.namespace, subject.project, subject), data: { toggle: 'tooltip', title: "#{subject.name} - #{subject.status}", container: '.pipeline-graph', placement: 'bottom' } do + = link_to namespace_project_build_path(subject.project.namespace, subject.project, subject), data: { toggle: 'tooltip', title: "#{subject.name} - #{subject.status}", container: '.js-pipeline-graph', placement: 'bottom' } do %span{class: "ci-status-icon ci-status-icon-#{subject.status}"} = ci_icon_for_status(subject.status) .ci-status-text= subject.name diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index f6e3d5e76f5df4b22af3c4eacbffcb3ce7aa4826..782f558e8b081c59fd6b43787d6c63f54a92d026 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -13,7 +13,7 @@ %a.close{href: "#", "data-dismiss" => "modal"} × %h3.page-title== #{label} this #{commit.change_type_title(current_user)} .modal-body - = form_tag send("#{type.underscore}_namespace_project_commit_path", @project.namespace, @project, commit.id), method: :post, remote: false, class: 'form-horizontal js-#{type}-form js-requires-input' do + = form_tag send("#{type.underscore}_namespace_project_commit_path", @project.namespace, @project, commit.id), method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do .form-group.branch = label_tag 'target_branch', target_label, class: 'control-label' .col-sm-10 @@ -23,12 +23,11 @@ - if can?(current_user, :push_code, @project) .js-create-merge-request-container .checkbox - - nonce = SecureRandom.hex - = label_tag "create_merge_request-#{nonce}" do - = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" + = label_tag do + = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: nil Start a new merge request with these changes - else - = hidden_field_tag 'create_merge_request', 1 + = hidden_field_tag 'create_merge_request', 1, id: nil .form-actions = submit_tag label, class: 'btn btn-create' = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal" diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml index c7b5c1124b37a56cbb75e2de5a454f79a472c0b2..08d3443b3d02f8afbf68052b25af604805aa636e 100644 --- a/app/views/projects/commit/_pipeline.html.haml +++ b/app/views/projects/commit/_pipeline.html.haml @@ -24,7 +24,7 @@ in = time_interval_in_words pipeline.duration - .row-content-block.build-content.middle-block.hidden + .row-content-block.build-content.middle-block.js-pipeline-graph.hidden = render "projects/pipelines/graph", pipeline: pipeline - if pipeline.yaml_errors.present? diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml index 7b82d913d293a306efceeb27987173c849c5a175..1bba04431542e1128b7514e1a9dc970534c32193 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml @@ -1,4 +1,4 @@ -%a{ data: { toggle: 'tooltip', title: "#{subject.name} - #{subject.status}", container: '.pipeline-graph', placement: 'bottom' } } +%a{ data: { toggle: 'tooltip', title: "#{subject.name} - #{subject.status}", container: '.js-pipeline-graph', placement: 'bottom' } } - if subject.target_url = link_to subject.target_url do %span{class: "ci-status-icon ci-status-icon-#{subject.status}"} diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 26f3f0ac292c105bb3bc9b1591cc3165af1eabd0..18e8372ecab67019a842486d613aa9c4825ed4d3 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -6,6 +6,9 @@ = content_for :sub_nav do = render "projects/issues/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('filtered_search/filtered_search_bundle.js') + = content_for :meta_tags do - if current_user = auto_discovery_link_tag(:atom, url_for(params.merge(format: :atom, private_token: current_user.private_token)), title: "#{@project.name} issues") @@ -20,7 +23,6 @@ = icon('rss') %span.icon-label Subscribe - = render 'shared/issuable/search_form', path: namespace_project_issues_path(@project.namespace, @project) - if can? current_user, :create_issue, @project = link_to new_namespace_project_issue_path(@project.namespace, @project, @@ -30,7 +32,7 @@ title: "New Issue", id: "new_issue_link" do New Issue - = render 'shared/issuable/filter', type: :issues + = render 'shared/issuable/search_bar', type: :issues .issues-holder = render 'issues' diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 739e59308224cbf46fcbd92fe77c6e6a7a2f1649..88af41aa83516987397dc909fad01b86fced2fd3 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -12,7 +12,7 @@ .tab-content #js-tab-pipeline.tab-pane - .build-content.middle-block + .build-content.middle-block.js-pipeline-graph = render "projects/pipelines/graph", pipeline: pipeline #js-tab-builds.tab-pane diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml index 32e1f8a21b004a4de7ca7e35358593a179e57f8b..068a66103501e17fbdb7d8130c9f8960dd20c53e 100644 --- a/app/views/projects/snippets/_actions.html.haml +++ b/app/views/projects/snippets/_actions.html.haml @@ -1,13 +1,13 @@ .hidden-xs - - if can?(current_user, :create_project_snippet, @project) - = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: 'btn btn-grouped btn-create new-snippet-link', title: "New snippet" do - New snippet - - if can?(current_user, :update_project_snippet, @snippet) - = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-danger", title: 'Delete Snippet' do - Delete - if can?(current_user, :update_project_snippet, @snippet) - = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped snippable-edit" do + = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped" do Edit + - if can?(current_user, :update_project_snippet, @snippet) + = link_to namespace_project_snippet_path(@project.namespace, @project, @snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do + Delete + - if can?(current_user, :create_project_snippet, @project) + = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: 'btn btn-grouped btn-inverted btn-create', title: "New snippet" do + New snippet - if can?(current_user, :create_project_snippet, @project) || can?(current_user, :update_project_snippet, @snippet) .visible-xs-block.dropdown %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml index e77e1b026f6ec236b76104d6108def4968c9fa3a..84e05cd6d88b49c1fd34282af7dc47a6b03d09d2 100644 --- a/app/views/projects/snippets/index.html.haml +++ b/app/views/projects/snippets/index.html.haml @@ -1,11 +1,19 @@ - page_title "Snippets" -.sub-header-block - - if can?(current_user, :create_project_snippet, @project) - = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new btn-wide-on-sm pull-right", title: "New snippet" do - New snippet +- if current_user + .top-area + - include_private = @project.team.member?(current_user) || current_user.admin? + = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private } + + .nav-controls.hidden-xs + - if can?(current_user, :create_project_snippet, @project) + = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new", title: "New snippet" do + New snippet - .oneline - Share code pastes with others out of git repository +- if can?(current_user, :create_project_snippet, @project) + .visible-xs +   + = link_to new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new btn-block", title: "New snippet" do + New snippet = render 'snippets/snippets' diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml index c414acb6a11118080b000133a372f6df57984bc9..027d42396b48f42e52223b1870523f459bf80428 100644 --- a/app/views/search/results/_snippet_title.html.haml +++ b/app/views/search/results/_snippet_title.html.haml @@ -14,7 +14,7 @@ = link_to snippet_title.project.name_with_namespace, namespace_project_path(snippet_title.project.namespace, snippet_title.project) .snippet-info - = "##{snippet_title.id}" + = snippet_title.to_reference %span by = link_to user_snippets_path(snippet_title.author) do diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..896769768eba3875c705b66b3a1c1a90ec83d090 --- /dev/null +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -0,0 +1,126 @@ +- finder = controller.controller_name == 'issues' || controller.controller_name == 'boards' ? issues_finder : merge_requests_finder +- boards_page = controller.controller_name == 'boards' + +.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 + %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}}', 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}}', width: '30' } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-milestone.dropdown-menu{ 'data-dropdown' => true } + %ul{ 'data-dropdown' => true } + %li.filter-dropdown-item{ 'data-value' => 'none' } + %button.btn.btn-link + No Milestone + %li.filter-dropdown-item{ 'data-value' => 'upcoming' } + %button.btn.btn-link + Upcoming + %li.divider + %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value + {{title}} + #js-dropdown-label.dropdown-menu{ 'data-dropdown' => true } + %ul{ 'data-dropdown' => true } + %li.filter-dropdown-item{ 'data-value' => 'none' } + %button.btn.btn-link + No Label + %li.divider + %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true } + %li.filter-dropdown-item + %button.btn.btn-link + %span.dropdown-label-box{ style: 'background: {{color}}'} + %span.label-title.js-data-value + {{title}} + .pull-right + = render 'shared/sort_dropdown' + + - if @bulk_edit + .issues_bulk_update.hide + = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do + .filter-item.inline + = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do + %ul + %li + %a{href: "#", data: {id: "reopen"}} Open + %li + %a{href: "#", data: {id: "close"}} Closed + .filter-item.inline + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) + .filter-item.inline + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) + .filter-item.inline.labels-filter + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + .filter-item.inline + = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do + %ul + %li + %a{href: "#", data: {id: "subscribe"}} Subscribe + %li + %a{href: "#", data: {id: "unsubscribe"}} Unsubscribe + + = hidden_field_tag 'update[issuable_ids]', [] + = hidden_field_tag :state_event, params[:state_event] + .filter-item.inline + = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" + +:javascript + new UsersSelect(); + new LabelsSelect(); + new MilestoneSelect(); + new IssueStatusSelect(); + new SubscriptionSelect(); + $('form.filter-form').on('submit', function (event) { + event.preventDefault(); + Turbolinks.visit(this.action + '&' + $(this).serialize()); + }); diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index d7506e07ff6b0663445658d2e14eaab029cc60cc..d084f5e9684167b22cd6b2311b06afe8c1bd1e71 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -8,10 +8,6 @@ %span.creator authored = time_ago_with_tooltip(@snippet.created_at, placement: 'bottom', html_class: 'snippet_updated_ago') - - if @snippet.updated_at != @snippet.created_at - %span - = icon('edit', title: 'edited') - = time_ago_with_tooltip(@snippet.updated_at, placement: 'bottom', html_class: 'snippet_edited_ago') by #{link_to_member(@project, @snippet.author, size: 24, author_class: "author item-title", avatar_class: "hidden-xs")} .snippet-actions @@ -20,5 +16,9 @@ - else = render "snippets/actions" -%h2.snippet-title.prepend-top-0.append-bottom-0 - = markdown_field(@snippet, :title) +.snippet-header + %h2.snippet-title.prepend-top-0.append-bottom-0 + = markdown_field(@snippet, :title) + + - if @snippet.updated_at != @snippet.created_at + = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago') diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index ea17bec8677ed48680cbb7c8abbdaebfda7396f5..5d2d2317f22697394490fa6db051cf04c3d0630f 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,17 +1,16 @@ +- link_project = local_assigns.fetch(:link_project, false) + %li.snippet-row = image_tag avatar_icon(snippet.author_email), class: "avatar s40 hidden-xs", alt: '' .title = link_to reliable_snippet_path(snippet) do = snippet.title - - if snippet.private? - %span.label.label-gray.hidden-xs - = icon('lock') - private - %span.monospace.pull-right.hidden-xs - = snippet.file_name + - if snippet.file_name + %span.snippet-filename.monospace.hidden-xs + = snippet.file_name - %ul.controls.visible-xs + %ul.controls %li - note_count = snippet.notes.user.count = link_to reliable_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if note_count.zero?) do @@ -22,11 +21,17 @@ = visibility_level_label(snippet.visibility_level) = visibility_level_icon(snippet.visibility_level, fw: false) - %small.pull-right.cgray.hidden-xs - - if snippet.project_id? - = link_to snippet.project.name_with_namespace, namespace_project_path(snippet.project.namespace, snippet.project) - - .snippet-info.hidden-xs + .snippet-info + #{snippet.to_reference} · + authored #{time_ago_with_tooltip(snippet.created_at, placement: 'bottom', html_class: 'snippet-created-ago')} + by = link_to user_snippets_path(snippet.author) do = snippet.author_name - authored #{time_ago_with_tooltip(snippet.created_at)} + - if link_project && snippet.project_id? + %span.hidden-xs + in + = link_to namespace_project_path(snippet.project.namespace, snippet.project) do + = snippet.project.name_with_namespace + + .pull-right.snippet-updated-at + %span updated #{time_ago_with_tooltip(snippet.updated_at, placement: 'bottom')} diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 1d0e549ed3d64ec891ec5fcb5b90481c66758c89..95fc71981044a0253152391a8cd231ce61d32891 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -1,13 +1,13 @@ .hidden-xs - - if current_user - = link_to new_snippet_path, class: "btn btn-grouped btn-create new-snippet-link", title: "New snippet" do - New snippet - - if can?(current_user, :admin_personal_snippet, @snippet) - = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-danger", title: 'Delete Snippet' do - Delete - if can?(current_user, :update_personal_snippet, @snippet) - = link_to edit_snippet_path(@snippet), class: "btn btn-grouped snippable-edit" do + = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do Edit + - if can?(current_user, :admin_personal_snippet, @snippet) + = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do + Delete + - if current_user + = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: "New snippet" do + New snippet - if current_user .visible-xs-block.dropdown %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml index 77b66ca74b6385ac55c3b9d8dc22e4d2c41beb21..ac3701233ad1e8411391a9c190a65c3ec2e40462 100644 --- a/app/views/snippets/_snippets.html.haml +++ b/app/views/snippets/_snippets.html.haml @@ -1,8 +1,9 @@ - remote = local_assigns.fetch(:remote, false) +- link_project = local_assigns.fetch(:link_project, false) .snippets-list-holder %ul.content-list - = render partial: 'shared/snippets/snippet', collection: @snippets + = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project } - if @snippets.empty? %li .nothing-here-block Nothing here. diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..2dda5fed64794221d31c1cea59a480cd9b9a9faa --- /dev/null +++ b/app/views/snippets/_snippets_scope_menu.html.haml @@ -0,0 +1,31 @@ +- subject = local_assigns.fetch(:subject, current_user) +- include_private = local_assigns.fetch(:include_private, false) + +.nav-links.snippet-scope-menu + %li{ class: ("active" unless params[:scope]) } + = link_to subject_snippets_path(subject) do + All + %span.badge + - if include_private + = subject.snippets.count + - else + = subject.snippets.public_and_internal.count + + - if include_private + %li{ class: ("active" if params[:scope] == "are_private") } + = link_to subject_snippets_path(subject, scope: 'are_private') do + Private + %span.badge + = subject.snippets.are_private.count + + %li{ class: ("active" if params[:scope] == "are_internal") } + = link_to subject_snippets_path(subject, scope: 'are_internal') do + Internal + %span.badge + = subject.snippets.are_internal.count + + %li{ class: ("active" if params[:scope] == "are_public") } + = link_to subject_snippets_path(subject, scope: 'are_public') do + Public + %span.badge + = subject.snippets.are_public.count diff --git a/changelogs/unreleased/24807-stop-ddosing-ourselves.yml b/changelogs/unreleased/24807-stop-ddosing-ourselves.yml new file mode 100644 index 0000000000000000000000000000000000000000..49e6c5e56e598741d478a98120f56c88547c0c9c --- /dev/null +++ b/changelogs/unreleased/24807-stop-ddosing-ourselves.yml @@ -0,0 +1,4 @@ +--- +title: Use SmartInterval for MR widget and improve visibilitychange functionality +merge_request: 7762 +author: diff --git a/changelogs/unreleased/25483-broken-tabs.yml b/changelogs/unreleased/25483-broken-tabs.yml new file mode 100644 index 0000000000000000000000000000000000000000..d6c92014bea37caf8be3e28f481cb87523b8010b --- /dev/null +++ b/changelogs/unreleased/25483-broken-tabs.yml @@ -0,0 +1,4 @@ +--- +title: Fix TypeError: Cannot read property 'initTabs' on commit builds tab +merge_request: 8009 +author: diff --git a/changelogs/unreleased/unescape-relative-path.yml b/changelogs/unreleased/unescape-relative-path.yml new file mode 100644 index 0000000000000000000000000000000000000000..755b0379a16bd2917b09c08e205450046ac4b6c0 --- /dev/null +++ b/changelogs/unreleased/unescape-relative-path.yml @@ -0,0 +1,4 @@ +--- +title: Avoid escaping relative links in Markdown twice +merge_request: 7940 +author: winniehell diff --git a/config/application.rb b/config/application.rb index 0aa2873f94a4bed120f9c8a7b5c8c198c7d847a5..de7c133bc65033ebc7fa957f8891b9f6ad999925 100644 --- a/config/application.rb +++ b/config/application.rb @@ -98,6 +98,7 @@ class Application < Rails::Application config.assets.precompile << "environments/environments_bundle.js" config.assets.precompile << "blob_edit/blob_edit_bundle.js" config.assets.precompile << "snippet/snippet_bundle.js" + config.assets.precompile << "filtered_search/filtered_search_bundle.js" config.assets.precompile << "lib/utils/*.js" config.assets.precompile << "lib/*.js" config.assets.precompile << "u2f.js" diff --git a/doc/api/services.md b/doc/api/services.md index a5d733fe6c7ebebc6e2833bc9087b42e41225c0a..3dad953cd1e0e60ef6087ae50749f1605cc87d69 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -139,6 +139,43 @@ Get Buildkite service settings for a project. GET /projects/:id/services/buildkite ``` +## Build-Emails + +Get emails for GitLab CI builds. + +### Create/Edit Build-Emails service + +Set Build-Emails service for a project. + +``` +PUT /projects/:id/services/builds-email +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `recipients` | string | yes | Comma-separated list of recipient email addresses | +| `add_pusher` | boolean | no | Add pusher to recipients list | +| `notify_only_broken_builds` | boolean | no | Notify only broken builds | + + +### Delete Build-Emails service + +Delete Build-Emails service for a project. + +``` +DELETE /projects/:id/services/builds-email +``` + +### Get Build-Emails service settings + +Get Build-Emails service settings for a project. + +``` +GET /projects/:id/services/builds-email +``` + ## Campfire Simple web-based real-time group chat @@ -476,12 +513,11 @@ PUT /projects/:id/services/jira | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `active` | boolean| no | Enable/disable the JIRA service. | | `url` | string | yes | The URL to the JIRA project which is being linked to this GitLab project, e.g., `https://jira.example.com`. | | `project_key` | string | yes | The short identifier for your JIRA project, all uppercase, e.g., `PROJ`. | | `username` | string | no | The username of the user created to be used with GitLab/JIRA. | | `password` | string | no | The password of the user created to be used with GitLab/JIRA. | -| `jira_issue_transition_id` | string | no | The ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot][trans]). By default, this ID is set to `2`. | +| `jira_issue_transition_id` | integer | no | The ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot][trans]). By default, this ID is set to `2`. | ### Delete JIRA service @@ -491,6 +527,78 @@ Remove all previously JIRA settings from a project. DELETE /projects/:id/services/jira ``` +## Mattermost Slash Commands + +Ability to receive slash commands from a Mattermost chat instance. + +### Create/Edit Mattermost Slash Command service + +Set Mattermost Slash Command for a project. + +``` +PUT /projects/:id/services/mattermost-slash-commands +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `token` | string | yes | The Mattermost token | + + +### Delete Mattermost Slash Command service + +Delete Mattermost Slash Command service for a project. + +``` +DELETE /projects/:id/services/mattermost-slash-commands +``` + +### Get Mattermost Slash Command service settings + +Get Mattermost Slash Command service settings for a project. + +``` +GET /projects/:id/services/mattermost-slash-commands +``` + +## Pipeline-Emails + +Get emails for GitLab CI pipelines. + +### Create/Edit Pipeline-Emails service + +Set Pipeline-Emails service for a project. + +``` +PUT /projects/:id/services/pipelines-email +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `recipients` | string | yes | Comma-separated list of recipient email addresses | +| `add_pusher` | boolean | no | Add pusher to recipients list | +| `notify_only_broken_builds` | boolean | no | Notify only broken pipelines | + + +### Delete Pipeline-Emails service + +Delete Pipeline-Emails service for a project. + +``` +DELETE /projects/:id/services/pipelines-email +``` + +### Get Pipeline-Emails service settings + +Get Pipeline-Emails service settings for a project. + +``` +GET /projects/:id/services/pipelines-email +``` + ## PivotalTracker Project Management Software (Source Commits Endpoint) diff --git a/features/project/issues/filter_labels.feature b/features/project/issues/filter_labels.feature deleted file mode 100644 index 49d7a3b9af248d1888539d7d6fa416f1457f0d71..0000000000000000000000000000000000000000 --- a/features/project/issues/filter_labels.feature +++ /dev/null @@ -1,28 +0,0 @@ -@project_issues -Feature: Project Issues Filter Labels - Background: - Given I sign in as a user - And I own project "Shop" - And project "Shop" has labels: "bug", "feature", "enhancement" - And project "Shop" has issue "Bugfix1" with labels: "bug", "feature" - And project "Shop" has issue "Bugfix2" with labels: "bug", "enhancement" - And project "Shop" has issue "Feature1" with labels: "feature" - Given I visit project "Shop" issues page - - @javascript - Scenario: I filter by one label - Given I click link "bug" - And I click "dropdown close button" - Then I should see "Bugfix1" in issues list - And I should see "Bugfix2" in issues list - And I should not see "Feature1" in issues list - - # TODO: make labels filter works according to this scanario - # right now it looks for label 1 OR label 2. Old behaviour (this test) was - # all issues that have both label 1 AND label 2 - #Scenario: I filter by two labels - #Given I click link "bug" - #And I click link "feature" - #Then I should see "Bugfix1" in issues list - #And I should not see "Bugfix2" in issues list - #And I should not see "Feature1" in issues list diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index 80670063ea00e0b89269d17ce88b3f35f098967b..b2b4fe722205eccb7a5e4431c74f7c693baeefce 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -26,12 +26,6 @@ Feature: Project Issues Given I click link "Release 0.4" Then I should see issue "Release 0.4" - @javascript - Scenario: I filter by author - Given I add a user to project "Shop" - And I click "author" dropdown - Then I see current user as the first user - Scenario: I submit new unassigned issue Given I click link "New Issue" And I submit new issue "500 error on profile" @@ -84,56 +78,6 @@ Feature: Project Issues And I sort the list by "Least popular" Then The list should be sorted by "Least popular" - @javascript - Scenario: I search issue - Given I fill in issue search with "Re" - Then I should see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - And I should not see "Tweet control" in issues - - @javascript - Scenario: I search issue that not exist - Given I fill in issue search with "Bu" - Then I should not see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - - @javascript - Scenario: I search all issues - Given I click link "All" - And I fill in issue search with ".3" - Then I should see "Release 0.3" in issues - And I should not see "Release 0.4" in issues - - @javascript - Scenario: Search issues when search string exactly matches issue description - Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1' - And I fill in issue search with 'Description for issue1' - Then I should see 'Bugfix1' in issues - And I should not see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - And I should not see "Tweet control" in issues - - @javascript - Scenario: Search issues when search string partially matches issue description - Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1' - And project 'Shop' has issue 'Feature1' with description: 'Feature submitted for issue1' - And I fill in issue search with 'issue1' - Then I should see 'Feature1' in issues - Then I should see 'Bugfix1' in issues - And I should not see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - And I should not see "Tweet control" in issues - - @javascript - Scenario: Search issues when search string matches no issue description - Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1' - And I fill in issue search with 'Rock and roll' - Then I should not see 'Bugfix1' in issues - And I should not see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - And I should not see "Tweet control" in issues - - # Markdown Scenario: Headers inside the description should have ids generated for them. diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb index 5e7d539add6c181be89681d7597eb198297d4fe1..a3bebfa4b715052a3130553c640f598da768db11 100644 --- a/features/steps/project/snippets.rb +++ b/features/steps/project/snippets.rb @@ -22,7 +22,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps end step 'I click link "New snippet"' do - click_link "New snippet" + first(:link, "New snippet").click end step 'I click link "Snippet one"' do diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 8b0f8deadfa6d7d77104f0302418841cc0993957..f03d8da732e05a7a244b371d12ab525977dcf061 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -96,17 +96,6 @@ def find_project!(id) end end - def project_service(project = user_project) - @project_service ||= project.find_or_initialize_service(params[:service_slug].underscore) - @project_service || not_found!("Service") - end - - def service_attributes - @service_attributes ||= project_service.fields.inject([]) do |arr, hash| - arr << hash[:name].to_sym - end - end - def find_group(id) if id =~ /^\d+$/ Group.find_by(id: id) diff --git a/lib/api/services.rb b/lib/api/services.rb index bc427705777fc38ca3856ddfe99dbec23f2736a8..fde2e2746f1bb1cc12fc1982d3de9049e447c5ba 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -1,84 +1,602 @@ module API - # Projects API class Services < Grape::API + services = { + 'asana' => [ + { + required: true, + name: :api_key, + type: String, + desc: 'User API token' + }, + { + required: false, + name: :restrict_to_branch, + type: String, + desc: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches' + } + ], + 'assembla' => [ + { + required: true, + name: :token, + type: String, + desc: 'The authentication token' + }, + { + required: false, + name: :subdomain, + type: String, + desc: 'Subdomain setting' + } + ], + 'bamboo' => [ + { + required: true, + name: :bamboo_url, + type: String, + desc: 'Bamboo root URL like https://bamboo.example.com' + }, + { + required: true, + name: :build_key, + type: String, + desc: 'Bamboo build plan key like' + }, + { + required: true, + name: :username, + type: String, + desc: 'A user with API access, if applicable' + }, + { + required: true, + name: :password, + type: String, + desc: 'Passord of the user' + } + ], + 'bugzilla' => [ + { + required: true, + name: :new_issue_url, + type: String, + desc: 'New issue URL' + }, + { + required: true, + name: :issues_url, + type: String, + desc: 'Issues URL' + }, + { + required: true, + name: :project_url, + type: String, + desc: 'Project URL' + }, + { + required: false, + name: :description, + type: String, + desc: 'Description' + }, + { + required: false, + name: :title, + type: String, + desc: 'Title' + } + ], + 'buildkite' => [ + { + required: true, + name: :token, + type: String, + desc: 'Buildkite project GitLab token' + }, + { + required: true, + name: :project_url, + type: String, + desc: 'The buildkite project URL' + }, + { + required: false, + name: :enable_ssl_verification, + type: Boolean, + desc: 'Enable SSL verification for communication' + } + ], + 'builds-email' => [ + { + required: true, + name: :recipients, + type: String, + desc: 'Comma-separated list of recipient email addresses' + }, + { + required: false, + name: :add_pusher, + type: Boolean, + desc: 'Add pusher to recipients list' + }, + { + required: false, + name: :notify_only_broken_builds, + type: Boolean, + desc: 'Notify only broken builds' + } + ], + 'campfire' => [ + { + required: true, + name: :token, + type: String, + desc: 'Campfire token' + }, + { + required: false, + name: :subdomain, + type: String, + desc: 'Campfire subdomain' + }, + { + required: false, + name: :room, + type: String, + desc: 'Campfire room' + }, + ], + 'custom-issue-tracker' => [ + { + required: true, + name: :new_issue_url, + type: String, + desc: 'New issue URL' + }, + { + required: true, + name: :issues_url, + type: String, + desc: 'Issues URL' + }, + { + required: true, + name: :project_url, + type: String, + desc: 'Project URL' + }, + { + required: false, + name: :description, + type: String, + desc: 'Description' + }, + { + required: false, + name: :title, + type: String, + desc: 'Title' + } + ], + 'drone-ci' => [ + { + required: true, + name: :token, + type: String, + desc: 'Drone CI token' + }, + { + required: true, + name: :drone_url, + type: String, + desc: 'Drone CI URL' + }, + { + required: false, + name: :enable_ssl_verification, + type: Boolean, + desc: 'Enable SSL verification for communication' + } + ], + 'emails-on-push' => [ + { + required: true, + name: :recipients, + type: String, + desc: 'Comma-separated list of recipient email addresses' + }, + { + required: false, + name: :disable_diffs, + type: Boolean, + desc: 'Disable code diffs' + }, + { + required: false, + name: :send_from_committer_email, + type: Boolean, + desc: 'Send from committer' + } + ], + 'external-wiki' => [ + { + required: true, + name: :external_wiki_url, + type: String, + desc: 'The URL of the external Wiki' + } + ], + 'flowdock' => [ + { + required: true, + name: :token, + type: String, + desc: 'Flowdock token' + } + ], + 'gemnasium' => [ + { + required: true, + name: :api_key, + type: String, + desc: 'Your personal API key on gemnasium.com' + }, + { + required: true, + name: :token, + type: String, + desc: "The project's slug on gemnasium.com" + } + ], + 'hipchat' => [ + { + required: true, + name: :token, + type: String, + desc: 'The room token' + }, + { + required: false, + name: :room, + type: String, + desc: 'The room name or ID' + }, + { + required: false, + name: :color, + type: String, + desc: 'The room color' + }, + { + required: false, + name: :notify, + type: Boolean, + desc: 'Enable notifications' + }, + { + required: false, + name: :api_version, + type: String, + desc: 'Leave blank for default (v2)' + }, + { + required: false, + name: :server, + type: String, + desc: 'Leave blank for default. https://hipchat.example.com' + } + ], + 'irker' => [ + { + required: true, + name: :recipients, + type: String, + desc: 'Recipients/channels separated by whitespaces' + }, + { + required: false, + name: :default_irc_uri, + type: String, + desc: 'Default: irc://irc.network.net:6697' + }, + { + required: false, + name: :server_host, + type: String, + desc: 'Server host. Default localhost' + }, + { + required: false, + name: :server_port, + type: Integer, + desc: 'Server port. Default 6659' + }, + { + required: false, + name: :colorize_messages, + type: Boolean, + desc: 'Colorize messages' + } + ], + 'jira' => [ + { + required: true, + name: :url, + type: String, + desc: 'The URL to the JIRA project which is being linked to this GitLab project, e.g., https://jira.example.com' + }, + { + required: true, + name: :project_key, + type: String, + desc: 'The short identifier for your JIRA project, all uppercase, e.g., PROJ' + }, + { + required: false, + name: :username, + type: String, + desc: 'The username of the user created to be used with GitLab/JIRA' + }, + { + required: false, + name: :password, + type: String, + desc: 'The password of the user created to be used with GitLab/JIRA' + }, + { + required: false, + name: :jira_issue_transition_id, + type: Integer, + desc: 'The ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot][trans]). By default, this ID is set to `2`' + } + ], + 'mattermost-slash-commands' => [ + { + required: true, + name: :token, + type: String, + desc: 'The Mattermost token' + } + ], + 'pipelines-email' => [ + { + required: true, + name: :recipients, + type: String, + desc: 'Comma-separated list of recipient email addresses' + }, + { + required: false, + name: :notify_only_broken_builds, + type: Boolean, + desc: 'Notify only broken builds' + } + ], + 'pivotaltracker' => [ + { + required: true, + name: :token, + type: String, + desc: 'The Pivotaltracker token' + }, + { + required: false, + name: :restrict_to_branch, + type: String, + desc: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.' + } + ], + 'pushover' => [ + { + required: true, + name: :api_key, + type: String, + desc: 'The application key' + }, + { + required: true, + name: :user_key, + type: String, + desc: 'The user key' + }, + { + required: true, + name: :priority, + type: String, + desc: 'The priority' + }, + { + required: true, + name: :device, + type: String, + desc: 'Leave blank for all active devices' + }, + { + required: true, + name: :sound, + type: String, + desc: 'The sound of the notification' + } + ], + 'redmine' => [ + { + required: true, + name: :new_issue_url, + type: String, + desc: 'The new issue URL' + }, + { + required: true, + name: :project_url, + type: String, + desc: 'The project URL' + }, + { + required: true, + name: :issues_url, + type: String, + desc: 'The issues URL' + }, + { + required: false, + name: :description, + type: String, + desc: 'The description of the tracker' + } + ], + 'slack' => [ + { + required: true, + name: :webhook, + type: String, + desc: 'The Slack webhook. e.g. https://hooks.slack.com/services/...' + }, + { + required: false, + name: :new_issue_url, + type: String, + desc: 'The user name' + }, + { + required: false, + name: :channel, + type: String, + desc: 'The channel name' + } + ], + 'teamcity' => [ + { + required: true, + name: :teamcity_url, + type: String, + desc: 'TeamCity root URL like https://teamcity.example.com' + }, + { + required: true, + name: :build_type, + type: String, + desc: 'Build configuration ID' + }, + { + required: true, + name: :username, + type: String, + desc: 'A user with permissions to trigger a manual build' + }, + { + required: true, + name: :password, + type: String, + desc: 'The password of the user' + } + ] + }.freeze + + trigger_services = { + 'mattermost-slash-commands' => [ + { + name: :token, + type: String, + desc: 'The Mattermost token' + } + ] + }.freeze + resource :projects do before { authenticate! } before { authorize_admin_project } - # Set service for project - # - # Example Request: - # - # PUT /projects/:id/services/gitlab-ci - # - put ':id/services/:service_slug' do - if project_service - validators = project_service.class.validators.select do |s| - s.class == ActiveRecord::Validations::PresenceValidator && - s.attributes != [:project_id] + helpers do + def service_attributes(service) + service.fields.inject([]) do |arr, hash| + arr << hash[:name].to_sym end + end + end - required_attributes! validators.map(&:attributes).flatten.uniq - attrs = attributes_for_keys service_attributes + services.each do |service_slug, settings| + desc "Set #{service_slug} service for project" + params do + settings.each do |setting| + if setting[:required] + requires setting[:name], type: setting[:type], desc: setting[:desc] + else + optional setting[:name], type: setting[:type], desc: setting[:desc] + end + end + end + put ":id/services/#{service_slug}" do + service = user_project.find_or_initialize_service(service_slug.underscore) + service_params = declared_params(include_missing: false).merge(active: true) - if project_service.update_attributes(attrs.merge(active: true)) + if service.update_attributes(service_params) true else - not_found! + render_api_error!('400 Bad Request', 400) end end end - # Delete service for project - # - # Example Request: - # - # DELETE /project/:id/services/gitlab-ci - # - delete ':id/services/:service_slug' do - if project_service - attrs = service_attributes.inject({}) do |hash, key| - hash.merge!(key => nil) - end + desc "Delete a service for project" + params do + requires :service_slug, type: String, values: services.keys, desc: 'The name of the service' + end + delete ":id/services/:service_slug" do + service = user_project.find_or_initialize_service(params[:service_slug].underscore) - if project_service.update_attributes(attrs.merge(active: false)) - true - else - not_found! - end + attrs = service_attributes(service).inject({}) do |hash, key| + hash.merge!(key => nil) + end + + if service.update_attributes(attrs.merge(active: false)) + true + else + render_api_error!('400 Bad Request', 400) end end - # Get service settings for project - # - # Example Request: - # - # GET /project/:id/services/gitlab-ci - # - get ':id/services/:service_slug' do - present project_service, with: Entities::ProjectService, include_passwords: current_user.is_admin? + desc 'Get the service settings for project' do + success Entities::ProjectService + end + params do + requires :service_slug, type: String, values: services.keys, desc: 'The name of the service' + end + get ":id/services/:service_slug" do + service = user_project.find_or_initialize_service(params[:service_slug].underscore) + present service, with: Entities::ProjectService, include_passwords: current_user.is_admin? end end - resource :projects do - desc 'Trigger a slash command' do - detail 'Added in GitLab 8.13' + trigger_services.each do |service_slug, settings| + params do + requires :id, type: String, desc: 'The ID of a project' end - post ':id/services/:service_slug/trigger' do - project = find_project(params[:id]) + resource :projects do + desc "Trigger a slash command for #{service_slug}" do + detail 'Added in GitLab 8.13' + end + params do + settings.each do |setting| + requires setting[:name], type: setting[:type], desc: setting[:desc] + end + end + post ":id/services/#{service_slug.underscore}/trigger" do + project = find_project(params[:id]) - # This is not accurate, but done to prevent leakage of the project names - not_found!('Service') unless project + # This is not accurate, but done to prevent leakage of the project names + not_found!('Service') unless project - service = project_service(project) + service = project.find_or_initialize_service(service_slug.underscore) - result = service.try(:active?) && service.try(:trigger, params) + result = service.try(:active?) && service.try(:trigger, params) - if result - status result[:status] || 200 - present result - else - not_found!('Service') + if result + status result[:status] || 200 + present result + else + not_found!('Service') + end end end end diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/relative_link_filter.rb index f09d78be0cee191ca461f0f9b625e79a93da1349..9e23c8f8c553d22dc856fb0a3cba99ca5ad27b52 100644 --- a/lib/banzai/filter/relative_link_filter.rb +++ b/lib/banzai/filter/relative_link_filter.rb @@ -46,7 +46,7 @@ def process_link_attr(html_attr) end def rebuild_relative_uri(uri) - file_path = relative_file_path(uri.path) + file_path = relative_file_path(uri) uri.path = [ relative_url_root, @@ -59,8 +59,10 @@ def rebuild_relative_uri(uri) uri end - def relative_file_path(path) - nested_path = build_relative_path(path, context[:requested_path]) + def relative_file_path(uri) + path = Addressable::URI.unescape(uri.path) + request_path = Addressable::URI.unescape(context[:requested_path]) + nested_path = build_relative_path(path, request_path) file_exists?(nested_path) ? nested_path : path end @@ -108,11 +110,7 @@ def file_exists?(path) end def uri_type(path) - @uri_types[path] ||= begin - unescaped_path = Addressable::URI.unescape(path) - - current_commit.uri_type(unescaped_path) - end + @uri_types[path] ||= current_commit.uri_type(path) end def current_commit diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 2c21804fe7a6328b2f1d027b0dcacc918119d753..ab735315515c22f63a49c052134457fd7dfa7c3a 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -11,6 +11,7 @@ def add_gon_variables if current_user gon.current_user_id = current_user.id + gon.current_username = current_user.username end end end diff --git a/spec/features/dashboard/datetime_on_tooltips_spec.rb b/spec/features/dashboard/datetime_on_tooltips_spec.rb index 365cb445df1e519d634501a2de2c1c27897bae40..44dfc2dff450b502495603a6877b2d93a7c20bf7 100644 --- a/spec/features/dashboard/datetime_on_tooltips_spec.rb +++ b/spec/features/dashboard/datetime_on_tooltips_spec.rb @@ -36,7 +36,7 @@ visit user_snippets_path(user) wait_for_ajax() - page.find('.js-timeago').hover + page.find('.js-timeago.snippet-created-ago').hover end it 'has the datetime formated correctly' do diff --git a/spec/features/issues/filter_by_milestone_spec.rb b/spec/features/issues/filter_by_milestone_spec.rb deleted file mode 100644 index 9dfa5d1de1991eb543acc69bbd3023dd4ce14d39..0000000000000000000000000000000000000000 --- a/spec/features/issues/filter_by_milestone_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -require 'rails_helper' - -feature 'Issue filtering by Milestone', feature: true do - let(:project) { create(:project, :public) } - let(:milestone) { create(:milestone, project: project) } - - scenario 'filters by no Milestone', js: true do - create(:issue, project: project) - create(:issue, project: project, milestone: milestone) - - visit_issues(project) - filter_by_milestone(Milestone::None.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'No Milestone') - expect(page).to have_css('.issue', count: 1) - end - - context 'filters by upcoming milestone', js: true do - it 'does not show issues with no expiry' do - create(:issue, project: project) - create(:issue, project: project, milestone: milestone) - - visit_issues(project) - filter_by_milestone(Milestone::Upcoming.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming') - expect(page).to have_css('.issue', count: 0) - end - - it 'shows issues in future' do - milestone = create(:milestone, project: project, due_date: Date.tomorrow) - create(:issue, project: project) - create(:issue, project: project, milestone: milestone) - - visit_issues(project) - filter_by_milestone(Milestone::Upcoming.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming') - expect(page).to have_css('.issue', count: 1) - end - - it 'does not show issues in past' do - milestone = create(:milestone, project: project, due_date: Date.yesterday) - create(:issue, project: project) - create(:issue, project: project, milestone: milestone) - - visit_issues(project) - filter_by_milestone(Milestone::Upcoming.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming') - expect(page).to have_css('.issue', count: 0) - end - end - - scenario 'filters by a specific Milestone', js: true do - create(:issue, project: project, milestone: milestone) - create(:issue, project: project) - - visit_issues(project) - filter_by_milestone(milestone.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: milestone.title) - expect(page).to have_css('.issue', count: 1) - end - - context 'when milestone has single quotes in title' do - background do - milestone.update(name: "rock 'n' roll") - end - - scenario 'filters by a specific Milestone', js: true do - create(:issue, project: project, milestone: milestone) - create(:issue, project: project) - - visit_issues(project) - filter_by_milestone(milestone.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: milestone.title) - expect(page).to have_css('.issue', count: 1) - end - end - - def visit_issues(project) - visit namespace_project_issues_path(project.namespace, project) - end - - def filter_by_milestone(title) - find(".js-milestone-select").click - find(".milestone-filter .dropdown-content a", text: title).click - end -end diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..216cd78850b7f8ca1c770e312caf563651ca3a13 --- /dev/null +++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb @@ -0,0 +1,114 @@ +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') } + + 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(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(0) + end + + it 'filters with text' do + filtered_search.set('a') + expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').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 + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'author').click + 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 + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'assignee').click + 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 + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'milestone').click + 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 + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'label').click + 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') + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'author').click + 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') + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'assignee').click + 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') + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'milestone').click + 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') + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'label').click + expect(page).to have_css('#js-dropdown-hint', visible: false) + expect(page).to have_css('#js-dropdown-label', visible: true) + expect(filtered_search.value).to eq('label:') + end + end +end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..283814d2cbb9c3303b8eb38d0f4af7b55a6cda11 --- /dev/null +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -0,0 +1,682 @@ +require 'rails_helper' + +describe 'Filter issues', feature: true do + include WaitForAjax + + let!(:group) { create(:group) } + let!(:project) { create(:project, group: group) } + let!(:user) { create(:user) } + let!(:user2) { create(:user) } + let!(:milestone) { create(:milestone, project: project) } + let!(:label) { create(:label, project: project) } + let!(:wontfix) { create(:label, project: project, title: "Won't fix") } + + let!(:bug_label) { create(:label, project: project, title: 'bug') } + let!(:caps_sensitive_label) { create(:label, project: project, title: 'CAPS_sensitive') } + let!(:milestone) { create(:milestone, title: "8", project: project) } + let!(:multiple_words_label) { create(:label, project: project, title: "Two words") } + + let!(:closed_issue) { create(:issue, title: 'bug that is closed', project: project, state: :closed) } + + def input_filtered_search(search_term) + filtered_search = find('.filtered-search') + filtered_search.set(search_term) + filtered_search.send_keys(:enter) + end + + def expect_filtered_search_input(input) + expect(find('.filtered-search').value).to eq(input) + end + + def expect_no_issues_list + page.within '.issues-list' do + expect(page).not_to have_selector('.issue') + end + end + + def expect_issues_list_count(open_count, closed_count = 0) + all_count = open_count + closed_count + + expect(page).to have_issuable_counts(open: open_count, closed: closed_count, all: all_count) + page.within '.issues-list' do + expect(page).to have_selector('.issue', count: open_count) + end + end + + before do + project.team << [user, :master] + project.team << [user2, :master] + group.add_developer(user) + group.add_developer(user2) + login_as(user) + create(:issue, project: project) + + create(:issue, title: "Bug report 1", project: project) + create(:issue, title: "Bug report 2", project: project) + create(:issue, title: "issue with 'single quotes'", project: project) + create(:issue, title: "issue with \"double quotes\"", project: project) + create(:issue, title: "issue with !@\#{$%^&*()-+", project: project) + create(:issue, title: "issue by assignee", project: project, milestone: milestone, author: user, assignee: user) + create(:issue, title: "issue by assignee with searchTerm", project: project, milestone: milestone, author: user, assignee: user) + + issue = create(:issue, + title: "Bug 2", + project: project, + milestone: milestone, + author: user, + assignee: user) + issue.labels << bug_label + + issue_with_caps_label = create(:issue, + title: "issue by assignee with searchTerm and label", + project: project, + milestone: milestone, + author: user, + assignee: user) + issue_with_caps_label.labels << caps_sensitive_label + + issue_with_everything = create(:issue, + title: "Bug report with everything you thought was possible", + project: project, + milestone: milestone, + author: user, + assignee: user) + issue_with_everything.labels << bug_label + issue_with_everything.labels << caps_sensitive_label + + multiple_words_label_issue = create(:issue, title: "Issue with multiple words label", project: project) + multiple_words_label_issue.labels << multiple_words_label + + future_milestone = create(:milestone, title: "future", project: project, due_date: Time.now + 1.month) + + create(:issue, + title: "Issue with future milestone", + milestone: future_milestone, + project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'filter issues by author' do + context 'only author', js: true do + it 'filters issues by searched author' do + input_filtered_search("author:@#{user.username}") + expect_issues_list_count(5) + end + + it 'filters issues by invalid author' do + # YOLO + end + + it 'filters issues by multiple authors' do + # YOLO + end + end + + context 'author with other filters', js: true do + it 'filters issues by searched author and text' do + search = "author:@#{user.username} issue" + input_filtered_search(search) + expect_issues_list_count(3) + expect_filtered_search_input(search) + end + + it 'filters issues by searched author, assignee and text' do + search = "author:@#{user.username} assignee:@#{user.username} issue" + input_filtered_search(search) + expect_issues_list_count(3) + expect_filtered_search_input(search) + end + + it 'filters issues by searched author, assignee, label, and text' do + search = "author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} issue" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched author, assignee, label, milestone and text' do + search = "author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} issue" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'sorting', js: true do + # TODO + end + end + + describe 'filter issues by assignee' do + context 'only assignee', js: true do + it 'filters issues by searched assignee' do + search = "assignee:@#{user.username}" + input_filtered_search(search) + expect_issues_list_count(5) + expect_filtered_search_input(search) + end + + it 'filters issues by no assignee' do + search = "assignee:none" + input_filtered_search(search) + expect_issues_list_count(8, 1) + expect_filtered_search_input(search) + end + + it 'filters issues by invalid assignee' do + # YOLO + end + + it 'filters issues by multiple assignees' do + # YOLO + end + end + + context 'assignee with other filters', js: true do + it 'filters issues by searched assignee and text' do + search = "assignee:@#{user.username} searchTerm" + input_filtered_search(search) + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched assignee, author and text' do + search = "assignee:@#{user.username} author:@#{user.username} searchTerm" + input_filtered_search(search) + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched assignee, author, label, text' do + search = "assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} searchTerm" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched assignee, author, label, milestone and text' do + search = "assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} searchTerm" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'sorting', js: true do + # TODO + end + end + + describe 'filter issues by label' do + context 'only label', js: true do + it 'filters issues by searched label' do + search = "label:~#{bug_label.title}" + input_filtered_search(search) + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by no label' do + search = "label:none" + input_filtered_search(search) + expect_issues_list_count(9, 1) + expect_filtered_search_input(search) + end + + it 'filters issues by invalid label' do + # YOLO + end + + it 'filters issues by multiple labels' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title}" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by label containing special characters' do + special_label = create(:label, project: project, title: '!@#{$%^&*()-+[]<>?/:{}|\}') + special_issue = create(:issue, title: "Issue with special character label", project: project) + special_issue.labels << special_label + + search = "label:~#{special_label.title}" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'does not show issues' do + new_label = create(:label, project: project, title: "new_label") + + search = "label:~#{new_label.title}" + input_filtered_search(search) + expect_no_issues_list() + expect_filtered_search_input(search) + end + end + + context 'label with multiple words', js: true do + it 'special characters' do + special_multiple_label = create(:label, project: project, title: "Utmost |mp0rt@nce") + special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project) + special_multiple_issue.labels << special_multiple_label + + search = "label:~'#{special_multiple_label.title}'" + input_filtered_search(search) + expect_issues_list_count(1) + + # filtered search defaults quotations to double quotes + expect_filtered_search_input("label:~\"#{special_multiple_label.title}\"") + end + + it 'single quotes' do + search = "label:~'#{multiple_words_label.title}'" + input_filtered_search(search) + expect_issues_list_count(1) + + expect_filtered_search_input("label:~\"#{multiple_words_label.title}\"") + end + + it 'double quotes' do + search = "label:~\"#{multiple_words_label.title}\"" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'single quotes containing double quotes' do + double_quotes_label = create(:label, project: project, title: 'won"t fix') + double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project) + double_quotes_label_issue.labels << double_quotes_label + + search = "label:~'#{double_quotes_label.title}'" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'double quotes containing single quotes' do + single_quotes_label = create(:label, project: project, title: "won't fix") + single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project) + single_quotes_label_issue.labels << single_quotes_label + + search = "label:~\"#{single_quotes_label.title}\"" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'label with other filters', js: true do + it 'filters issues by searched label and text' do + search = "label:~#{caps_sensitive_label.title} bug" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, author and text' do + search = "label:~#{caps_sensitive_label.title} author:@#{user.username} bug" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, author, assignee and text' do + search = "label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} bug" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, author, assignee, milestone and text' do + search = "label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} bug" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'multiple labels with other filters', js: true do + it 'filters issues by searched label, label2, and text' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} bug" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, label2, author and text' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} bug" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, label2, author, assignee and text' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} bug" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, label2, author, assignee, milestone and text' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} bug" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'issue label clicked', js: true do + before do + find('.issues-list .issue .issue-info a .label', text: multiple_words_label.title).click + sleep 1 + end + + it 'filters' do + expect_issues_list_count(1) + end + + it 'displays in search bar' do + expect(find('.filtered-search').value).to eq("label:~\"#{multiple_words_label.title}\"") + end + end + + context 'sorting', js: true do + # TODO + end + end + + describe 'filter issues by milestone' do + context 'only milestone', js: true do + it 'filters issues by searched milestone' do + input_filtered_search("milestone:%#{milestone.title}") + expect_issues_list_count(5) + end + + it 'filters issues by no milestone' do + input_filtered_search("milestone:none") + expect_issues_list_count(7, 1) + end + + it 'filters issues by upcoming milestones' do + input_filtered_search("milestone:upcoming") + expect_issues_list_count(1) + end + + it 'filters issues by invalid milestones' do + # YOLO + end + + it 'filters issues by multiple milestones' do + # YOLO + end + + it 'filters issues by milestone containing special characters' do + special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) + create(:issue, title: "Issue with special character milestone", project: project, milestone: special_milestone) + + search = "milestone:%#{special_milestone.title}" + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'does not show issues' do + new_milestone = create(:milestone, title: "new", project: project) + + search = "milestone:%#{new_milestone.title}" + input_filtered_search(search) + expect_no_issues_list() + expect_filtered_search_input(search) + end + end + + context 'milestone with other filters', js: true do + it 'filters issues by searched milestone and text' do + search = "milestone:%#{milestone.title} bug" + input_filtered_search(search) + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched milestone, author and text' do + search = "milestone:%#{milestone.title} author:@#{user.username} bug" + input_filtered_search(search) + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched milestone, author, assignee and text' do + search = "milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} bug" + input_filtered_search(search) + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched milestone, author, assignee, label and text' do + search = "milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug" + input_filtered_search(search) + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + end + + context 'sorting', js: true do + # TODO + end + end + + describe 'filter issues by text' do + context 'only text', js: true do + it 'filters issues by searched text' do + search = 'Bug' + input_filtered_search(search) + expect_issues_list_count(4, 1) + expect_filtered_search_input(search) + end + + it 'filters issues by multiple searched text' do + search = 'Bug report' + input_filtered_search(search) + expect_issues_list_count(3) + expect_filtered_search_input(search) + end + + it 'filters issues by case insensitive searched text' do + search = 'bug report' + input_filtered_search(search) + expect_issues_list_count(3) + expect_filtered_search_input(search) + end + + it 'filters issues by searched text containing single quotes' do + search = '\'single quotes\'' + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched text containing double quotes' do + search = '"double quotes"' + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched text containing special characters' do + search = '!@#{$%^&*()-+' + input_filtered_search(search) + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'does not show any issues' do + search = 'testing' + input_filtered_search(search) + expect_no_issues_list() + expect_filtered_search_input(search) + end + end + + context 'searched text with other filters', js: true do + it 'filters issues by searched text and author' do + input_filtered_search("bug author:@#{user.username}") + expect_issues_list_count(2) + expect_filtered_search_input("author:@#{user.username} bug") + end + + it 'filters issues by searched text, author and more text' do + input_filtered_search("bug author:@#{user.username} report") + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} bug report") + end + + it 'filters issues by searched text, author and assignee' do + input_filtered_search("bug author:@#{user.username} assignee:@#{user.username}") + expect_issues_list_count(2) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug") + end + + it 'filters issues by searched text, author, more text and assignee' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username}") + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug report") + end + + it 'filters issues by searched text, author, more text, assignee and even more text' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with") + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug report with") + end + + it 'filters issues by searched text, author, assignee and label' do + input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title}") + expect_issues_list_count(2) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug") + end + + it 'filters issues by searched text, author, text, assignee, text, label and text' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything") + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug report with everything") + end + + it 'filters issues by searched text, author, assignee, label and milestone' do + input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title}") + expect_issues_list_count(2) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title} bug") + end + + it 'filters issues by searched text, author, text, assignee, text, label, text, milestone and text' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything milestone:%#{milestone.title} you") + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title} bug report with everything you") + end + + it 'filters issues by searched text, author, assignee, multiple labels and milestone' do + input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title}") + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} bug") + end + + it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything label:~#{caps_sensitive_label.title} you milestone:%#{milestone.title} thought") + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} bug report with everything you thought") + end + end + + context 'sorting', js: true do + it 'sorts by oldest updated' do + create(:issue, + title: '3 days ago', + project: project, + author: user, + created_at: 3.days.ago, + updated_at: 3.days.ago) + + old_issue = create(:issue, + title: '5 days ago', + project: project, + author: user, + created_at: 5.days.ago, + updated_at: 5.days.ago) + + input_filtered_search('days ago') + expect_issues_list_count(2) + + sort_toggle = find('.filtered-search-container .dropdown-toggle') + sort_toggle.click + + find('.filtered-search-container .dropdown-menu li a', text: 'Oldest updated').click + wait_for_ajax + + expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title) + end + end + end + + describe 'retains filter when switching issue states', js: true do + before do + input_filtered_search('bug') + + # Wait for search results to load + sleep 2 + end + + it 'open state' do + find('.issues-state-filters a', text: 'Closed').click + wait_for_ajax + + find('.issues-state-filters a', text: 'Open').click + wait_for_ajax + + expect(page).to have_selector('.issues-list .issue', count: 4) + end + + it 'closed state' do + find('.issues-state-filters a', text: 'Closed').click + wait_for_ajax + expect(page).to have_selector('.issues-list .issue', count: 1) + expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(closed_issue.title) + end + + it 'all state' do + find('.issues-state-filters a', text: 'All').click + wait_for_ajax + expect(page).to have_selector('.issues-list .issue', count: 5) + end + end + + describe 'RSS feeds' do + it 'updates atom feed link for project issues' do + visit namespace_project_issues_path(project.namespace, project, milestone_title: milestone.title, assignee_id: user.id) + link = find('.nav-controls a', text: 'Subscribe') + params = CGI.parse(URI.parse(link[:href]).query) + auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) + auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) + expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('milestone_title' => [milestone.title]) + expect(params).to include('assignee_id' => [user.id.to_s]) + expect(auto_discovery_params).to include('private_token' => [user.private_token]) + expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) + expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) + end + + it 'updates atom feed link for group issues' do + visit issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id) + link = find('.nav-controls a', text: 'Subscribe') + params = CGI.parse(URI.parse(link[:href]).query) + auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) + auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) + expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('milestone_title' => [milestone.title]) + expect(params).to include('assignee_id' => [user.id.to_s]) + expect(auto_discovery_params).to include('private_token' => [user.private_token]) + expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) + expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) + end + end +end diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d37057a44f8ce57c347f508e1c7e8c073d7c346a --- /dev/null +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -0,0 +1,81 @@ +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 getLeftStyle(style) + leftStyle = /left:\s\d*[.]\d*px/.match(style) + leftStyle.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') + hintStyle = page.find('#js-dropdown-hint')['style'] + hintOffset = getLeftStyle(hintStyle) + + 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(getLeftStyle(page.find('#js-dropdown-hint')['style'])).to eq (hintOffset) + end + end +end diff --git a/spec/features/issues/reset_filters_spec.rb b/spec/features/issues/reset_filters_spec.rb deleted file mode 100644 index c9a3ecf16ea1ae528bbb456eba4a80647f6d339e..0000000000000000000000000000000000000000 --- a/spec/features/issues/reset_filters_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -require 'rails_helper' - -feature 'Issues filter reset button', feature: true, js: true do - include WaitForAjax - include IssueHelpers - - let!(:project) { create(:project, :public) } - let!(:user) { create(:user)} - let!(:milestone) { create(:milestone, project: project) } - let!(:bug) { create(:label, project: project, name: 'bug')} - let!(:issue1) { create(:issue, project: project, milestone: milestone, author: user, assignee: user, title: 'Feature')} - let!(:issue2) { create(:labeled_issue, project: project, labels: [bug], title: 'Bugfix1')} - - before do - project.team << [user, :developer] - end - - context 'when a milestone filter has been applied' do - it 'resets the milestone filter' do - visit_issues(project, milestone_title: milestone.title) - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when a label filter has been applied' do - it 'resets the label filter' do - visit_issues(project, label_name: bug.name) - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when a text search has been conducted' do - it 'resets the text search filter' do - visit_issues(project, search: 'Bug') - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when author filter has been applied' do - it 'resets the author filter' do - visit_issues(project, author_id: user.id) - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when assignee filter has been applied' do - it 'resets the assignee filter' do - visit_issues(project, assignee_id: user.id) - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when all filters have been applied' do - it 'resets all filters' do - visit_issues(project, assignee_id: user.id, author_id: user.id, milestone_title: milestone.title, label_name: bug.name, search: 'Bug') - expect(page).to have_css('.issue', count: 0) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when no filters have been applied' do - it 'the reset link should not be visible' do - visit_issues(project) - expect(page).to have_css('.issue', count: 2) - expect(page).not_to have_css '.reset_filters' - end - end - - def reset_filters - find('.reset-filters').click - end -end diff --git a/spec/features/issues/filter_by_labels_spec.rb b/spec/features/merge_requests/filter_by_labels_spec.rb similarity index 83% rename from spec/features/issues/filter_by_labels_spec.rb rename to spec/features/merge_requests/filter_by_labels_spec.rb index 0253629f753fb03602b897f5af04af09f74913e8..4c60329865cf7c64ec2937202331ca43096004ea 100644 --- a/spec/features/issues/filter_by_labels_spec.rb +++ b/spec/features/merge_requests/filter_by_labels_spec.rb @@ -7,25 +7,27 @@ let!(:user) { create(:user) } let!(:label) { create(:label, project: project) } - before do - bug = create(:label, project: project, title: 'bug') - feature = create(:label, project: project, title: 'feature') - enhancement = create(:label, project: project, title: 'enhancement') + let!(:bug) { create(:label, project: project, title: 'bug') } + let!(:feature) { create(:label, project: project, title: 'feature') } + let!(:enhancement) { create(:label, project: project, title: 'enhancement') } + + let!(:mr1) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "bugfix1") } + let!(:mr2) { create(:merge_request, title: "Bugfix2", source_project: project, target_project: project, source_branch: "bugfix2") } + let!(:mr3) { create(:merge_request, title: "Feature1", source_project: project, target_project: project, source_branch: "feature1") } - issue1 = create(:issue, title: "Bugfix1", project: project) - issue1.labels << bug + before do + mr1.labels << bug - issue2 = create(:issue, title: "Bugfix2", project: project) - issue2.labels << bug - issue2.labels << enhancement + mr2.labels << bug + mr2.labels << enhancement - issue3 = create(:issue, title: "Feature1", project: project) - issue3.labels << feature + mr3.title = "Feature1" + mr3.labels << feature project.team << [user, :master] login_as(user) - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) end context 'filter by label bug' do diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/merge_requests/filter_merge_requests_spec.rb similarity index 66% rename from spec/features/issues/filter_issues_spec.rb rename to spec/features/merge_requests/filter_merge_requests_spec.rb index 0d19563d6284e9048226b8657cb4aaa29e0f30aa..4642b5a530d1c2ec3b0913dd089ca63b9945a81f 100644 --- a/spec/features/issues/filter_issues_spec.rb +++ b/spec/features/merge_requests/filter_merge_requests_spec.rb @@ -1,10 +1,10 @@ require 'rails_helper' -describe 'Filter issues', feature: true do +describe 'Filter merge requests', feature: true do include WaitForAjax + let!(:project) { create(:project) } let!(:group) { create(:group) } - let!(:project) { create(:project, group: group) } let!(:user) { create(:user)} let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } @@ -14,12 +14,12 @@ project.team << [user, :master] group.add_developer(user) login_as(user) - create(:issue, project: project) + create(:merge_request, source_project: project, target_project: project) end - describe 'for assignee from issues#index' do + describe 'for assignee from mr#index' do before do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) find('.js-assignee-search').click @@ -47,9 +47,9 @@ end end - describe 'for milestone from issues#index' do + describe 'for milestone from mr#index' do before do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) find('.js-milestone-select').click @@ -77,9 +77,9 @@ end end - describe 'for label from issues#index', js: true do + describe 'for label from mr#index', js: true do before do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) find('.js-label-select').click wait_for_ajax end @@ -127,7 +127,7 @@ expect(page).to have_content wontfix.title end - find('.dropdown-menu-close-icon').click + find('body').click expect(find('.filtered-labels')).to have_content(wontfix.title) @@ -135,7 +135,7 @@ wait_for_ajax find('.dropdown-menu-labels a', text: label.title).click - find('.dropdown-menu-close-icon').click + find('body').click expect(find('.filtered-labels')).to have_content(wontfix.title) expect(find('.filtered-labels')).to have_content(label.title) @@ -150,21 +150,21 @@ it "selects and unselects `won't fix`" do find('.dropdown-menu-labels a', text: wontfix.title).click find('.dropdown-menu-labels a', text: wontfix.title).click - - find('.dropdown-menu-close-icon').click + # Close label dropdown to load + find('body').click expect(page).not_to have_css('.filtered-labels') end end describe 'for assignee and label from issues#index' do before do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) find('.js-assignee-search').click find('.dropdown-menu-user-link', text: user.username).click - expect(page).not_to have_selector('.issues-list .issue') + expect(page).not_to have_selector('.mr-list .merge-request') find('.js-label-select').click @@ -196,38 +196,40 @@ end end - describe 'filter issues by text' do + describe 'filter merge requests by text' do before do - create(:issue, title: "Bug", project: project) + create(:merge_request, title: "Bug", source_project: project, target_project: project, source_branch: "bug") bug_label = create(:label, project: project, title: 'bug') milestone = create(:milestone, title: "8", project: project) - issue = create(:issue, - title: "Bug 2", - project: project, + mr = create(:merge_request, + title: "Bug 2", + source_project: project, + target_project: project, + source_branch: "bug2", milestone: milestone, author: user, assignee: user) - issue.labels << bug_label + mr.labels << bug_label - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) end context 'only text', js: true do - it 'filters issues by searched text' do + it 'filters merge requests by searched text' do fill_in 'issuable_search', with: 'Bug' - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end end - it 'does not show any issues' do + it 'does not show any merge requests' do fill_in 'issuable_search', with: 'testing' - page.within '.issues-list' do - expect(page).not_to have_selector('.issue') + page.within '.mr-list' do + expect(page).not_to have_selector('.merge-request') end end end @@ -237,8 +239,8 @@ fill_in 'issuable_search', with: 'Bug' expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Label' @@ -248,8 +250,8 @@ find('.dropdown-menu-close-icon').click expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 1) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 1) end end @@ -257,8 +259,8 @@ fill_in 'issuable_search', with: 'Bug' expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Milestone' @@ -267,8 +269,8 @@ end expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 1) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 1) end end @@ -276,8 +278,8 @@ fill_in 'issuable_search', with: 'Bug' expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Assignee' @@ -286,8 +288,8 @@ end expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 1) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 1) end end @@ -295,8 +297,8 @@ fill_in 'issuable_search', with: 'Bug' expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Author' @@ -305,26 +307,27 @@ end expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 1) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 1) end end end end - describe 'filter issues and sort', js: true do + describe 'filter merge requests and sort', js: true do before do bug_label = create(:label, project: project, title: 'bug') - bug_one = create(:issue, title: "Frontend", project: project) - bug_two = create(:issue, title: "Bug 2", project: project) - bug_one.labels << bug_label - bug_two.labels << bug_label + mr1 = create(:merge_request, title: "Frontend", source_project: project, target_project: project, source_branch: "Frontend") + mr2 = create(:merge_request, title: "Bug 2", source_project: project, target_project: project, source_branch: "bug2") - visit namespace_project_issues_path(project.namespace, project) + mr1.labels << bug_label + mr2.labels << bug_label + + visit namespace_project_merge_requests_path(project.namespace, project) end - it 'is able to filter and sort issues' do + it 'is able to filter and sort merge requests' do click_button 'Label' wait_for_ajax page.within '.labels-filter' do @@ -334,8 +337,8 @@ wait_for_ajax expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Last created' @@ -344,41 +347,9 @@ end wait_for_ajax - page.within '.issues-list' do + page.within '.mr-list' do expect(page).to have_content('Frontend') end end end - - it 'updates atom feed link for project issues' do - visit namespace_project_issues_path(project.namespace, project, milestone_title: '', assignee_id: user.id) - - link = find('.nav-controls a', text: 'Subscribe') - params = CGI::parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI::parse(URI.parse(auto_discovery_link[:href]).query) - - expect(params).to include('private_token' => [user.private_token]) - expect(params).to include('milestone_title' => ['']) - expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('private_token' => [user.private_token]) - expect(auto_discovery_params).to include('milestone_title' => ['']) - expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) - end - - it 'updates atom feed link for group issues' do - visit issues_group_path(group, milestone_title: '', assignee_id: user.id) - - link = find('.nav-controls a', text: 'Subscribe') - params = CGI::parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI::parse(URI.parse(auto_discovery_link[:href]).query) - - expect(params).to include('private_token' => [user.private_token]) - expect(params).to include('milestone_title' => ['']) - expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('private_token' => [user.private_token]) - expect(auto_discovery_params).to include('milestone_title' => ['']) - expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) - end end diff --git a/spec/features/merge_requests/reset_filters_spec.rb b/spec/features/merge_requests/reset_filters_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3a7ece7e1d6de16f317b72320c8fba9f5080f3a9 --- /dev/null +++ b/spec/features/merge_requests/reset_filters_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +feature 'Issues filter reset button', feature: true, js: true do + include WaitForAjax + include IssueHelpers + + let!(:project) { create(:project, :public) } + let!(:user) { create(:user)} + let!(:milestone) { create(:milestone, project: project) } + let!(:bug) { create(:label, project: project, name: 'bug')} + let!(:mr1) { create(:merge_request, title: "Feature", source_project: project, target_project: project, source_branch: "Feature", milestone: milestone, author: user, assignee: user) } + let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") } + + let(:merge_request_css) { '.merge-request' } + + before do + mr2.labels << bug + project.team << [user, :developer] + end + + context 'when a milestone filter has been applied' do + it 'resets the milestone filter' do + visit_merge_requests(project, milestone_title: milestone.title) + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when a label filter has been applied' do + it 'resets the label filter' do + visit_merge_requests(project, label_name: bug.name) + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when a text search has been conducted' do + it 'resets the text search filter' do + visit_merge_requests(project, search: 'Bug') + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when author filter has been applied' do + it 'resets the author filter' do + visit_merge_requests(project, author_id: user.id) + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when assignee filter has been applied' do + it 'resets the assignee filter' do + visit_merge_requests(project, assignee_id: user.id) + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when all filters have been applied' do + it 'resets all filters' do + visit_merge_requests(project, assignee_id: user.id, author_id: user.id, milestone_title: milestone.title, label_name: bug.name, search: 'Bug') + expect(page).to have_css(merge_request_css, count: 0) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when no filters have been applied' do + it 'the reset link should not be visible' do + visit_merge_requests(project) + expect(page).to have_css(merge_request_css, count: 2) + expect(page).not_to have_css '.reset_filters' + end + end + + def visit_merge_requests(project, opts = {}) + visit namespace_project_merge_requests_path project.namespace, project, opts + end + + def reset_filters + find('.reset-filters').click + end +end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index caecd027aaa4bda68c48387b3f3a5aa1e437e295..a05b83959fb70379fcdd45095925dcf95750974e 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -169,16 +169,16 @@ find('.dropdown-menu').click_link 'Issues assigned to me' sleep 2 - expect(page).to have_selector('.issues-holder') - expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) + expect(page).to have_selector('.filtered-search') + expect(find('.filtered-search').value).to eq("assignee:@#{user.username}") end it 'takes user to her issues page when issues authored is clicked' do find('.dropdown-menu').click_link "Issues I've created" sleep 2 - expect(page).to have_selector('.issues-holder') - expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name) + expect(page).to have_selector('.filtered-search') + expect(find('.filtered-search').value).to eq("author:@#{user.username}") end it 'takes user to her MR page when MR assigned is clicked' do diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb index 4427443208a12a6a84e3f4066b3bd3089f54a8ee..975e99c58072713dec0ebd4a84a9cec9735dd831 100644 --- a/spec/finders/snippets_finder_spec.rb +++ b/spec/finders/snippets_finder_spec.rb @@ -93,16 +93,39 @@ expect(snippets).not_to include(@snippet1, @snippet2) end - it "returns public and internal snippets for none project members" do + it "returns public and internal snippets for non project members" do snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1) expect(snippets).to include(@snippet2, @snippet3) expect(snippets).not_to include(@snippet1) end + it "returns public snippets for non project members" do + snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_public") + expect(snippets).to include(@snippet3) + expect(snippets).not_to include(@snippet1, @snippet2) + end + + it "returns internal snippets for non project members" do + snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_internal") + expect(snippets).to include(@snippet2) + expect(snippets).not_to include(@snippet1, @snippet3) + end + + it "does not return private snippets for non project members" do + snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_private") + expect(snippets).not_to include(@snippet1, @snippet2, @snippet3) + end + it "returns all snippets for project members" do project1.team << [user, :developer] snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1) expect(snippets).to include(@snippet1, @snippet2, @snippet3) end + + it "returns private snippets for project members" do + project1.team << [user, :developer] + snippets = SnippetsFinder.new.execute(user, filter: :by_project, project: project1, scope: "are_private") + expect(snippets).to include(@snippet1) + end end end diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 b/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..07293b9f87738514382630b7000e1f2472bcced7 --- /dev/null +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 @@ -0,0 +1,121 @@ +//= 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', + }; + + beforeEach(() => { + spyOn(gl.FilteredSearchTokenizer, 'getLastTokenObject') + .and.callFake(query => ({ value: query })); + }); + + 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 invalid symbol', () => { + const updatedItem = gl.DropdownUtils.filterWithSymbol('@', item, ':#'); + expect(updatedItem.droplab_hidden).toBe(true); + }); + + it('should filter with colon', () => { + const updatedItem = gl.DropdownUtils.filterWithSymbol('@', item, ':'); + expect(updatedItem.droplab_hidden).toBe(false); + }); + }); + + describe('filterMethod', () => { + beforeEach(() => { + spyOn(gl.FilteredSearchTokenizer, 'getLastTokenObject') + .and.callFake(query => ({ value: query })); + }); + + it('should filter by hint', () => { + let updatedItem = gl.DropdownUtils.filterMethod({ + hint: 'label', + }, 'l'); + expect(updatedItem.droplab_hidden).toBe(false); + + updatedItem = gl.DropdownUtils.filterMethod({ + hint: 'label', + }, 'o'); + expect(updatedItem.droplab_hidden).toBe(true); + }); + + it('should return droplab_hidden false when item has no hint', () => { + const updatedItem = gl.DropdownUtils.filterMethod({}, ''); + 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(selected); + expect(gl.FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1); + }); + + it('returns true when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + }; + + const result = gl.DropdownUtils.setDataValueIfSelected(selected); + expect(result).toBe(true); + }); + + it('returns false when dataValue does not exist', () => { + const selected = { + getAttribute: () => null, + }; + + const result = gl.DropdownUtils.setDataValueIfSelected(selected); + expect(result).toBe(false); + }); + }); + }); +})(); diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..17d414aaad1a704fe5fd133882a2f4ccd98cbecf --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 @@ -0,0 +1,90 @@ +//= 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; + } + + beforeEach(() => { + const input = document.createElement('input'); + input.classList.add('filtered-search'); + document.body.appendChild(input); + + expect(input.value).toBe(''); + }); + + afterEach(() => { + document.querySelector('.filtered-search').outerHTML = ''; + }); + + describe('input has no existing value', () => { + beforeEach(() => { + spyOn(gl.FilteredSearchTokenizer, 'processTokens') + .and.callFake(() => ({ + lastToken: {}, + })); + }); + + it('should add word', () => { + gl.FilteredSearchDropdownManager.addWordToInput('firstWord'); + expect(getInputValue()).toBe('firstWord'); + }); + + it('should not add space before first word', () => { + gl.FilteredSearchDropdownManager.addWordToInput('firstWord', true); + expect(getInputValue()).toBe('firstWord'); + }); + + it('should not add space before second word by default', () => { + gl.FilteredSearchDropdownManager.addWordToInput('firstWord'); + expect(getInputValue()).toBe('firstWord'); + gl.FilteredSearchDropdownManager.addWordToInput('secondWord'); + expect(getInputValue()).toBe('firstWordsecondWord'); + }); + + it('should add space before new word when addSpace is passed', () => { + expect(getInputValue()).toBe(''); + gl.FilteredSearchDropdownManager.addWordToInput('firstWord'); + expect(getInputValue()).toBe('firstWord'); + gl.FilteredSearchDropdownManager.addWordToInput('secondWord', true); + expect(getInputValue()).toBe('firstWord secondWord'); + }); + }); + + describe('input has exsting value', () => { + it('should only add the remaining characters of the word', () => { + const lastToken = { + key: 'author', + value: 'roo', + }; + + spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.callFake(() => ({ + lastToken, + })); + + document.querySelector('.filtered-search').value = `${lastToken.key}:${lastToken.value}`; + gl.FilteredSearchDropdownManager.addWordToInput('root'); + expect(getInputValue()).toBe('author:root'); + }); + + it('should only add the remaining characters of the word (contains space)', () => { + const lastToken = { + key: 'label', + value: 'test me', + }; + + spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.callFake(() => ({ + lastToken, + })); + + document.querySelector('.filtered-search').value = `${lastToken.key}:"${lastToken.value}"`; + gl.FilteredSearchDropdownManager.addWordToInput('~\'"test me"\''); + expect(getInputValue()).toBe('label:~\'"test me"\''); + }); + }); + }); + }); +})(); diff --git a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..6df7c0e44efe9e29c5c8d4249ccbbacc2ac1e977 --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 @@ -0,0 +1,104 @@ +//= require extensions/array +//= require filtered_search/filtered_search_token_keys + +(() => { + describe('Filtered Search Token Keys', () => { + describe('get', () => { + let tokenKeys; + + beforeEach(() => { + tokenKeys = gl.FilteredSearchTokenKeys.get(); + }); + + it('should return tokenKeys', () => { + expect(tokenKeys !== null).toBe(true); + }); + + it('should return tokenKeys as an array', () => { + expect(tokenKeys instanceof Array).toBe(true); + }); + }); + + describe('getConditions', () => { + let conditions; + + beforeEach(() => { + conditions = gl.FilteredSearchTokenKeys.getConditions(); + }); + + it('should return conditions', () => { + expect(conditions !== null).toBe(true); + }); + + it('should return conditions as an array', () => { + expect(conditions instanceof Array).toBe(true); + }); + }); + + describe('searchByKey', () => { + it('should return null when key not found', () => { + const tokenKey = gl.FilteredSearchTokenKeys.searchByKey('notakey'); + expect(tokenKey === null).toBe(true); + }); + + it('should return tokenKey when found by key', () => { + const tokenKeys = gl.FilteredSearchTokenKeys.get(); + const result = gl.FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key); + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchBySymbol', () => { + it('should return null when symbol not found', () => { + const tokenKey = gl.FilteredSearchTokenKeys.searchBySymbol('notasymbol'); + expect(tokenKey === null).toBe(true); + }); + + it('should return tokenKey when found by symbol', () => { + const tokenKeys = gl.FilteredSearchTokenKeys.get(); + const result = gl.FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol); + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByKeyParam', () => { + it('should return null when key param not found', () => { + const tokenKey = gl.FilteredSearchTokenKeys.searchByKeyParam('notakeyparam'); + expect(tokenKey === null).toBe(true); + }); + + it('should return tokenKey when found by key param', () => { + const tokenKeys = gl.FilteredSearchTokenKeys.get(); + const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`); + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByConditionUrl', () => { + it('should return null when condition url not found', () => { + const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(null); + expect(condition === null).toBe(true); + }); + + it('should return condition when found by url', () => { + const conditions = gl.FilteredSearchTokenKeys.getConditions(); + const result = gl.FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); + expect(result).toBe(conditions[0]); + }); + }); + + describe('searchByConditionKeyValue', () => { + it('should return null when condition tokenKey and value not found', () => { + const condition = gl.FilteredSearchTokenKeys.searchByConditionKeyValue(null, null); + expect(condition === null).toBe(true); + }); + + it('should return condition when found by tokenKey and value', () => { + const conditions = gl.FilteredSearchTokenKeys.getConditions(); + const result = gl.FilteredSearchTokenKeys + .searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value); + expect(result).toEqual(conditions[0]); + }); + }); + }); +})(); diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..c93f163e763c474b988fd7d75a56179c3aac5875 --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 @@ -0,0 +1,271 @@ +//= require extensions/array +//= require filtered_search/filtered_search_token_keys +//= require filtered_search/filtered_search_tokenizer + +(() => { + describe('Filtered Search Tokenizer', () => { + describe('parseToken', () => { + it('should return key, value and symbol', () => { + const { tokenKey, tokenValue, tokenSymbol } = gl.FilteredSearchTokenizer + .parseToken('author:@user'); + + expect(tokenKey).toBe('author'); + expect(tokenValue).toBe('@user'); + expect(tokenSymbol).toBe('@'); + }); + + it('should return value with spaces', () => { + const { tokenKey, tokenValue, tokenSymbol } = gl.FilteredSearchTokenizer + .parseToken('label:~"test me"'); + + expect(tokenKey).toBe('label'); + expect(tokenValue).toBe('~"test me"'); + expect(tokenSymbol).toBe('~'); + }); + }); + + describe('getLastTokenObject', () => { + beforeEach(() => { + spyOn(gl.FilteredSearchTokenizer, 'getLastToken').and.callFake(input => input); + }); + + it('should return key and value', () => { + const { key, value } = gl.FilteredSearchTokenizer.getLastTokenObject('author:@root'); + expect(key).toBe('author'); + expect(value).toBe(':@root'); + }); + + describe('string without colon', () => { + let lastTokenObject; + + beforeEach(() => { + lastTokenObject = gl.FilteredSearchTokenizer.getLastTokenObject('author'); + }); + + it('should return key as an empty string', () => { + expect(lastTokenObject.key).toBe(''); + }); + + it('should return input as value', () => { + expect(lastTokenObject.value).toBe('author'); + }); + }); + }); + + describe('getLastToken', () => { + it('returns entire string when there is only one word', () => { + const lastToken = gl.FilteredSearchTokenizer.getLastToken('input'); + expect(lastToken).toBe('input'); + }); + + it('returns last word when there are multiple words', () => { + const lastToken = gl.FilteredSearchTokenizer.getLastToken('this is a few words'); + expect(lastToken).toBe('words'); + }); + + it('returns last token when there are multiple tokens', () => { + const lastToken = gl.FilteredSearchTokenizer + .getLastToken('label:fun author:root milestone:2.0'); + expect(lastToken).toBe('milestone:2.0'); + }); + + it('returns last token containing spaces escaped by double quotes', () => { + const lastToken = gl.FilteredSearchTokenizer + .getLastToken('label:fun author:root milestone:2.0 label:~"Feature Proposal"'); + expect(lastToken).toBe('label:~"Feature Proposal"'); + }); + + it('returns last token containing spaces escaped by single quotes', () => { + const lastToken = gl.FilteredSearchTokenizer + .getLastToken('label:fun author:root milestone:2.0 label:~\'Feature Proposal\''); + expect(lastToken).toBe('label:~\'Feature Proposal\''); + }); + + it('returns last token containing special characters', () => { + const lastToken = gl.FilteredSearchTokenizer + .getLastToken('label:fun author:root milestone:2.0 label:~!@#$%^&*()'); + expect(lastToken).toBe('label:~!@#$%^&*()'); + }); + }); + + describe('processTokens', () => { + describe('input does not contain any tokens', () => { + let results; + beforeEach(() => { + results = gl.FilteredSearchTokenizer.processTokens('searchTerm'); + }); + + it('returns input as searchToken', () => { + expect(results.searchToken).toBe('searchTerm'); + }); + + it('returns tokens as an empty array', () => { + expect(results.tokens.length).toBe(0); + }); + + it('returns lastToken equal to searchToken', () => { + expect(results.lastToken).toBe(results.searchToken); + }); + }); + + describe('input contains only tokens', () => { + let results; + beforeEach(() => { + results = gl.FilteredSearchTokenizer + .processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none'); + }); + + it('returns searchToken as an empty string', () => { + expect(results.searchToken).toBe(''); + }); + + it('returns tokens array of size equal to the number of tokens in input', () => { + expect(results.tokens.length).toBe(4); + }); + + it('returns tokens array that matches the tokens found in input', () => { + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('@root'); + expect(results.tokens[0].wildcard).toBe(false); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('~Very Important'); + expect(results.tokens[1].wildcard).toBe(false); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('%v1.0'); + expect(results.tokens[2].wildcard).toBe(false); + + expect(results.tokens[3].key).toBe('assignee'); + expect(results.tokens[3].value).toBe('none'); + expect(results.tokens[3].wildcard).toBe(true); + }); + + it('returns lastToken equal to the last object in the tokens array', () => { + expect(results.tokens[3]).toBe(results.lastToken); + }); + }); + + describe('input starts with search value and ends with tokens', () => { + let results; + beforeEach(() => { + results = gl.FilteredSearchTokenizer + .processTokens('searchTerm anotherSearchTerm milestone:none'); + }); + + it('returns searchToken', () => { + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + }); + + it('returns correct number of tokens', () => { + expect(results.tokens.length).toBe(1); + }); + + it('returns correct tokens', () => { + expect(results.tokens[0].key).toBe('milestone'); + expect(results.tokens[0].value).toBe('none'); + expect(results.tokens[0].wildcard).toBe(true); + }); + + it('returns lastToken', () => { + expect(results.tokens[0]).toBe(results.lastToken); + }); + }); + + describe('input starts with token and ends with search value', () => { + let results; + beforeEach(() => { + results = gl.FilteredSearchTokenizer + .processTokens('assignee:@user searchTerm'); + }); + + it('returns searchToken', () => { + expect(results.searchToken).toBe('searchTerm'); + }); + + it('returns correct number of tokens', () => { + expect(results.tokens.length).toBe(1); + }); + + it('returns correct tokens', () => { + expect(results.tokens[0].key).toBe('assignee'); + expect(results.tokens[0].value).toBe('@user'); + expect(results.tokens[0].wildcard).toBe(false); + }); + + it('returns lastToken as the searchTerm', () => { + expect(results.lastToken).toBe(results.searchToken); + }); + }); + + describe('input contains search value wrapped between tokens', () => { + let results; + beforeEach(() => { + results = gl.FilteredSearchTokenizer + .processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none'); + }); + + it('returns searchToken', () => { + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + }); + + it('returns correct number of tokens', () => { + expect(results.tokens.length).toBe(3); + }); + + + it('returns tokens array in the order it was processed', () => { + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('@root'); + expect(results.tokens[0].wildcard).toBe(false); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('~Won\'t fix'); + expect(results.tokens[1].wildcard).toBe(false); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('none'); + expect(results.tokens[2].wildcard).toBe(true); + }); + + it('returns lastToken', () => { + expect(results.tokens[2]).toBe(results.lastToken); + }); + }); + + describe('input search value is spaced in between tokens', () => { + let results; + beforeEach(() => { + results = gl.FilteredSearchTokenizer + .processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing'); + }); + + it('returns searchToken', () => { + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + }); + + it('returns correct number of tokens', () => { + expect(results.tokens.length).toBe(3); + }); + + it('returns tokens array in the order it was processed', () => { + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('@root'); + expect(results.tokens[0].wildcard).toBe(false); + + expect(results.tokens[1].key).toBe('assignee'); + expect(results.tokens[1].value).toBe('none'); + expect(results.tokens[1].wildcard).toBe(true); + + expect(results.tokens[2].key).toBe('label'); + expect(results.tokens[2].value).toBe('~Doing'); + expect(results.tokens[2].wildcard).toBe(false); + }); + + it('returns lastToken', () => { + expect(results.tokens[2]).toBe(results.lastToken); + }); + }); + }); + }); +})(); diff --git a/spec/javascripts/fixtures/pipeline_graph.html.haml b/spec/javascripts/fixtures/pipeline_graph.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..deca50ceaa74b54c1c90755501144b587e23c416 --- /dev/null +++ b/spec/javascripts/fixtures/pipeline_graph.html.haml @@ -0,0 +1,15 @@ +%div.pipeline-visualization.js-pipeline-graph + %ul.stage-column-list + %li.stage-column + .stage-name + %a{:href => "/"} + Test + .builds-container + %ul + %li.build + .curve + .build-content + %a + %svg + .ci-status-text + stop_review diff --git a/spec/javascripts/lib/utils/common_utils_spec.js.es6 b/spec/javascripts/lib/utils/common_utils_spec.js.es6 index ef75f6008985aaa55ae506631e2e8bc3aca0a63f..031f9ca03c96361492aed9e724925e759af05442 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js.es6 +++ b/spec/javascripts/lib/utils/common_utils_spec.js.es6 @@ -15,6 +15,7 @@ expect(gl.utils.parseUrl('" test="asf"').pathname).toEqual('/teaspoon/%22%20test=%22asf%22'); }); }); + describe('gl.utils.parseUrlPathname', () => { beforeEach(() => { spyOn(gl.utils, 'parseUrl').and.callFake(url => ({ @@ -28,5 +29,28 @@ expect(gl.utils.parseUrlPathname('some/relative/url')).toEqual('/some/relative/url'); }); }); + + describe('gl.utils.getUrlParamsArray', () => { + it('should return params array', () => { + expect(gl.utils.getUrlParamsArray() instanceof Array).toBe(true); + }); + + it('should remove the question mark from the search params', () => { + const paramsArray = gl.utils.getUrlParamsArray(); + expect(paramsArray[0][0] !== '?').toBe(true); + }); + }); + + describe('gl.utils.getParameterByName', () => { + it('should return valid parameter', () => { + const value = gl.utils.getParameterByName('reporter'); + expect(value).toBe('Console'); + }); + + it('should return invalid parameter', () => { + const value = gl.utils.getParameterByName('fakeParameter'); + expect(value).toBe(null); + }); + }); }); })(); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js.es6 b/spec/javascripts/lib/utils/text_utility_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..e97356b65d5510088261738640d8dbd30dcd65a3 --- /dev/null +++ b/spec/javascripts/lib/utils/text_utility_spec.js.es6 @@ -0,0 +1,25 @@ +//= require lib/utils/text_utility + +(() => { + describe('text_utility', () => { + describe('gl.text.getTextWidth', () => { + it('returns zero width when no text is passed', () => { + expect(gl.text.getTextWidth('')).toBe(0); + }); + + it('returns zero width when no text is passed and font is passed', () => { + expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0); + }); + + it('returns width when text is passed', () => { + expect(gl.text.getTextWidth('foo') > 0).toBe(true); + }); + + it('returns bigger width when font is larger', () => { + const largeFont = gl.text.getTextWidth('foo', '100px sans-serif'); + const regular = gl.text.getTextWidth('foo', '10px sans-serif'); + expect(largeFont > regular).toBe(true); + }); + }); + }); +})(); diff --git a/spec/javascripts/pipelines_spec.js.es6 b/spec/javascripts/pipelines_spec.js.es6 new file mode 100644 index 0000000000000000000000000000000000000000..85c9cf4b4f1d0f3f2c1090b38c3a66d1eaa22a85 --- /dev/null +++ b/spec/javascripts/pipelines_spec.js.es6 @@ -0,0 +1,25 @@ +//= require pipelines + +(() => { + describe('Pipelines', () => { + fixture.preload('pipeline_graph'); + + beforeEach(() => { + fixture.load('pipeline_graph'); + }); + + it('should be defined', () => { + expect(window.gl.Pipelines).toBeDefined(); + }); + + it('should create a `Pipelines` instance without options', () => { + expect(() => { new window.gl.Pipelines(); }).not.toThrow(); //eslint-disable-line + }); + + it('should create a `Pipelines` instance with options', () => { + const pipelines = new window.gl.Pipelines({ foo: 'bar' }); + + expect(pipelines.pipelineGraph).toBeDefined(); + }); + }); +})(); diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index 1b7f642d59e0af330e75fdd52c4915cd0c08b161..e2d548f95e97f51493d77007ee2640251f570161 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -11,6 +11,7 @@ (function() { var addBodyAttributes, assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget; + var userName = 'root'; widget = null; @@ -19,6 +20,7 @@ window.gon || (window.gon = {}); window.gon.current_user_id = userId; + window.gon.current_username = userName; dashboardIssuesPath = '/dashboard/issues'; @@ -93,8 +95,8 @@ assertLinks = function(list, issuesPath, mrsPath) { var a1, a2, a3, a4, issuesAssignedToMeLink, issuesIHaveCreatedLink, mrsAssignedToMeLink, mrsIHaveCreatedLink; - issuesAssignedToMeLink = issuesPath + "/?assignee_id=" + userId; - issuesIHaveCreatedLink = issuesPath + "/?author_id=" + userId; + issuesAssignedToMeLink = issuesPath + "/?assignee_username=" + userName; + issuesIHaveCreatedLink = issuesPath + "/?author_username=" + userName; mrsAssignedToMeLink = mrsPath + "/?assignee_id=" + userId; mrsIHaveCreatedLink = mrsPath + "/?author_id=" + userId; a1 = "a[href='" + issuesAssignedToMeLink + "']"; diff --git a/spec/javascripts/smart_interval_spec.js.es6 b/spec/javascripts/smart_interval_spec.js.es6 index ed6166a25a83b40324e62c81596cc2b39594ce81..1b7ca97cde48c6e071e012c5b40a0e76f3465c76 100644 --- a/spec/javascripts/smart_interval_spec.js.es6 +++ b/spec/javascripts/smart_interval_spec.js.es6 @@ -14,8 +14,9 @@ startingInterval: DEFAULT_STARTING_INTERVAL, maxInterval: DEFAULT_MAX_INTERVAL, incrementByFactorOf: DEFAULT_INCREMENT_FACTOR, - delayStartBy: 0, lazyStart: false, + immediateExecution: false, + hiddenInterval: null, }; if (config) { @@ -114,14 +115,31 @@ expect(interval.state.intervalId).toBeTruthy(); // simulates triggering of visibilitychange event - interval.state.pageVisibility = 'hidden'; - interval.handleVisibilityChange(); + interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } }); expect(interval.state.intervalId).toBeUndefined(); done(); }, DEFAULT_SHORT_TIMEOUT); }); + it('should change to the hidden interval when page is not visible', function (done) { + const HIDDEN_INTERVAL = 1500; + const interval = createDefaultSmartInterval({ hiddenInterval: HIDDEN_INTERVAL }); + + setTimeout(() => { + expect(interval.state.intervalId).toBeTruthy(); + expect(interval.getCurrentInterval() >= DEFAULT_STARTING_INTERVAL && + interval.getCurrentInterval() <= DEFAULT_MAX_INTERVAL).toBeTruthy(); + + // simulates triggering of visibilitychange event + interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } }); + + expect(interval.state.intervalId).toBeTruthy(); + expect(interval.getCurrentInterval()).toBe(HIDDEN_INTERVAL); + done(); + }, DEFAULT_SHORT_TIMEOUT); + }); + it('should resume when page is becomes visible at the previous interval', function (done) { const interval = this.smartInterval; @@ -129,14 +147,12 @@ expect(interval.state.intervalId).toBeTruthy(); // simulates triggering of visibilitychange event - interval.state.pageVisibility = 'hidden'; - interval.handleVisibilityChange(); + interval.handleVisibilityChange({ target: { visibilityState: 'hidden' } }); expect(interval.state.intervalId).toBeUndefined(); // simulates triggering of visibilitychange event - interval.state.pageVisibility = 'visible'; - interval.handleVisibilityChange(); + interval.handleVisibilityChange({ target: { visibilityState: 'visible' } }); expect(interval.state.intervalId).toBeTruthy(); @@ -154,6 +170,11 @@ done(); }, DEFAULT_SHORT_TIMEOUT); }); + + it('should execute callback before first interval', function () { + const interval = createDefaultSmartInterval({ immediateExecution: true }); + expect(interval.cfg.immediateExecution).toBeFalsy(); + }); }); }); })(window.gl || (window.gl = {})); diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/relative_link_filter_spec.rb index 2bfa51deb2063406c69d8a1e4c6d45af1231c030..df2dd173b5786d5d8b93e2433c8d8e7bd098ba4c 100644 --- a/spec/lib/banzai/filter/relative_link_filter_spec.rb +++ b/spec/lib/banzai/filter/relative_link_filter_spec.rb @@ -175,7 +175,7 @@ def link(path) allow_any_instance_of(described_class).to receive(:uri_type).and_return(:raw) doc = filter(image(escaped)) - expect(doc.at_css('img')['src']).to match '/raw/' + expect(doc.at_css('img')['src']).to eq "/#{project_path}/raw/#{Addressable::URI.escape(ref)}/#{escaped}" end context 'when requested path is a file in the repo' do diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb index d30361f53d4aa0de064bf336d912a1fa3724d422..668e39f9dba983a4e2fe3d7f51543ed1079d9b18 100644 --- a/spec/requests/api/services_spec.rb +++ b/spec/requests/api/services_spec.rb @@ -2,6 +2,7 @@ describe API::Services, api: true do include ApiHelpers + let(:user) { create(:user) } let(:admin) { create(:admin) } let(:user2) { create(:user) } @@ -98,7 +99,7 @@ post api("/projects/#{project.id}/services/idonotexist/trigger") expect(response).to have_http_status(404) - expect(json_response["message"]).to eq("404 Service Not Found") + expect(json_response["error"]).to eq("404 Not Found") end end @@ -114,7 +115,7 @@ end it 'when the service is inactive' do - post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger") + post api("/projects/#{project.id}/services/mattermost_slash_commands/trigger"), params expect(response).to have_http_status(404) end diff --git a/spec/support/services_shared_context.rb b/spec/support/services_shared_context.rb index d1c999cad4de8d2625964478f90b86f6d7b55cd2..66c93890e31ef202211c7d1643f206f8e7567ddf 100644 --- a/spec/support/services_shared_context.rb +++ b/spec/support/services_shared_context.rb @@ -16,8 +16,14 @@ hash.merge!(k => 'secrettoken') elsif k =~ /^(.*_url|url|webhook)/ hash.merge!(k => "http://example.com") + elsif service_klass.method_defined?("#{k}?") + hash.merge!(k => true) elsif service == 'irker' && k == :recipients hash.merge!(k => 'irc://irc.network.net:666/#channel') + elsif service == 'irker' && k == :server_port + hash.merge!(k => 1234) + elsif service == 'jira' && k == :jira_issue_transition_id + hash.merge!(k => 1234) else hash.merge!(k => "someword") end diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb index bf027499c94c59f6165510fea384070517f591a1..a066ea078e65bd704aa066e50a8ccd6f9342af75 100644 --- a/spec/views/projects/pipelines/show.html.haml_spec.rb +++ b/spec/views/projects/pipelines/show.html.haml_spec.rb @@ -28,7 +28,7 @@ it 'shows a graph with grouped stages' do render - expect(rendered).to have_css('.pipeline-graph') + expect(rendered).to have_css('.js-pipeline-graph') expect(rendered).to have_css('.grouped-pipeline-dropdown') # stages