diff options
Diffstat (limited to 'app')
1450 files changed, 35057 insertions, 17107 deletions
diff --git a/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico Binary files differnew file mode 100644 index 00000000000..4af3582b60d --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_canceled.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_created.ico b/app/assets/images/ci_favicons/dev/favicon_status_created.ico Binary files differnew file mode 100644 index 00000000000..13639da2e8a --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_created.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_failed.ico b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico Binary files differnew file mode 100644 index 00000000000..5f0e711b104 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_failed.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_manual.ico b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico Binary files differnew file mode 100644 index 00000000000..8b1168a1267 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_manual.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico Binary files differnew file mode 100644 index 00000000000..ed19b69e1c5 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_not_found.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_pending.ico b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico Binary files differnew file mode 100644 index 00000000000..5dfefd4cc5a --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_pending.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_running.ico b/app/assets/images/ci_favicons/dev/favicon_status_running.ico Binary files differnew file mode 100644 index 00000000000..a41539c0e3e --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_running.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico Binary files differnew file mode 100644 index 00000000000..2c1ae552b93 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_skipped.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_success.ico b/app/assets/images/ci_favicons/dev/favicon_status_success.ico Binary files differnew file mode 100644 index 00000000000..70f0ca61eca --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_success.ico diff --git a/app/assets/images/ci_favicons/dev/favicon_status_warning.ico b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico Binary files differnew file mode 100644 index 00000000000..db289e03eb1 --- /dev/null +++ b/app/assets/images/ci_favicons/dev/favicon_status_warning.ico diff --git a/app/assets/images/ci_favicons/favicon_status_canceled.ico b/app/assets/images/ci_favicons/favicon_status_canceled.ico Binary files differnew file mode 100644 index 00000000000..23adcffff50 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_canceled.ico diff --git a/app/assets/images/ci_favicons/favicon_status_created.ico b/app/assets/images/ci_favicons/favicon_status_created.ico Binary files differnew file mode 100644 index 00000000000..f9d93b390d8 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_created.ico diff --git a/app/assets/images/ci_favicons/favicon_status_failed.ico b/app/assets/images/ci_favicons/favicon_status_failed.ico Binary files differnew file mode 100644 index 00000000000..28a22ebf724 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_failed.ico diff --git a/app/assets/images/ci_favicons/favicon_status_manual.ico b/app/assets/images/ci_favicons/favicon_status_manual.ico Binary files differnew file mode 100644 index 00000000000..dbbf1abf30c --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_manual.ico diff --git a/app/assets/images/ci_favicons/favicon_status_not_found.ico b/app/assets/images/ci_favicons/favicon_status_not_found.ico Binary files differnew file mode 100644 index 00000000000..49b9b232dd1 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_not_found.ico diff --git a/app/assets/images/ci_favicons/favicon_status_pending.ico b/app/assets/images/ci_favicons/favicon_status_pending.ico Binary files differnew file mode 100644 index 00000000000..05962f3f148 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_pending.ico diff --git a/app/assets/images/ci_favicons/favicon_status_running.ico b/app/assets/images/ci_favicons/favicon_status_running.ico Binary files differnew file mode 100644 index 00000000000..7fa3d4d48d4 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_running.ico diff --git a/app/assets/images/ci_favicons/favicon_status_skipped.ico b/app/assets/images/ci_favicons/favicon_status_skipped.ico Binary files differnew file mode 100644 index 00000000000..b0c26b62068 --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_skipped.ico diff --git a/app/assets/images/ci_favicons/favicon_status_success.ico b/app/assets/images/ci_favicons/favicon_status_success.ico Binary files differnew file mode 100644 index 00000000000..b150960b5be --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_success.ico diff --git a/app/assets/images/ci_favicons/favicon_status_warning.ico b/app/assets/images/ci_favicons/favicon_status_warning.ico Binary files differnew file mode 100644 index 00000000000..7e71d71684d --- /dev/null +++ b/app/assets/images/ci_favicons/favicon_status_warning.ico diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e5f36c84987..6680834a8d1 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,148 +1,175 @@ -/* eslint-disable func-names, space-before-function-paren, quotes, object-shorthand, camelcase, no-var, comma-dangle, prefer-arrow-callback, quote-props, no-param-reassign, max-len */ - -var Api = { - groupsPath: "/api/:version/groups.json", - groupPath: "/api/:version/groups/:id.json", - namespacesPath: "/api/:version/namespaces.json", - groupProjectsPath: "/api/:version/groups/:id/projects.json", - projectsPath: "/api/:version/projects.json?simple=true", - labelsPath: "/:namespace_path/:project_path/labels", - licensePath: "/api/:version/templates/licenses/:key", - gitignorePath: "/api/:version/templates/gitignores/:key", - gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key", - dockerfilePath: "/api/:version/templates/dockerfiles/:key", - issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", - group: function(group_id, callback) { - var url = Api.buildUrl(Api.groupPath) - .replace(':id', group_id); +import $ from 'jquery'; + +const Api = { + groupsPath: '/api/:version/groups.json', + groupPath: '/api/:version/groups/:id.json', + namespacesPath: '/api/:version/namespaces.json', + groupProjectsPath: '/api/:version/groups/:id/projects.json', + projectsPath: '/api/:version/projects.json?simple=true', + labelsPath: '/:namespace_path/:project_path/labels', + licensePath: '/api/:version/templates/licenses/:key', + gitignorePath: '/api/:version/templates/gitignores/:key', + gitlabCiYmlPath: '/api/:version/templates/gitlab_ci_ymls/:key', + dockerfilePath: '/api/:version/templates/dockerfiles/:key', + issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', + usersPath: '/api/:version/users.json', + + group(groupId, callback) { + const url = Api.buildUrl(Api.groupPath) + .replace(':id', groupId); return $.ajax({ - url: url, - dataType: "json" - }).done(function(group) { - return callback(group); - }); + url, + dataType: 'json', + }) + .done(group => callback(group)); }, + // Return groups list. Filtered by query - groups: function(query, options, callback) { - var url = Api.buildUrl(Api.groupsPath); + groups(query, options, callback) { + const url = Api.buildUrl(Api.groupsPath); return $.ajax({ - url: url, - data: $.extend({ + url, + data: Object.assign({ search: query, - per_page: 20 + per_page: 20, }, options), - dataType: "json" - }).done(function(groups) { - return callback(groups); - }); + dataType: 'json', + }) + .done(groups => callback(groups)); }, + // Return namespaces list. Filtered by query - namespaces: function(query, callback) { - var url = Api.buildUrl(Api.namespacesPath); + namespaces(query, callback) { + const url = Api.buildUrl(Api.namespacesPath); return $.ajax({ - url: url, + url, data: { search: query, - per_page: 20 + per_page: 20, }, - dataType: "json" - }).done(function(namespaces) { - return callback(namespaces); - }); + dataType: 'json', + }).done(namespaces => callback(namespaces)); }, + // Return projects list. Filtered by query - projects: function(query, options, callback) { - var url = Api.buildUrl(Api.projectsPath); + projects(query, options, callback) { + const url = Api.buildUrl(Api.projectsPath); return $.ajax({ - url: url, - data: $.extend({ + url, + data: Object.assign({ search: query, per_page: 20, - membership: true + membership: true, }, options), - dataType: "json" - }).done(function(projects) { - return callback(projects); - }); + dataType: 'json', + }) + .done(projects => callback(projects)); }, - newLabel: function(namespace_path, project_path, data, callback) { - var url = Api.buildUrl(Api.labelsPath) - .replace(':namespace_path', namespace_path) - .replace(':project_path', project_path); + + newLabel(namespacePath, projectPath, data, callback) { + const url = Api.buildUrl(Api.labelsPath) + .replace(':namespace_path', namespacePath) + .replace(':project_path', projectPath); return $.ajax({ - url: url, - type: "POST", - data: { 'label': data }, - dataType: "json" - }).done(function(label) { - return callback(label); - }).error(function(message) { - return callback(message.responseJSON); - }); + url, + type: 'POST', + data: { label: data }, + dataType: 'json', + }) + .done(label => callback(label)) + .error(message => callback(message.responseJSON)); }, + // Return group projects list. Filtered by query - groupProjects: function(group_id, query, callback) { - var url = Api.buildUrl(Api.groupProjectsPath) - .replace(':id', group_id); + groupProjects(groupId, query, callback) { + const url = Api.buildUrl(Api.groupProjectsPath) + .replace(':id', groupId); return $.ajax({ - url: url, + url, data: { search: query, - per_page: 20 + per_page: 20, }, - dataType: "json" - }).done(function(projects) { - return callback(projects); - }); + dataType: 'json', + }) + .done(projects => callback(projects)); }, + // Return text for a specific license - licenseText: function(key, data, callback) { - var url = Api.buildUrl(Api.licensePath) + licenseText(key, data, callback) { + const url = Api.buildUrl(Api.licensePath) .replace(':key', key); return $.ajax({ - url: url, - data: data - }).done(function(license) { - return callback(license); - }); + url, + data, + }) + .done(license => callback(license)); }, - gitignoreText: function(key, callback) { - var url = Api.buildUrl(Api.gitignorePath) + + gitignoreText(key, callback) { + const url = Api.buildUrl(Api.gitignorePath) .replace(':key', key); - return $.get(url, function(gitignore) { - return callback(gitignore); - }); + return $.get(url, gitignore => callback(gitignore)); }, - gitlabCiYml: function(key, callback) { - var url = Api.buildUrl(Api.gitlabCiYmlPath) + + gitlabCiYml(key, callback) { + const url = Api.buildUrl(Api.gitlabCiYmlPath) .replace(':key', key); - return $.get(url, function(file) { - return callback(file); - }); + return $.get(url, file => callback(file)); }, - dockerfileYml: function(key, callback) { - var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); + + dockerfileYml(key, callback) { + const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); $.get(url, callback); }, - issueTemplate: function(namespacePath, projectPath, key, type, callback) { - var url = Api.buildUrl(Api.issuableTemplatePath) + + issueTemplate(namespacePath, projectPath, key, type, callback) { + const url = Api.buildUrl(Api.issuableTemplatePath) .replace(':key', key) .replace(':type', type) .replace(':project_path', projectPath) .replace(':namespace_path', namespacePath); $.ajax({ - url: url, - dataType: 'json' - }).done(function(file) { - callback(null, file); - }).error(callback); + url, + dataType: 'json', + }) + .done(file => callback(null, file)) + .error(callback); }, - buildUrl: function(url) { + + users(query, options) { + const url = Api.buildUrl(this.usersPath); + return Api.wrapAjaxCall({ + url, + data: Object.assign({ + search: query, + per_page: 20, + }, options), + dataType: 'json', + }); + }, + + buildUrl(url) { + let urlRoot = ''; if (gon.relative_url_root != null) { - url = gon.relative_url_root + url; + urlRoot = gon.relative_url_root; } - return url.replace(':version', gon.api_version); - } + return urlRoot + url.replace(':version', gon.api_version); + }, + + wrapAjaxCall(options) { + return new Promise((resolve, reject) => { + // jQuery 2 is not Promises/A+ compatible (missing catch) + $.ajax(options) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${options.url}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }); + }, }; -window.Api = Api; +export default Api; diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 8630b18a73f..cfab6c40b34 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,8 +1,11 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */ +import AccessorUtilities from './lib/utils/accessor'; window.Autosave = (function() { function Autosave(field, key) { this.field = field; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + if (key.join != null) { key = key.join("/"); } @@ -17,16 +20,12 @@ window.Autosave = (function() { } Autosave.prototype.restore = function() { - var e, text; - if (window.localStorage == null) { - return; - } - try { - text = window.localStorage.getItem(this.key); - } catch (error) { - e = error; - return; - } + var text; + + if (!this.isLocalStorageAvailable) return; + + text = window.localStorage.getItem(this.key); + if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } @@ -35,27 +34,22 @@ window.Autosave = (function() { Autosave.prototype.save = function() { var text; - if (window.localStorage == null) { - return; - } text = this.field.val(); - if ((text != null ? text.length : void 0) > 0) { - try { - return window.localStorage.setItem(this.key, text); - } catch (error) {} - } else { - return this.reset(); + + if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { + return window.localStorage.setItem(this.key, text); } + + return this.reset(); }; Autosave.prototype.reset = function() { - if (window.localStorage == null) { - return; - } - try { - return window.localStorage.removeItem(this.key); - } catch (error) {} + if (!this.isLocalStorageAvailable) return; + + return window.localStorage.removeItem(this.key); }; return Autosave; })(); + +export default window.Autosave; diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index c743dd551d7..adb45b0606d 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -1,3 +1,5 @@ +/* global Flash */ + import Cookies from 'js-cookie'; import emojiMap from 'emojis/digests.json'; @@ -6,6 +8,7 @@ import { glEmojiTag } from './behaviors/gl_emoji'; import isEmojiNameValid from './behaviors/gl_emoji/is_emoji_name_valid'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; +const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; const requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || @@ -51,7 +54,7 @@ function renderCategory(name, emojiList, opts = {}) { <h5 class="emoji-menu-title"> ${name} </h5> - <ul class="clearfix emoji-menu-list ${opts.menuListClass}"> + <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}"> ${emojiList.map(emojiName => ` <li class="emoji-menu-list-item"> <button class="emoji-menu-btn text-center js-emoji-btn" type="button"> @@ -103,8 +106,9 @@ function AwardsHandler() { const $glEmojiElement = $target.find('gl-emoji'); const $spriteIconElement = $target.find('.icon'); const emoji = ($glEmojiElement.length ? $glEmojiElement : $spriteIconElement).data('name'); + $target.closest('.js-awards-block').addClass('current'); - return this.addAward(this.getVotesBlock(), this.getAwardUrl(), emoji); + this.addAward(this.getVotesBlock(), this.getAwardUrl(), emoji); }); } @@ -124,16 +128,18 @@ AwardsHandler.prototype.showEmojiMenu = function showEmojiMenu($addBtn) { } const $menu = $('.emoji-menu'); + const $thumbsBtn = $menu.find('[data-name="thumbsup"], [data-name="thumbsdown"]').parent(); + const $userAuthored = this.isUserAuthored($addBtn); if ($menu.length) { if ($menu.is('.is-visible')) { $addBtn.removeClass('is-active'); $menu.removeClass('is-visible'); - $('#emoji_search').blur(); + $('.js-emoji-menu-search').blur(); } else { $addBtn.addClass('is-active'); this.positionMenu($menu, $addBtn); $menu.addClass('is-visible'); - $('#emoji_search').focus(); + $('.js-emoji-menu-search').focus(); } } else { $addBtn.addClass('is-loading is-active'); @@ -143,10 +149,12 @@ AwardsHandler.prototype.showEmojiMenu = function showEmojiMenu($addBtn) { this.positionMenu($createdMenu, $addBtn); return setTimeout(() => { $createdMenu.addClass('is-visible'); - $('#emoji_search').focus(); + $('.js-emoji-menu-search').focus(); }, 200); }); } + + $thumbsBtn.toggleClass('disabled', $userAuthored); }; // Create the emoji menu with the first category of emojis. @@ -174,7 +182,7 @@ AwardsHandler.prototype.createEmojiMenu = function createEmojiMenu(callback) { const emojiMenuMarkup = ` <div class="emoji-menu"> - <input type="text" name="emoji_search" id="emoji_search" value="" class="emoji-search search-input form-control" placeholder="Search emoji" /> + <input type="text" name="emoji-menu-search" value="" class="js-emoji-menu-search emoji-search search-input form-control" placeholder="Search emoji" /> <div class="emoji-menu-content"> ${frequentlyUsedCatgegory} @@ -231,6 +239,9 @@ AwardsHandler if (menu) { menu.dispatchEvent(new CustomEvent('build-emoji-menu-finish')); } + }).catch((err) => { + emojiContentElement.insertAdjacentHTML('beforeend', '<p>We encountered an error while adding the remaining categories</p>'); + throw new Error(`Error occurred in addRemainingEmojiMenuCategories: ${err.message}`); }); }; @@ -259,11 +270,13 @@ AwardsHandler.prototype.addAward = function addAward( callback, ) { const normalizedEmoji = this.normalizeEmojiName(emoji); - this.postEmoji(awardUrl, normalizedEmoji, () => { + const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent(); + this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => { this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); return typeof callback === 'function' ? callback() : undefined; }); - return $('.emoji-menu').removeClass('is-visible'); + $('.emoji-menu').removeClass('is-visible'); + $('.js-add-award.is-active').removeClass('is-active'); }; AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar( @@ -323,6 +336,10 @@ AwardsHandler.prototype.isActive = function isActive($emojiButton) { return $emojiButton.hasClass('active'); }; +AwardsHandler.prototype.isUserAuthored = function isUserAuthored($button) { + return $button.hasClass('js-user-authored'); +}; + AwardsHandler.prototype.decrementCounter = function decrementCounter($emojiButton, emoji) { const counter = $('.js-counter', $emojiButton); const counterNumber = parseInt(counter.text(), 10); @@ -427,20 +444,35 @@ AwardsHandler.prototype.createEmoji = function createEmoji(votesBlock, emoji) { }); }; -AwardsHandler.prototype.postEmoji = function postEmoji(awardUrl, emoji, callback) { - return $.post(awardUrl, { - name: emoji, - }, (data) => { - if (data.ok) { - callback(); - } - }); +AwardsHandler.prototype.postEmoji = function postEmoji($emojiButton, awardUrl, emoji, callback) { + if (this.isUserAuthored($emojiButton)) { + this.userAuthored($emojiButton); + } else { + $.post(awardUrl, { + name: emoji, + }, (data) => { + if (data.ok) { + callback(); + } + }).fail(() => new Flash('Something went wrong on our end.')); + } }; AwardsHandler.prototype.findEmojiIcon = function findEmojiIcon(votesBlock, emoji) { return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`); }; +AwardsHandler.prototype.userAuthored = function userAuthored($emojiButton) { + const oldTitle = this.getAwardTooltip($emojiButton); + const newTitle = 'You cannot vote on your own issue, MR and note'; + gl.utils.updateTooltipTitle($emojiButton, newTitle).tooltip('show'); + // Restore tooltip back to award list + return setTimeout(() => { + $emojiButton.tooltip('hide'); + gl.utils.updateTooltipTitle($emojiButton, oldTitle); + }, 2800); +}; + AwardsHandler.prototype.scrollToAwards = function scrollToAwards() { const options = { scrollTop: $('.awards').offset().top - 110, @@ -473,24 +505,41 @@ AwardsHandler.prototype.getFrequentlyUsedEmojis = function getFrequentlyUsedEmoj }; AwardsHandler.prototype.setupSearch = function setupSearch() { - this.registerEventListener('on', $('input.emoji-search'), 'input', (e) => { + const $search = $('.js-emoji-menu-search'); + + this.registerEventListener('on', $search, 'input', (e) => { const term = $(e.target).val().trim(); - // Clean previous search results - $('ul.emoji-menu-search, h5.emoji-search').remove(); - if (term.length > 0) { - // Generate a search result block - const h5 = $('<h5 class="emoji-search" />').text('Search results'); - const foundEmojis = this.searchEmojis(term).show(); - const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis); - $('.emoji-menu-content ul, .emoji-menu-content h5').hide(); - $('.emoji-menu-content').append(h5).append(ul); - } else { - $('.emoji-menu-content').children().show(); + this.searchEmojis(term); + }); + + const $menu = $('.emoji-menu'); + this.registerEventListener('on', $menu, transitionEndEventString, (e) => { + if (e.target === e.currentTarget) { + // Clear the search + this.searchEmojis(''); } }); }; AwardsHandler.prototype.searchEmojis = function searchEmojis(term) { + const $search = $('.js-emoji-menu-search'); + $search.val(term); + + // Clean previous search results + $('ul.emoji-menu-search, h5.emoji-search-title').remove(); + if (term.length > 0) { + // Generate a search result block + const h5 = $('<h5 class="emoji-search-title"/>').text('Search results'); + const foundEmojis = this.findMatchingEmojiElements(term).show(); + const ul = $('<ul>').addClass('emoji-menu-list emoji-menu-search').append(foundEmojis); + $('.emoji-menu-content ul, .emoji-menu-content h5').hide(); + $('.emoji-menu-content').append(h5).append(ul); + } else { + $('.emoji-menu-content').children().show(); + } +}; + +AwardsHandler.prototype.findMatchingEmojiElements = function findMatchingEmojiElements(term) { const safeTerm = term.toLowerCase(); const namesMatchingAlias = []; diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js index f7f41d55b52..3bea460dcc6 100644 --- a/app/assets/javascripts/behaviors/autosize.js +++ b/app/assets/javascripts/behaviors/autosize.js @@ -1,28 +1,23 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, consistent-return, max-len */ -/* global autosize */ +import autosize from 'vendor/autosize'; -var autosize = require('vendor/autosize'); +$(() => { + const $fields = $('.js-autosize'); -(function() { - $(function() { - var $fields; - $fields = $('.js-autosize'); - $fields.on('autosize:resized', function() { - var $field; - $field = $(this); - return $field.data('height', $field.outerHeight()); - }); - $fields.on('resize.autosize', function() { - var $field; - $field = $(this); - if ($field.data('height') !== $field.outerHeight()) { - $field.data('height', $field.outerHeight()); - autosize.destroy($field); - return $field.css('max-height', window.outerHeight); - } - }); - autosize($fields); - autosize.update($fields); - return $fields.css('resize', 'vertical'); + $fields.on('autosize:resized', function resized() { + const $field = $(this); + $field.data('height', $field.outerHeight()); }); -}).call(window); + + $fields.on('resize.autosize', function resize() { + const $field = $(this); + if ($field.data('height') !== $field.outerHeight()) { + $field.data('height', $field.outerHeight()); + autosize.destroy($field); + $field.css('max-height', window.outerHeight); + } + }); + + autosize($fields); + autosize.update($fields); + $fields.css('resize', 'vertical'); +}); diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js index fd0840fa117..7c9dbcc8d6e 100644 --- a/app/assets/javascripts/behaviors/details_behavior.js +++ b/app/assets/javascripts/behaviors/details_behavior.js @@ -1,26 +1,23 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, max-len */ -(function() { - $(function() { - $("body").on("click", ".js-details-target", function() { - var container; - container = $(this).closest(".js-details-container"); - return container.toggleClass("open"); - }); - // Show details content. Hides link after click. - // - // %div - // %a.js-details-expand - // %div.js-details-content - // - return $("body").on("click", ".js-details-expand", function(e) { - $(this).next('.js-details-content').removeClass("hide"); - $(this).hide(); - var truncatedItem = $(this).siblings('.js-details-short'); - if (truncatedItem.length) { - truncatedItem.addClass("hide"); - } - return e.preventDefault(); - }); +$(() => { + $('body').on('click', '.js-details-target', function target() { + $(this).closest('.js-details-container').toggleClass('open'); }); -}).call(window); + + // Show details content. Hides link after click. + // + // %div + // %a.js-details-expand + // %div.js-details-content + // + $('body').on('click', '.js-details-expand', function expand(e) { + e.preventDefault(); + $(this).next('.js-details-content').removeClass('hide'); + $(this).hide(); + + const truncatedItem = $(this).siblings('.js-details-short'); + if (truncatedItem.length) { + truncatedItem.addClass('hide'); + } + }); +}); diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 19a607309e4..23d91fdb259 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -62,6 +62,7 @@ function glEmojiTag(inputName, options) { data-fallback-src="${fallbackImageSrc}" ${fallbackSpriteAttribute} data-unicode-version="${emojiInfo.unicodeVersion}" + title="${emojiInfo.description}" > ${contents} </gl-emoji> diff --git a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js index aa522e20c36..257df55e54f 100644 --- a/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js +++ b/app/assets/javascripts/behaviors/gl_emoji/unicode_support_map.js @@ -1,3 +1,5 @@ +import AccessorUtilities from '../../lib/utils/accessor'; + const unicodeSupportTestMap = { // man, student (emojione does not have any of these yet), http://emojipedia.org/emoji-zwj-sequences/ // occupationZwj: '\u{1F468}\u{200D}\u{1F393}', @@ -140,16 +142,25 @@ function generateUnicodeSupportMap(testMap) { function getUnicodeSupportMap() { let unicodeSupportMap; - const userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent'); + let userAgentFromCache; + + const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + + if (isLocalStorageAvailable) userAgentFromCache = window.localStorage.getItem('gl-emoji-user-agent'); + try { unicodeSupportMap = JSON.parse(window.localStorage.getItem('gl-emoji-unicode-support-map')); } catch (err) { // swallow } + if (!unicodeSupportMap || userAgentFromCache !== navigator.userAgent) { unicodeSupportMap = generateUnicodeSupportMap(unicodeSupportTestMap); - window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); - window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + + if (isLocalStorageAvailable) { + window.localStorage.setItem('gl-emoji-user-agent', navigator.userAgent); + window.localStorage.setItem('gl-emoji-unicode-support-map', JSON.stringify(unicodeSupportMap)); + } } return unicodeSupportMap; diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js new file mode 100644 index 00000000000..5b931e6cfa6 --- /dev/null +++ b/app/assets/javascripts/behaviors/index.js @@ -0,0 +1,9 @@ +import './autosize'; +import './bind_in_out'; +import './details_behavior'; +import { installGlEmojiElement } from './gl_emoji'; +import './quick_submit'; +import './requires_input'; +import './toggler_behavior'; + +installGlEmojiElement(); diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 626f3503c91..1f9e0448084 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-arrow-callback, camelcase, consistent-return, quotes, object-shorthand, comma-dangle, max-len */ +import '../commons/bootstrap'; // Quick Submit behavior // @@ -6,9 +6,6 @@ // "Meta+Enter" (Mac) or "Ctrl+Enter" (Linux/Windows) key combination, the form // is submitted. // -import '../commons/bootstrap'; - -// // ### Example Markup // // <form action="/foo" class="js-quick-submit"> @@ -17,61 +14,59 @@ import '../commons/bootstrap'; // <input type="submit" value="Submit" /> // </form> // -(function() { - var isMac, keyCodeIs; - isMac = function() { - return navigator.userAgent.match(/Macintosh/); - }; +function isMac() { + return navigator.userAgent.match(/Macintosh/); +} - keyCodeIs = function(e, keyCode) { - if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) { - return false; - } - return e.keyCode === keyCode; - }; +function keyCodeIs(e, keyCode) { + if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) { + return false; + } + return e.keyCode === keyCode; +} - $(document).on('keydown.quick_submit', '.js-quick-submit', function(e) { - var $form, $submit_button; - // Enter - if (!keyCodeIs(e, 13)) { - return; - } - if (!((e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey) || (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey))) { - return; - } - e.preventDefault(); - $form = $(e.target).closest('form'); - $submit_button = $form.find('input[type=submit], button[type=submit]'); - if ($submit_button.attr('disabled')) { - return; - } - $submit_button.disable(); - return $form.submit(); - }); +$(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { + // Enter + if (!keyCodeIs(e, 13)) { + return; + } + + const onlyMeta = e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey; + const onlyCtrl = e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey; + if (!onlyMeta && !onlyCtrl) { + return; + } + + e.preventDefault(); + const $form = $(e.target).closest('form'); + const $submitButton = $form.find('input[type=submit], button[type=submit]'); + + if (!$submitButton.attr('disabled')) { + $submitButton.trigger('click', [e]); + $submitButton.disable(); + } +}); + +// If the user tabs to a submit button on a `js-quick-submit` form, display a +// tooltip to let them know they could've used the hotkey +$(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function displayTooltip(e) { + // Tab + if (!keyCodeIs(e, 9)) { + return; + } + + const $this = $(this); + const title = isMac() ? + 'You can also press ⌘-Enter' : + 'You can also press Ctrl-Enter'; - // If the user tabs to a submit button on a `js-quick-submit` form, display a - // tooltip to let them know they could've used the hotkey - $(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function(e) { - var $this, title; - // Tab - if (!keyCodeIs(e, 9)) { - return; - } - if (isMac()) { - title = "You can also press ⌘-Enter"; - } else { - title = "You can also press Ctrl-Enter"; - } - $this = $(this); - return $this.tooltip({ - container: 'body', - html: 'true', - placement: 'auto top', - title: title, - trigger: 'manual' - }).tooltip('show').one('blur', function() { - return $this.tooltip('hide'); - }); + $this.tooltip({ + container: 'body', + html: 'true', + placement: 'auto top', + title, + trigger: 'manual', }); -}).call(window); + $this.tooltip('show').one('blur', () => $this.tooltip('hide')); +}); diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index eb7143f5b1a..b20d108aa25 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -1,12 +1,10 @@ -/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, no-else-return, consistent-return, max-len */ +import '../commons/bootstrap'; + // Requires Input behavior // // When called on a form with input fields with the `required` attribute, the // form's submit button will be disabled until all required fields have values. // -import '../commons/bootstrap'; - -// // ### Example Markup // // <form class="js-requires-input"> @@ -14,49 +12,43 @@ import '../commons/bootstrap'; // <input type="submit" value="Submit"> // </form> // -(function() { - $.fn.requiresInput = function() { - var $button, $form, fieldSelector, requireInput, required; - $form = $(this); - $button = $('button[type=submit], input[type=submit]', $form); - required = '[required=required]'; - fieldSelector = "input" + required + ", select" + required + ", textarea" + required; - requireInput = function() { - var values; - values = _.map($(fieldSelector, $form), function(field) { - // Collect the input values of *all* required fields - return field.value; - }); - // Disable the button if any required fields are empty - if (values.length && _.any(values, _.isEmpty)) { - return $button.disable(); - } else { - return $button.enable(); - } - }; - // Set initial button state - requireInput(); - return $form.on('change input', fieldSelector, requireInput); - }; - $(function() { - var $form, hideOrShowHelpBlock; - $form = $('form.js-requires-input'); - $form.requiresInput(); - // Hide or Show the help block when creating a new project - // based on the option selected - hideOrShowHelpBlock = function(form) { - var selected; - selected = $('.js-select-namespace option:selected'); - if (selected.length && selected.data('options-parent') === 'groups') { - return form.find('.help-block').hide(); - } else if (selected.length) { - return form.find('.help-block').show(); - } - }; - hideOrShowHelpBlock($form); - return $('.select2.js-select-namespace').change(function() { - return hideOrShowHelpBlock($form); - }); - }); -}).call(window); +$.fn.requiresInput = function requiresInput() { + const $form = $(this); + const $button = $('button[type=submit], input[type=submit]', $form); + const fieldSelector = 'input[required=required], select[required=required], textarea[required=required]'; + + function requireInput() { + // Collect the input values of *all* required fields + const values = _.map($(fieldSelector, $form), field => field.value); + + // Disable the button if any required fields are empty + if (values.length && _.any(values, _.isEmpty)) { + $button.disable(); + } else { + $button.enable(); + } + } + + // Set initial button state + requireInput(); + $form.on('change input', fieldSelector, requireInput); +}; + +// Hide or Show the help block when creating a new project +// based on the option selected +function hideOrShowHelpBlock(form) { + const selected = $('.js-select-namespace option:selected'); + if (selected.length && selected.data('options-parent') === 'groups') { + form.find('.help-block').hide(); + } else if (selected.length) { + form.find('.help-block').show(); + } +} + +$(() => { + const $form = $('form.js-requires-input'); + $form.requiresInput(); + hideOrShowHelpBlock($form); + $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form)); +}); diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index 576b8a0425f..77e92ff8caf 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -1,44 +1,44 @@ -/* eslint-disable wrap-iife, func-names, space-before-function-paren, prefer-arrow-callback, vars-on-top, no-var, max-len */ -(function(w) { - $(function() { - var toggleContainer = function(container, /* optional */toggleState) { - var $container = $(container); - - $container - .find('.js-toggle-button .fa') - .toggleClass('fa-chevron-up', toggleState) - .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined); - - $container - .find('.js-toggle-content') - .toggle(toggleState); - }; - - // Toggle button. Show/hide content inside parent container. - // Button does not change visibility. If button has icon - it changes chevron style. - // - // %div.js-toggle-container - // %button.js-toggle-button - // %div.js-toggle-content - // - $('body').on('click', '.js-toggle-button', function(e) { - toggleContainer($(this).closest('.js-toggle-container')); - - const targetTag = e.currentTarget.tagName.toLowerCase(); - if (targetTag === 'a' || targetTag === 'button') { - e.preventDefault(); - } - }); - - // If we're accessing a permalink, ensure it is not inside a - // closed js-toggle-container! - var hash = w.gl.utils.getLocationHash(); - var anchor = hash && document.getElementById(hash); - var container = anchor && $(anchor).closest('.js-toggle-container'); - - if (container) { - toggleContainer(container, true); - anchor.scrollIntoView(); + +// Toggle button. Show/hide content inside parent container. +// Button does not change visibility. If button has icon - it changes chevron style. +// +// %div.js-toggle-container +// %button.js-toggle-button +// %div.js-toggle-content +// + +$(() => { + function toggleContainer(container, toggleState) { + const $container = $(container); + + $container + .find('.js-toggle-button .fa') + .toggleClass('fa-chevron-up', toggleState) + .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined); + + $container + .find('.js-toggle-content') + .toggle(toggleState); + } + + $('body').on('click', '.js-toggle-button', function toggleButton(e) { + e.target.classList.toggle('open'); + toggleContainer($(this).closest('.js-toggle-container')); + + const targetTag = e.currentTarget.tagName.toLowerCase(); + if (targetTag === 'a' || targetTag === 'button') { + e.preventDefault(); } }); -})(window); + + // If we're accessing a permalink, ensure it is not inside a + // closed js-toggle-container! + const hash = window.gl.utils.getLocationHash(); + const anchor = hash && document.getElementById(hash); + const container = anchor && $(anchor).closest('.js-toggle-container'); + + if (container) { + toggleContainer(container, true); + anchor.scrollIntoView(); + } +}); diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js new file mode 100644 index 00000000000..68d4ddad551 --- /dev/null +++ b/app/assets/javascripts/blob/3d_viewer/index.js @@ -0,0 +1,147 @@ +import * as THREE from 'three/build/three.module'; +import STLLoaderClass from 'three-stl-loader'; +import OrbitControlsClass from 'three-orbit-controls'; +import MeshObject from './mesh_object'; + +const STLLoader = STLLoaderClass(THREE); +const OrbitControls = OrbitControlsClass(THREE); + +export default class Renderer { + constructor(container) { + this.renderWrapper = this.render.bind(this); + this.objects = []; + + this.container = container; + this.width = this.container.offsetWidth; + this.height = 500; + + this.loader = new STLLoader(); + + this.fov = 45; + this.camera = new THREE.PerspectiveCamera( + this.fov, + this.width / this.height, + 1, + 1000, + ); + + this.scene = new THREE.Scene(); + + this.scene.add(this.camera); + + // Setup the viewer + this.setupRenderer(); + this.setupGrid(); + this.setupLight(); + + // Setup OrbitControls + this.controls = new OrbitControls( + this.camera, + this.renderer.domElement, + ); + this.controls.minDistance = 5; + this.controls.maxDistance = 30; + this.controls.enableKeys = false; + + this.loadFile(); + } + + setupRenderer() { + this.renderer = new THREE.WebGLRenderer({ + antialias: true, + }); + + this.renderer.setClearColor(0xFFFFFF); + this.renderer.setPixelRatio(window.devicePixelRatio); + this.renderer.setSize( + this.width, + this.height, + ); + } + + setupLight() { + // Point light illuminates the object + const pointLight = new THREE.PointLight( + 0xFFFFFF, + 2, + 0, + ); + + pointLight.castShadow = true; + + this.camera.add(pointLight); + + // Ambient light illuminates the scene + const ambientLight = new THREE.AmbientLight( + 0xFFFFFF, + 1, + ); + this.scene.add(ambientLight); + } + + setupGrid() { + this.grid = new THREE.GridHelper( + 20, + 20, + 0x000000, + 0x000000, + ); + + this.scene.add(this.grid); + } + + loadFile() { + this.loader.load(this.container.dataset.endpoint, (geo) => { + const obj = new MeshObject(geo); + + this.objects.push(obj); + this.scene.add(obj); + + this.start(); + this.setDefaultCameraPosition(); + }); + } + + start() { + // Empty the container first + this.container.innerHTML = ''; + + // Add to DOM + this.container.appendChild(this.renderer.domElement); + + // Make controls visible + this.container.parentNode.classList.remove('is-stl-loading'); + + this.render(); + } + + render() { + this.renderer.render( + this.scene, + this.camera, + ); + + requestAnimationFrame(this.renderWrapper); + } + + changeObjectMaterials(type) { + this.objects.forEach((obj) => { + obj.changeMaterial(type); + }); + } + + setDefaultCameraPosition() { + const obj = this.objects[0]; + const radius = (obj.geometry.boundingSphere.radius / 1.5); + const dist = radius / (Math.sin((this.fov * (Math.PI / 180)) / 2)); + + this.camera.position.set( + 0, + dist + 1, + dist, + ); + + this.camera.lookAt(this.grid); + this.controls.update(); + } +} diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js new file mode 100644 index 00000000000..96758884abf --- /dev/null +++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js @@ -0,0 +1,49 @@ +import { + Matrix4, + MeshLambertMaterial, + Mesh, +} from 'three/build/three.module'; + +const defaultColor = 0xE24329; +const materials = { + default: new MeshLambertMaterial({ + color: defaultColor, + }), + wireframe: new MeshLambertMaterial({ + color: defaultColor, + wireframe: true, + }), +}; + +export default class MeshObject extends Mesh { + constructor(geo) { + super( + geo, + materials.default, + ); + + this.geometry.computeBoundingSphere(); + + this.rotation.set(-Math.PI / 2, 0, 0); + + if (this.geometry.boundingSphere.radius > 4) { + const scale = 4 / this.geometry.boundingSphere.radius; + + this.geometry.applyMatrix( + new Matrix4().makeScale( + scale, + scale, + scale, + ), + ); + this.geometry.computeBoundingSphere(); + + this.position.x = -this.geometry.boundingSphere.center.x; + this.position.z = this.geometry.boundingSphere.center.y; + } + } + + changeMaterial(type) { + this.material = materials[type]; + } +} diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js new file mode 100644 index 00000000000..c17877a276d --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js @@ -0,0 +1,114 @@ +import sqljs from 'sql.js'; +import { template as _template } from 'underscore'; + +const PREVIEW_TEMPLATE = _template(` + <div class="panel panel-default"> + <div class="panel-heading"><%- name %></div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,<%- image %>"/> + </div> + </div> +`); + +class BalsamiqViewer { + constructor(viewer) { + this.viewer = viewer; + } + + loadFile(endpoint) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open('GET', endpoint, true); + xhr.responseType = 'arraybuffer'; + xhr.onload = loadEvent => this.fileLoaded(loadEvent, resolve, reject); + xhr.onerror = reject; + + xhr.send(); + }); + } + + fileLoaded(loadEvent, resolve, reject) { + if (loadEvent.target.status !== 200) return reject(); + + this.renderFile(loadEvent); + + return resolve(); + } + + renderFile(loadEvent) { + const container = document.createElement('ul'); + + this.initDatabase(loadEvent.target.response); + + const previews = this.getPreviews(); + previews.forEach((preview) => { + const renderedPreview = this.renderPreview(preview); + + container.appendChild(renderedPreview); + }); + + container.classList.add('list-inline'); + container.classList.add('previews'); + + this.viewer.appendChild(container); + } + + initDatabase(data) { + const previewBinary = new Uint8Array(data); + + this.database = new sqljs.Database(previewBinary); + } + + getPreviews() { + const thumbnails = this.database.exec('SELECT * FROM thumbnails'); + + return thumbnails[0].values.map(BalsamiqViewer.parsePreview); + } + + getResource(resourceID) { + const resources = this.database.exec(`SELECT * FROM resources WHERE id = '${resourceID}'`); + + return resources[0]; + } + + renderPreview(preview) { + const previewElement = document.createElement('li'); + + previewElement.classList.add('preview'); + previewElement.innerHTML = this.renderTemplate(preview); + + return previewElement; + } + + renderTemplate(preview) { + const resource = this.getResource(preview.resourceID); + const name = BalsamiqViewer.parseTitle(resource); + const image = preview.image; + + const template = PREVIEW_TEMPLATE({ + name, + image, + }); + + return template; + } + + static parsePreview(preview) { + return JSON.parse(preview[1]); + } + + /* + * resource = { + * columns: ['ID', 'BRANCHID', 'ATTRIBUTES', 'DATA'], + * values: [['id', 'branchId', 'attributes', 'data']], + * } + * + * 'attributes' being a JSON string containing the `name` property. + */ + static parseTitle(resource) { + return JSON.parse(resource.values[0][2]).name; + } +} + +export default BalsamiqViewer; diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js new file mode 100644 index 00000000000..8641a6fdae6 --- /dev/null +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -0,0 +1,22 @@ +/* global Flash */ + +import BalsamiqViewer from './balsamiq/balsamiq_viewer'; + +function onError() { + const flash = new window.Flash('Balsamiq file could not be loaded.'); + + return flash; +} + +function loadBalsamiqFile() { + const viewer = document.getElementById('js-balsamiq-viewer'); + + if (!(viewer instanceof Element)) return; + + const endpoint = viewer.dataset.endpoint; + + const balsamiqViewer = new BalsamiqViewer(viewer); + balsamiqViewer.loadFile(endpoint).catch(onError); +} + +$(loadBalsamiqFile); diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index c9fe23aec75..4568b86f298 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -35,7 +35,7 @@ export default class BlobFileDropzone { this.removeFile(file); }); this.on('sending', function (file, xhr, formData) { - formData.append('target_branch', form.find('input[name="target_branch"]').val()); + formData.append('branch_name', form.find('input[name="branch_name"]').val()); formData.append('create_merge_request', form.find('.js-create-merge-request').val()); formData.append('commit_message', form.find('.js-commit-message').val()); }); diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js new file mode 100644 index 00000000000..47c431fb809 --- /dev/null +++ b/app/assets/javascripts/blob/blob_fork_suggestion.js @@ -0,0 +1,60 @@ +const defaults = { + // Buttons that will show the `suggestionSections` + // has `data-fork-path`, and `data-action` + openButtons: [], + // Update the href(from `openButton` -> `data-fork-path`) + // whenever a `openButton` is clicked + forkButtons: [], + // Buttons to hide the `suggestionSections` + cancelButtons: [], + // Section to show/hide + suggestionSections: [], + // Pieces of text that need updating depending on the action, `edit`, `replace`, `delete` + actionTextPieces: [], +}; + +class BlobForkSuggestion { + constructor(options) { + this.elementMap = Object.assign({}, defaults, options); + this.onOpenButtonClick = this.onOpenButtonClick.bind(this); + this.onCancelButtonClick = this.onCancelButtonClick.bind(this); + } + + init() { + this.bindEvents(); + + return this; + } + + bindEvents() { + $(this.elementMap.openButtons).on('click', this.onOpenButtonClick); + $(this.elementMap.cancelButtons).on('click', this.onCancelButtonClick); + } + + showSuggestionSection(forkPath, action = 'edit') { + $(this.elementMap.suggestionSections).removeClass('hidden'); + $(this.elementMap.forkButtons).attr('href', forkPath); + $(this.elementMap.actionTextPieces).text(action); + } + + hideSuggestionSection() { + $(this.elementMap.suggestionSections).addClass('hidden'); + } + + onOpenButtonClick(e) { + const forkPath = $(e.currentTarget).attr('data-fork-path'); + const action = $(e.currentTarget).attr('data-action'); + this.showSuggestionSection(forkPath, action); + } + + onCancelButtonClick() { + this.hideSuggestionSection(); + } + + destroy() { + $(this.elementMap.openButtons).off('click', this.onOpenButtonClick); + $(this.elementMap.cancelButtons).off('click', this.onCancelButtonClick); + } +} + +export default BlobForkSuggestion; diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js new file mode 100644 index 00000000000..a20c6ca7a21 --- /dev/null +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -0,0 +1,245 @@ +/* eslint-disable class-methods-use-this */ +/* global Flash */ + +import FileTemplateTypeSelector from './template_selectors/type_selector'; +import BlobCiYamlSelector from './template_selectors/ci_yaml_selector'; +import DockerfileSelector from './template_selectors/dockerfile_selector'; +import GitignoreSelector from './template_selectors/gitignore_selector'; +import LicenseSelector from './template_selectors/license_selector'; + +export default class FileTemplateMediator { + constructor({ editor, currentAction }) { + this.editor = editor; + this.currentAction = currentAction; + + this.initTemplateSelectors(); + this.initTemplateTypeSelector(); + this.initDomElements(); + this.initDropdowns(); + this.initPageEvents(); + } + + initTemplateSelectors() { + // Order dictates template type dropdown item order + this.templateSelectors = [ + GitignoreSelector, + BlobCiYamlSelector, + DockerfileSelector, + LicenseSelector, + ].map(TemplateSelectorClass => new TemplateSelectorClass({ mediator: this })); + } + + initTemplateTypeSelector() { + this.typeSelector = new FileTemplateTypeSelector({ + mediator: this, + dropdownData: this.templateSelectors + .map((templateSelector) => { + const cfg = templateSelector.config; + + return { + name: cfg.name, + key: cfg.key, + }; + }), + }); + } + + initDomElements() { + const $templatesMenu = $('.template-selectors-menu'); + const $undoMenu = $templatesMenu.find('.template-selectors-undo-menu'); + const $fileEditor = $('.file-editor'); + + this.$templatesMenu = $templatesMenu; + this.$undoMenu = $undoMenu; + this.$undoBtn = $undoMenu.find('button'); + this.$templateSelectors = $templatesMenu.find('.template-selector-dropdowns-wrap'); + this.$filenameInput = $fileEditor.find('.js-file-path-name-input'); + this.$fileContent = $fileEditor.find('#file-content'); + this.$commitForm = $fileEditor.find('form'); + this.$navLinks = $fileEditor.find('.nav-links'); + } + + initDropdowns() { + if (this.currentAction === 'create') { + this.typeSelector.show(); + } else { + this.hideTemplateSelectorMenu(); + } + + this.displayMatchedTemplateSelector(); + } + + initPageEvents() { + this.listenForFilenameInput(); + this.prepFileContentForSubmit(); + this.listenForPreviewMode(); + } + + listenForFilenameInput() { + this.$filenameInput.on('keyup blur', () => { + this.displayMatchedTemplateSelector(); + }); + } + + prepFileContentForSubmit() { + this.$commitForm.submit(() => { + this.$fileContent.val(this.editor.getValue()); + }); + } + + listenForPreviewMode() { + this.$navLinks.on('click', 'a', (e) => { + const urlPieces = e.target.href.split('#'); + const hash = urlPieces[1]; + if (hash === 'preview') { + this.hideTemplateSelectorMenu(); + } else if (hash === 'editor') { + this.showTemplateSelectorMenu(); + } + }); + } + + selectTemplateType(item, e) { + if (e) { + e.preventDefault(); + } + + this.templateSelectors.forEach((selector) => { + if (selector.config.key === item.key) { + selector.show(); + } else { + selector.hide(); + } + }); + + this.typeSelector.setToggleText(item.name); + + this.cacheToggleText(); + } + + selectTemplateTypeOptions(options) { + this.selectTemplateType(options.selectedObj, options.e); + } + + selectTemplateFile(selector, query, data) { + selector.renderLoading(); + // in case undo menu is already already there + this.destroyUndoMenu(); + this.fetchFileTemplate(selector.config.endpoint, query, data) + .then((file) => { + this.showUndoMenu(); + this.setEditorContent(file); + this.setFilename(selector.config.name); + selector.renderLoaded(); + }) + .catch(err => new Flash(`An error occurred while fetching the template: ${err}`)); + } + + displayMatchedTemplateSelector() { + const currentInput = this.getFilename(); + this.templateSelectors.forEach((selector) => { + const match = selector.config.pattern.test(currentInput); + + if (match) { + this.typeSelector.show(); + this.selectTemplateType(selector.config); + this.showTemplateSelectorMenu(); + } + }); + } + + fetchFileTemplate(apiCall, query, data) { + return new Promise((resolve) => { + const resolveFile = file => resolve(file); + + if (!data) { + apiCall(query, resolveFile); + } else { + apiCall(query, data, resolveFile); + } + }); + } + + setEditorContent(file) { + if (!file && file !== '') return; + + const newValue = file.content || file; + + this.editor.setValue(newValue, 1); + + this.editor.focus(); + + this.editor.navigateFileStart(); + } + + findTemplateSelectorByKey(key) { + return this.templateSelectors.find(selector => selector.config.key === key); + } + + showUndoMenu() { + this.$undoMenu.removeClass('hidden'); + + this.$undoBtn.on('click', () => { + this.restoreFromCache(); + this.destroyUndoMenu(); + }); + } + + destroyUndoMenu() { + this.cacheFileContents(); + this.cacheToggleText(); + this.$undoMenu.addClass('hidden'); + this.$undoBtn.off('click'); + } + + hideTemplateSelectorMenu() { + this.$templatesMenu.hide(); + } + + showTemplateSelectorMenu() { + this.$templatesMenu.show(); + } + + cacheToggleText() { + this.cachedToggleText = this.getTemplateSelectorToggleText(); + } + + cacheFileContents() { + this.cachedContent = this.editor.getValue(); + this.cachedFilename = this.getFilename(); + } + + restoreFromCache() { + this.setEditorContent(this.cachedContent); + this.setFilename(this.cachedFilename); + this.setTemplateSelectorToggleText(); + } + + getTemplateSelectorToggleText() { + return this.$templateSelectors + .find('.js-template-selector-wrap:visible .dropdown-toggle-text') + .text(); + } + + setTemplateSelectorToggleText() { + return this.$templateSelectors + .find('.js-template-selector-wrap:visible .dropdown-toggle-text') + .text(this.cachedToggleText); + } + + getTypeSelectorToggleText() { + return this.typeSelector.getToggleText(); + } + + getFilename() { + return this.$filenameInput.val(); + } + + setFilename(name) { + this.$filenameInput.val(name); + } + + getSelected() { + return this.templateSelectors.find(selector => selector.selected); + } +} diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js new file mode 100644 index 00000000000..5ae30990aea --- /dev/null +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -0,0 +1,65 @@ +export default class FileTemplateSelector { + constructor(mediator) { + this.mediator = mediator; + this.$dropdown = null; + this.$wrapper = null; + } + + init() { + const cfg = this.config; + + this.$dropdown = $(cfg.dropdown); + this.$wrapper = $(cfg.wrapper); + this.$loadingIcon = this.$wrapper.find('.fa-chevron-down'); + this.$dropdownToggleText = this.$wrapper.find('.dropdown-toggle-text'); + + this.initDropdown(); + } + + show() { + if (this.$dropdown === null) { + this.init(); + } + + this.$wrapper.removeClass('hidden'); + } + + hide() { + if (this.$dropdown !== null) { + this.$wrapper.addClass('hidden'); + } + } + + getToggleText() { + return this.$dropdownToggleText.text(); + } + + setToggleText(text) { + this.$dropdownToggleText.text(text); + } + + renderLoading() { + this.$loadingIcon + .addClass('fa-spinner fa-spin') + .removeClass('fa-chevron-down'); + } + + renderLoaded() { + this.$loadingIcon + .addClass('fa-chevron-down') + .removeClass('fa-spinner fa-spin'); + } + + reportSelection(options) { + const { query, e, data } = options; + e.preventDefault(); + return this.mediator.selectTemplateFile(this, query, data); + } + + reportSelectionName(options) { + const opts = options; + opts.query = options.selectedObj.name; + + this.reportSelection(opts); + } +} diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js index 9b8bfbfc8c0..36fe8a7184f 100644 --- a/app/assets/javascripts/blob/notebook/index.js +++ b/app/assets/javascripts/blob/notebook/index.js @@ -1,10 +1,9 @@ /* eslint-disable no-new */ import Vue from 'vue'; import VueResource from 'vue-resource'; -import NotebookLab from 'vendor/notebooklab'; +import notebookLab from '../../notebook/index.vue'; Vue.use(VueResource); -Vue.use(NotebookLab); export default () => { const el = document.getElementById('js-notebook-viewer'); @@ -19,6 +18,9 @@ export default () => { json: {}, }; }, + components: { + notebookLab, + }, template: ` <div class="container-fluid md prepend-top-default append-bottom-default"> <div diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js new file mode 100644 index 00000000000..0ed915c1ac9 --- /dev/null +++ b/app/assets/javascripts/blob/pdf/index.js @@ -0,0 +1,60 @@ +/* eslint-disable no-new */ +import Vue from 'vue'; +import pdfLab from '../../pdf/index.vue'; + +export default () => { + const el = document.getElementById('js-pdf-viewer'); + + return new Vue({ + el, + data() { + return { + error: false, + loadError: false, + loading: true, + pdf: el.dataset.endpoint, + }; + }, + components: { + pdfLab, + }, + methods: { + onLoad() { + this.loading = false; + }, + onError(error) { + this.loading = false; + this.loadError = true; + this.error = error; + }, + }, + template: ` + <div class="js-pdf-viewer container-fluid md prepend-top-default append-bottom-default"> + <div + class="text-center loading" + v-if="loading && !error"> + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" + aria-label="PDF loading"> + </i> + </div> + <pdf-lab + v-if="!loadError" + :pdf="pdf" + @pdflabload="onLoad" + @pdflaberror="onError" /> + <p + class="text-center" + v-if="error"> + <span v-if="loadError"> + An error occured whilst loading the file. Please try again later. + </span> + <span v-else> + An error occured whilst decoding the file. + </span> + </p> + </div> + `, + }); +}; diff --git a/app/assets/javascripts/blob/pdf_viewer.js b/app/assets/javascripts/blob/pdf_viewer.js new file mode 100644 index 00000000000..91abe9dd699 --- /dev/null +++ b/app/assets/javascripts/blob/pdf_viewer.js @@ -0,0 +1,3 @@ +import renderPDF from './pdf'; + +document.addEventListener('DOMContentLoaded', renderPDF); diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js new file mode 100644 index 00000000000..0799991aa40 --- /dev/null +++ b/app/assets/javascripts/blob/sketch/index.js @@ -0,0 +1,73 @@ +import JSZip from 'jszip'; +import JSZipUtils from 'jszip-utils'; + +export default class SketchLoader { + constructor(container) { + this.container = container; + this.loadingIcon = this.container.querySelector('.js-loading-icon'); + + this.load(); + } + + load() { + return this.getZipFile() + .then(data => JSZip.loadAsync(data)) + .then(asyncResult => asyncResult.files['previews/preview.png'].async('uint8array')) + .then((content) => { + const url = window.URL || window.webkitURL; + const blob = new Blob([new Uint8Array(content)], { + type: 'image/png', + }); + const previewUrl = url.createObjectURL(blob); + + this.render(previewUrl); + }) + .catch(this.error.bind(this)); + } + + getZipFile() { + return new JSZip.external.Promise((resolve, reject) => { + JSZipUtils.getBinaryContent(this.container.dataset.endpoint, (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + } + + render(previewUrl) { + const previewLink = document.createElement('a'); + const previewImage = document.createElement('img'); + + previewLink.href = previewUrl; + previewLink.target = '_blank'; + previewImage.src = previewUrl; + previewImage.className = 'img-responsive'; + + previewLink.appendChild(previewImage); + this.container.appendChild(previewLink); + + this.removeLoadingIcon(); + } + + error() { + const errorMsg = document.createElement('p'); + + errorMsg.className = 'prepend-top-default append-bottom-default text-center'; + errorMsg.textContent = ` + Cannot show preview. For previews on sketch files, they must have the file format + introduced by Sketch version 43 and above. + `; + this.container.appendChild(errorMsg); + + this.removeLoadingIcon(); + } + + removeLoadingIcon() { + if (this.loadingIcon) { + this.loadingIcon.remove(); + } + } +} diff --git a/app/assets/javascripts/blob/sketch_viewer.js b/app/assets/javascripts/blob/sketch_viewer.js new file mode 100644 index 00000000000..0640dd26855 --- /dev/null +++ b/app/assets/javascripts/blob/sketch_viewer.js @@ -0,0 +1,8 @@ +/* eslint-disable no-new */ +import SketchLoader from './sketch'; + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-sketch-viewer'); + + new SketchLoader(el); +}); diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js new file mode 100644 index 00000000000..f611c4fe640 --- /dev/null +++ b/app/assets/javascripts/blob/stl_viewer.js @@ -0,0 +1,19 @@ +import Renderer from './3d_viewer'; + +document.addEventListener('DOMContentLoaded', () => { + const viewer = new Renderer(document.getElementById('js-stl-viewer')); + + [].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => { + el.addEventListener('click', (e) => { + const target = e.target; + + e.preventDefault(); + + document.querySelector('.js-material-changer.active').classList.remove('active'); + target.classList.add('active'); + target.blur(); + + viewer.changeObjectMaterials(target.dataset.type); + }); + }); +}); diff --git a/app/assets/javascripts/blob/target_branch_dropdown.js b/app/assets/javascripts/blob/target_branch_dropdown.js index 216f069ef71..d52d69b1274 100644 --- a/app/assets/javascripts/blob/target_branch_dropdown.js +++ b/app/assets/javascripts/blob/target_branch_dropdown.js @@ -37,8 +37,8 @@ class TargetBranchDropDown { } return SELECT_ITEM_MSG; }, - clicked(item, el, e) { - e.preventDefault(); + clicked(options) { + options.e.preventDefault(); self.onClick.call(self); }, fieldName: self.fieldName, diff --git a/app/assets/javascripts/blob/template_selectors/template_selector.js b/app/assets/javascripts/blob/template_selector.js index d7c1c32efbd..888883163c5 100644 --- a/app/assets/javascripts/blob/template_selectors/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -24,7 +24,7 @@ export default class TemplateSelector { search: { fields: ['name'], }, - clicked: (item, el, e) => this.fetchFileTemplate(item, el, e), + clicked: options => this.fetchFileTemplate(options), text: item => item.name, }); } @@ -51,7 +51,10 @@ export default class TemplateSelector { return this.$dropdownContainer.removeClass('hidden'); } - fetchFileTemplate(item, el, e) { + fetchFileTemplate(options) { + const { e } = options; + const item = options.selectedObj; + e.preventDefault(); return this.requestFile(item); } diff --git a/app/assets/javascripts/blob/template_selectors/blob_ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/blob_ci_yaml_selector.js deleted file mode 100644 index 5a5954e7751..00000000000 --- a/app/assets/javascripts/blob/template_selectors/blob_ci_yaml_selector.js +++ /dev/null @@ -1,9 +0,0 @@ -/* global Api */ - -import TemplateSelector from './template_selector'; - -export default class BlobCiYamlSelector extends TemplateSelector { - requestFile(query) { - return Api.gitlabCiYml(query.name, (file, config) => this.setEditorContent(file, config)); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/blob_ci_yaml_selectors.js b/app/assets/javascripts/blob/template_selectors/blob_ci_yaml_selectors.js deleted file mode 100644 index 7a4d6a42a03..00000000000 --- a/app/assets/javascripts/blob/template_selectors/blob_ci_yaml_selectors.js +++ /dev/null @@ -1,23 +0,0 @@ -/* global Api */ - -import BlobCiYamlSelector from './blob_ci_yaml_selector'; - -export default class BlobCiYamlSelectors { - constructor({ editor, $dropdowns }) { - this.$dropdowns = $dropdowns || $('.js-gitlab-ci-yml-selector'); - this.initSelectors(editor); - } - - initSelectors(editor) { - this.$dropdowns.each((i, dropdown) => { - const $dropdown = $(dropdown); - return new BlobCiYamlSelector({ - editor, - pattern: /(.gitlab-ci.yml)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-gitlab-ci-yml-selector-wrap'), - dropdown: $dropdown, - }); - }); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/blob_dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/blob_dockerfile_selector.js deleted file mode 100644 index 19f8820a0cb..00000000000 --- a/app/assets/javascripts/blob/template_selectors/blob_dockerfile_selector.js +++ /dev/null @@ -1,9 +0,0 @@ -/* global Api */ - -import TemplateSelector from './template_selector'; - -export default class BlobDockerfileSelector extends TemplateSelector { - requestFile(query) { - return Api.dockerfileYml(query.name, (file, config) => this.setEditorContent(file, config)); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/blob_dockerfile_selectors.js b/app/assets/javascripts/blob/template_selectors/blob_dockerfile_selectors.js deleted file mode 100644 index da067035b43..00000000000 --- a/app/assets/javascripts/blob/template_selectors/blob_dockerfile_selectors.js +++ /dev/null @@ -1,23 +0,0 @@ -import BlobDockerfileSelector from './blob_dockerfile_selector'; - -export default class BlobDockerfileSelectors { - constructor({ editor, $dropdowns }) { - this.editor = editor; - this.$dropdowns = $dropdowns || $('.js-dockerfile-selector'); - this.initSelectors(); - } - - initSelectors() { - const editor = this.editor; - this.$dropdowns.each((i, dropdown) => { - const $dropdown = $(dropdown); - return new BlobDockerfileSelector({ - editor, - pattern: /(Dockerfile)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-dockerfile-selector-wrap'), - dropdown: $dropdown, - }); - }); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/blob_gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/blob_gitignore_selector.js deleted file mode 100644 index 0b6b02fc2b3..00000000000 --- a/app/assets/javascripts/blob/template_selectors/blob_gitignore_selector.js +++ /dev/null @@ -1,9 +0,0 @@ -/* global Api */ - -import TemplateSelector from './template_selector'; - -export default class BlobGitignoreSelector extends TemplateSelector { - requestFile(query) { - return Api.gitignoreText(query.name, (file, config) => this.setEditorContent(file, config)); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/blob_gitignore_selectors.js b/app/assets/javascripts/blob/template_selectors/blob_gitignore_selectors.js deleted file mode 100644 index dc485d97677..00000000000 --- a/app/assets/javascripts/blob/template_selectors/blob_gitignore_selectors.js +++ /dev/null @@ -1,23 +0,0 @@ -import BlobGitignoreSelector from './blob_gitignore_selector'; - -export default class BlobGitignoreSelectors { - constructor({ editor, $dropdowns }) { - this.$dropdowns = $dropdowns || $('.js-gitignore-selector'); - this.editor = editor; - this.initSelectors(); - } - - initSelectors() { - this.$dropdowns.each((i, dropdown) => { - const $dropdown = $(dropdown); - - return new BlobGitignoreSelector({ - pattern: /(.gitignore)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-gitignore-selector-wrap'), - dropdown: $dropdown, - editor: this.editor, - }); - }); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/blob_license_selector.js b/app/assets/javascripts/blob/template_selectors/blob_license_selector.js deleted file mode 100644 index e9cb31cc2dc..00000000000 --- a/app/assets/javascripts/blob/template_selectors/blob_license_selector.js +++ /dev/null @@ -1,13 +0,0 @@ -/* global Api */ - -import TemplateSelector from './template_selector'; - -export default class BlobLicenseSelector extends TemplateSelector { - requestFile(query) { - const data = { - project: this.dropdown.data('project'), - fullname: this.dropdown.data('fullname'), - }; - return Api.licenseText(query.id, data, (file, config) => this.setEditorContent(file, config)); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/blob_license_selectors.js b/app/assets/javascripts/blob/template_selectors/blob_license_selectors.js deleted file mode 100644 index a44f4f78b2d..00000000000 --- a/app/assets/javascripts/blob/template_selectors/blob_license_selectors.js +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable no-unused-vars, no-param-reassign */ - -import BlobLicenseSelector from './blob_license_selector'; - -export default class BlobLicenseSelectors { - constructor({ $dropdowns, editor }) { - this.$dropdowns = $dropdowns || $('.js-license-selector'); - this.initSelectors(editor); - } - - initSelectors(editor) { - this.$dropdowns.each((i, dropdown) => { - const $dropdown = $(dropdown); - - return new BlobLicenseSelector({ - editor, - pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-license-selector-wrap'), - dropdown: $dropdown, - }); - }); - } -} diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js new file mode 100644 index 00000000000..9c41e429c8d --- /dev/null +++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js @@ -0,0 +1,32 @@ +import Api from '../../api'; + +import FileTemplateSelector from '../file_template_selector'; + +export default class BlobCiYamlSelector extends FileTemplateSelector { + constructor({ mediator }) { + super(mediator); + this.config = { + key: 'gitlab-ci-yaml', + name: '.gitlab-ci.yml', + pattern: /(.gitlab-ci.yml)/, + endpoint: Api.gitlabCiYml, + dropdown: '.js-gitlab-ci-yml-selector', + wrapper: '.js-gitlab-ci-yml-selector-wrap', + }; + } + + initDropdown() { + // maybe move to super class as well + this.$dropdown.glDropdown({ + data: this.$dropdown.data('data'), + filterable: true, + selectable: true, + toggleLabel: item => item.name, + search: { + fields: ['name'], + }, + clicked: options => this.reportSelectionName(options), + text: item => item.name, + }); + } +} diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js new file mode 100644 index 00000000000..45fb614fe00 --- /dev/null +++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js @@ -0,0 +1,32 @@ +import Api from '../../api'; + +import FileTemplateSelector from '../file_template_selector'; + +export default class DockerfileSelector extends FileTemplateSelector { + constructor({ mediator }) { + super(mediator); + this.config = { + key: 'dockerfile', + name: 'Dockerfile', + pattern: /(Dockerfile)/, + endpoint: Api.dockerfileYml, + dropdown: '.js-dockerfile-selector', + wrapper: '.js-dockerfile-selector-wrap', + }; + } + + initDropdown() { + // maybe move to super class as well + this.$dropdown.glDropdown({ + data: this.$dropdown.data('data'), + filterable: true, + selectable: true, + toggleLabel: item => item.name, + search: { + fields: ['name'], + }, + clicked: options => this.reportSelectionName(options), + text: item => item.name, + }); + } +} diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js new file mode 100644 index 00000000000..a894953cc86 --- /dev/null +++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js @@ -0,0 +1,31 @@ +import Api from '../../api'; + +import FileTemplateSelector from '../file_template_selector'; + +export default class BlobGitignoreSelector extends FileTemplateSelector { + constructor({ mediator }) { + super(mediator); + this.config = { + key: 'gitignore', + name: '.gitignore', + pattern: /(.gitignore)/, + endpoint: Api.gitignoreText, + dropdown: '.js-gitignore-selector', + wrapper: '.js-gitignore-selector-wrap', + }; + } + + initDropdown() { + this.$dropdown.glDropdown({ + data: this.$dropdown.data('data'), + filterable: true, + selectable: true, + toggleLabel: item => item.name, + search: { + fields: ['name'], + }, + clicked: options => this.reportSelectionName(options), + text: item => item.name, + }); + } +} diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js new file mode 100644 index 00000000000..b7c4da0f62e --- /dev/null +++ b/app/assets/javascripts/blob/template_selectors/license_selector.js @@ -0,0 +1,47 @@ +import Api from '../../api'; + +import FileTemplateSelector from '../file_template_selector'; + +export default class BlobLicenseSelector extends FileTemplateSelector { + constructor({ mediator }) { + super(mediator); + this.config = { + key: 'license', + name: 'LICENSE', + pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, + endpoint: Api.licenseText, + dropdown: '.js-license-selector', + wrapper: '.js-license-selector-wrap', + }; + } + + initDropdown() { + this.$dropdown.glDropdown({ + data: this.$dropdown.data('data'), + filterable: true, + selectable: true, + toggleLabel: item => item.name, + search: { + fields: ['name'], + }, + clicked: (options) => { + const { e } = options; + const el = options.$el; + const query = options.selectedObj; + + const data = { + project: this.$dropdown.data('project'), + fullname: this.$dropdown.data('fullname'), + }; + + this.reportSelection({ + query: query.id, + el, + e, + data, + }); + }, + text: item => item.name, + }); + } +} diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js new file mode 100644 index 00000000000..a09381014a7 --- /dev/null +++ b/app/assets/javascripts/blob/template_selectors/type_selector.js @@ -0,0 +1,25 @@ +import FileTemplateSelector from '../file_template_selector'; + +export default class FileTemplateTypeSelector extends FileTemplateSelector { + constructor({ mediator, dropdownData }) { + super(mediator); + this.mediator = mediator; + this.config = { + dropdown: '.js-template-type-selector', + wrapper: '.js-template-type-selector-wrap', + dropdownData, + }; + } + + initDropdown() { + this.$dropdown.glDropdown({ + data: this.config.dropdownData, + filterable: false, + selectable: true, + toggleLabel: item => item.name, + clicked: options => this.mediator.selectTemplateTypeOptions(options), + text: item => item.name, + }); + } + +} diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js new file mode 100644 index 00000000000..d7c62889dde --- /dev/null +++ b/app/assets/javascripts/blob/viewer/index.js @@ -0,0 +1,149 @@ +/* global Flash */ +export default class BlobViewer { + constructor() { + BlobViewer.initAuxiliaryViewer(); + + this.initMainViewers(); + } + + static initAuxiliaryViewer() { + const auxiliaryViewer = document.querySelector('.blob-viewer[data-type="auxiliary"]'); + if (!auxiliaryViewer) return; + + BlobViewer.loadViewer(auxiliaryViewer); + } + + initMainViewers() { + this.$fileHolder = $('.file-holder'); + if (!this.$fileHolder.length) return; + + this.switcher = document.querySelector('.js-blob-viewer-switcher'); + this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn'); + this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn'); + + this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]'); + this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]'); + + this.initBindings(); + + this.switchToInitialViewer(); + } + + switchToInitialViewer() { + const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)'); + let initialViewerName = initialViewer.getAttribute('data-type'); + + if (this.switcher && location.hash.indexOf('#L') === 0) { + initialViewerName = 'simple'; + } + + this.switchToViewer(initialViewerName); + } + + initBindings() { + if (this.switcherBtns.length) { + Array.from(this.switcherBtns) + .forEach((el) => { + el.addEventListener('click', this.switchViewHandler.bind(this)); + }); + } + + if (this.copySourceBtn) { + this.copySourceBtn.addEventListener('click', () => { + if (this.copySourceBtn.classList.contains('disabled')) return this.copySourceBtn.blur(); + + return this.switchToViewer('simple'); + }); + } + } + + switchViewHandler(e) { + const target = e.currentTarget; + + e.preventDefault(); + + this.switchToViewer(target.getAttribute('data-viewer')); + } + + toggleCopyButtonState() { + if (!this.copySourceBtn) return; + + if (this.simpleViewer.getAttribute('data-loaded')) { + this.copySourceBtn.setAttribute('title', 'Copy source to clipboard'); + this.copySourceBtn.classList.remove('disabled'); + } else if (this.activeViewer === this.simpleViewer) { + this.copySourceBtn.setAttribute('title', 'Wait for the source to load to copy it to the clipboard'); + this.copySourceBtn.classList.add('disabled'); + } else { + this.copySourceBtn.setAttribute('title', 'Switch to the source to copy it to the clipboard'); + this.copySourceBtn.classList.add('disabled'); + } + + $(this.copySourceBtn).tooltip('fixTitle'); + } + + switchToViewer(name) { + const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`); + if (this.activeViewer === newViewer) return; + + const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active'); + const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`); + const oldViewer = this.$fileHolder[0].querySelector(`.blob-viewer:not([data-type='${name}'])`); + + if (oldButton) { + oldButton.classList.remove('active'); + } + + if (newButton) { + newButton.classList.add('active'); + newButton.blur(); + } + + if (oldViewer) { + oldViewer.classList.add('hidden'); + } + + newViewer.classList.remove('hidden'); + + this.activeViewer = newViewer; + + this.toggleCopyButtonState(); + + BlobViewer.loadViewer(newViewer) + .then((viewer) => { + $(viewer).syntaxHighlight(); + + this.$fileHolder.trigger('highlight:line'); + gl.utils.handleLocationHash(); + + this.toggleCopyButtonState(); + }) + .catch(() => new Flash('Error loading viewer')); + } + + static loadViewer(viewerParam) { + const viewer = viewerParam; + const url = viewer.getAttribute('data-url'); + + return new Promise((resolve, reject) => { + if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { + resolve(viewer); + return; + } + + viewer.setAttribute('data-loading', 'true'); + + $.ajax({ + url, + dataType: 'JSON', + }) + .fail(reject) + .done((data) => { + viewer.innerHTML = data.html; + viewer.setAttribute('data-loaded', 'true'); + + resolve(viewer); + }); + }); + } +} diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index c5deccf631e..1c64ccf536f 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -13,8 +13,9 @@ $(() => { const urlRoot = editBlobForm.data('relative-url-root'); const assetsPath = editBlobForm.data('assets-prefix'); const blobLanguage = editBlobForm.data('blob-language'); + const currentAction = $('.js-file-title').data('current-action'); - new EditBlob(`${urlRoot}${assetsPath}`, blobLanguage); + new EditBlob(`${urlRoot}${assetsPath}`, blobLanguage, currentAction); new NewCommitForm(editBlobForm); } diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index d3560d5df3b..b37988a674d 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,17 +1,13 @@ /* global ace */ -import BlobLicenseSelectors from '../blob/template_selectors/blob_license_selectors'; -import BlobGitignoreSelectors from '../blob/template_selectors/blob_gitignore_selectors'; -import BlobCiYamlSelectors from '../blob/template_selectors/blob_ci_yaml_selectors'; -import BlobDockerfileSelectors from '../blob/template_selectors/blob_dockerfile_selectors'; +import TemplateSelectorMediator from '../blob/file_template_mediator'; export default class EditBlob { - constructor(assetsPath, aceMode) { + constructor(assetsPath, aceMode, currentAction) { this.configureAceEditor(aceMode, assetsPath); - this.prepFileContentForSubmit(); this.initModePanesAndLinks(); this.initSoftWrap(); - this.initFileSelectors(); + this.initFileSelectors(currentAction); } configureAceEditor(aceMode, assetsPath) { @@ -19,6 +15,10 @@ export default class EditBlob { ace.config.loadModule('ace/ext/searchbox'); this.editor = ace.edit('editor'); + + // This prevents warnings re: automatic scrolling being logged + this.editor.$blockScrolling = Infinity; + this.editor.focus(); if (aceMode) { @@ -26,29 +26,13 @@ export default class EditBlob { } } - prepFileContentForSubmit() { - $('form').submit(() => { - $('#file-content').val(this.editor.getValue()); + initFileSelectors(currentAction) { + this.fileTemplateMediator = new TemplateSelectorMediator({ + currentAction, + editor: this.editor, }); } - initFileSelectors() { - this.blobTemplateSelectors = [ - new BlobLicenseSelectors({ - editor: this.editor, - }), - new BlobGitignoreSelectors({ - editor: this.editor, - }), - new BlobCiYamlSelectors({ - editor: this.editor, - }), - new BlobDockerfileSelectors({ - editor: this.editor, - }), - ]; - } - initModePanesAndLinks() { this.$editModePanes = $('.js-edit-mode-pane'); this.$editModeLinks = $('.js-edit-mode a'); diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index e057ac8df02..e0a6f64dd42 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -1,27 +1,27 @@ /* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */ /* global BoardService */ +/* global Flash */ import Vue from 'vue'; import VueResource from 'vue-resource'; import FilteredSearchBoards from './filtered_search_boards'; import eventHub from './eventhub'; - -require('./models/issue'); -require('./models/label'); -require('./models/list'); -require('./models/milestone'); -require('./models/user'); -require('./stores/boards_store'); -require('./stores/modal_store'); -require('./services/board_service'); -require('./mixins/modal_mixins'); -require('./mixins/sortable_default_options'); -require('./filters/due_date_filters'); -require('./components/board'); -require('./components/board_sidebar'); -require('./components/new_list_dropdown'); -require('./components/modal/index'); -require('../vue_shared/vue_resource_interceptor'); +import './models/issue'; +import './models/label'; +import './models/list'; +import './models/milestone'; +import './models/assignee'; +import './stores/boards_store'; +import './stores/modal_store'; +import './services/board_service'; +import './mixins/modal_mixins'; +import './mixins/sortable_default_options'; +import './filters/due_date_filters'; +import './components/board'; +import './components/board_sidebar'; +import './components/new_list_dropdown'; +import './components/modal/index'; +import '../vue_shared/vue_resource_interceptor'; Vue.use(VueResource); @@ -38,6 +38,10 @@ $(() => { Store.create(); + // hack to allow sidebar scripts like milestone_select manipulate the BoardsStore + gl.issueBoards.boardStoreIssueSet = (...args) => Vue.set(Store.detail.issue, ...args); + gl.issueBoards.boardStoreIssueDelete = (...args) => Vue.delete(Store.detail.issue, ...args); + gl.IssueBoardsApp = new Vue({ el: $boardApp, components: { @@ -54,7 +58,8 @@ $(() => { issueLinkBase: $boardApp.dataset.issueLinkBase, rootPath: $boardApp.dataset.rootPath, bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, - detailIssue: Store.detail + detailIssue: Store.detail, + defaultAvatar: $boardApp.dataset.defaultAvatar, }, computed: { detailIssueVisible () { @@ -77,10 +82,11 @@ $(() => { gl.boardService.all() .then((resp) => { resp.json().forEach((board) => { - const list = Store.addList(board); + const list = Store.addList(board, this.defaultAvatar); if (list.type === 'closed') { list.position = Infinity; + list.label = { description: 'Shows all closed issues. Moving an issue to this list closes it' }; } }); @@ -88,7 +94,7 @@ $(() => { Store.addBlankState(); this.loading = false; - }); + }).catch(() => new Flash('An error occurred. Please try again.')); }, methods: { updateTokens() { diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 35b3205cca7..9ba84489910 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,106 +1,105 @@ /* eslint-disable comma-dangle, space-before-function-paren, one-var */ /* global Sortable */ - import Vue from 'vue'; +import boardList from './board_list'; import boardBlankState from './board_blank_state'; +import './board_delete'; -require('./board_delete'); -require('./board_list'); - -(() => { - const Store = gl.issueBoards.BoardsStore; +const Store = gl.issueBoards.BoardsStore; - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.Board = Vue.extend({ - template: '#js-board-template', - components: { - 'board-list': gl.issueBoards.BoardList, - 'board-delete': gl.issueBoards.BoardDelete, - boardBlankState, - }, - props: { - list: Object, - disabled: Boolean, - issueLinkBase: String, - rootPath: String, - }, - data () { - return { - detailIssue: Store.detail, - filter: Store.filter, - }; - }, - watch: { - filter: { - handler() { - this.list.page = 1; - this.list.getIssues(true); - }, - deep: true, +gl.issueBoards.Board = Vue.extend({ + template: '#js-board-template', + components: { + boardList, + 'board-delete': gl.issueBoards.BoardDelete, + boardBlankState, + }, + props: { + list: Object, + disabled: Boolean, + issueLinkBase: String, + rootPath: String, + }, + data () { + return { + detailIssue: Store.detail, + filter: Store.filter, + }; + }, + watch: { + filter: { + handler() { + this.list.page = 1; + this.list.getIssues(true) + .catch(() => { + // TODO: handle request error + }); }, - detailIssue: { - handler () { - if (!Object.keys(this.detailIssue.issue).length) return; + deep: true, + }, + detailIssue: { + handler () { + if (!Object.keys(this.detailIssue.issue).length) return; - const issue = this.list.findIssue(this.detailIssue.issue.id); + const issue = this.list.findIssue(this.detailIssue.issue.id); - if (issue) { - const offsetLeft = this.$el.offsetLeft; - const boardsList = document.querySelectorAll('.boards-list')[0]; - const left = boardsList.scrollLeft - offsetLeft; - let right = (offsetLeft + this.$el.offsetWidth); + if (issue) { + const offsetLeft = this.$el.offsetLeft; + const boardsList = document.querySelectorAll('.boards-list')[0]; + const left = boardsList.scrollLeft - offsetLeft; + let right = (offsetLeft + this.$el.offsetWidth); - if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) { - // -290 here because width of boardsList is animating so therefore - // getting the width here is incorrect - // 290 is the width of the sidebar - right -= (boardsList.offsetWidth - 290); - } else { - right -= boardsList.offsetWidth; - } + if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) { + // -290 here because width of boardsList is animating so therefore + // getting the width here is incorrect + // 290 is the width of the sidebar + right -= (boardsList.offsetWidth - 290); + } else { + right -= boardsList.offsetWidth; + } - if (right - boardsList.scrollLeft > 0) { - $(boardsList).animate({ - scrollLeft: right - }, this.sortableOptions.animation); - } else if (left > 0) { - $(boardsList).animate({ - scrollLeft: offsetLeft - }, this.sortableOptions.animation); - } + if (right - boardsList.scrollLeft > 0) { + $(boardsList).animate({ + scrollLeft: right + }, this.sortableOptions.animation); + } else if (left > 0) { + $(boardsList).animate({ + scrollLeft: offsetLeft + }, this.sortableOptions.animation); } - }, - deep: true - } - }, - methods: { - showNewIssueForm() { - this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - } - }, - mounted () { - this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ - disabled: this.disabled, - group: 'boards', - draggable: '.is-draggable', - handle: '.js-board-handle', - onEnd: (e) => { - gl.issueBoards.onEnd(); + } + }, + deep: true + } + }, + methods: { + showNewIssueForm() { + this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; + } + }, + mounted () { + this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ + disabled: this.disabled, + group: 'boards', + draggable: '.is-draggable', + handle: '.js-board-handle', + onEnd: (e) => { + gl.issueBoards.onEnd(); - if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { - const order = this.sortable.toArray(); - const list = Store.findList('id', parseInt(e.item.dataset.id, 10)); + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { + const order = this.sortable.toArray(); + const list = Store.findList('id', parseInt(e.item.dataset.id, 10)); - this.$nextTick(() => { - Store.moveList(list, order); - }); - } + this.$nextTick(() => { + Store.moveList(list, order); + }); } - }); + } + }); - this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); - }, - }); -})(); + this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); + }, +}); diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js index 3fc68457961..870e115bd1a 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.js +++ b/app/assets/javascripts/boards/components/board_blank_state.js @@ -70,7 +70,10 @@ export default { list.id = listObj.id; list.label.id = listObj.label.id; - list.getIssues(); + list.getIssues() + .catch(() => { + // TODO: handle request error + }); }); }) .catch(() => { diff --git a/app/assets/javascripts/boards/components/board_card.js b/app/assets/javascripts/boards/components/board_card.js index f591134c548..079fb6438b9 100644 --- a/app/assets/javascripts/boards/components/board_card.js +++ b/app/assets/javascripts/boards/components/board_card.js @@ -1,4 +1,4 @@ -require('./issue_card_inner'); +import './issue_card_inner'; const Store = gl.issueBoards.BoardsStore; diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js index af621cfd57f..8a1b177bba8 100644 --- a/app/assets/javascripts/boards/components/board_delete.js +++ b/app/assets/javascripts/boards/components/board_delete.js @@ -2,22 +2,20 @@ import Vue from 'vue'; -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.BoardDelete = Vue.extend({ - props: { - list: Object - }, - methods: { - deleteBoard () { - $(this.$el).tooltip('hide'); +gl.issueBoards.BoardDelete = Vue.extend({ + props: { + list: Object + }, + methods: { + deleteBoard () { + $(this.$el).tooltip('hide'); - if (confirm('Are you sure you want to delete this list?')) { - this.list.destroy(); - } + if (confirm('Are you sure you want to delete this list?')) { + this.list.destroy(); } } - }); -})(); + } +}); diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js index 86e6c26e570..7ee2696e720 100644 --- a/app/assets/javascripts/boards/components/board_list.js +++ b/app/assets/javascripts/boards/components/board_list.js @@ -1,131 +1,202 @@ -/* eslint-disable comma-dangle, space-before-function-paren, max-len */ /* global Sortable */ - -import Vue from 'vue'; import boardNewIssue from './board_new_issue'; import boardCard from './board_card'; +import eventHub from '../eventhub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +const Store = gl.issueBoards.BoardsStore; - gl.issueBoards.BoardList = Vue.extend({ - template: '#js-board-list-template', - components: { - boardCard, - boardNewIssue, +export default { + name: 'BoardList', + props: { + disabled: { + type: Boolean, + required: true, }, - props: { - disabled: Boolean, - list: Object, - issues: Array, - loading: Boolean, - issueLinkBase: String, - rootPath: String, + list: { + type: Object, + required: true, }, - data () { - return { - scrollOffset: 250, - filters: Store.state.filters, - showCount: false, - showIssueForm: false - }; + issues: { + type: Array, + required: true, }, - watch: { - filters: { - handler () { - this.list.loadingMore = false; - this.$refs.list.scrollTop = 0; - }, - deep: true - }, - issues () { - this.$nextTick(() => { - if (this.scrollHeight() <= this.listHeight() && this.list.issuesSize > this.list.issues.length) { - this.list.page += 1; - this.list.getIssues(false); - } + loading: { + type: Boolean, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + }, + data() { + return { + scrollOffset: 250, + filters: Store.state.filters, + showCount: false, + showIssueForm: false, + }; + }, + components: { + boardCard, + boardNewIssue, + loadingIcon, + }, + methods: { + listHeight() { + return this.$refs.list.getBoundingClientRect().height; + }, + scrollHeight() { + return this.$refs.list.scrollHeight; + }, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); + }, + loadNextPage() { + const getIssues = this.list.nextPage(); + const loadingDone = () => { + this.list.loadingMore = false; + }; - if (this.scrollHeight() > Math.ceil(this.listHeight())) { - this.showCount = true; - } else { - this.showCount = false; - } - }); + if (getIssues) { + this.list.loadingMore = true; + getIssues + .then(loadingDone) + .catch(loadingDone); } }, - methods: { - listHeight () { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight () { - return this.$refs.list.scrollHeight; - }, - scrollTop () { - return this.$refs.list.scrollTop + this.listHeight(); + toggleForm() { + this.showIssueForm = !this.showIssueForm; + }, + onScroll() { + if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { + this.loadNextPage(); + } + }, + }, + watch: { + filters: { + handler() { + this.list.loadingMore = false; + this.$refs.list.scrollTop = 0; }, - loadNextPage () { - const getIssues = this.list.nextPage(); + deep: true, + }, + issues() { + this.$nextTick(() => { + if (this.scrollHeight() <= this.listHeight() && + this.list.issuesSize > this.list.issues.length) { + this.list.page += 1; + this.list.getIssues(false) + .catch(() => { + // TODO: handle request error + }); + } - if (getIssues) { - this.list.loadingMore = true; - getIssues.then(() => { - this.list.loadingMore = false; - }); + if (this.scrollHeight() > Math.ceil(this.listHeight())) { + this.showCount = true; + } else { + this.showCount = false; } - }, - toggleForm() { - this.showIssueForm = !this.showIssueForm; - }, - }, - created() { - gl.IssueBoardsApp.$on(`hide-issue-form-${this.list.id}`, this.toggleForm); + }); }, - mounted () { - const options = gl.issueBoards.getBoardSortableDefaultOptions({ - scroll: document.querySelectorAll('.boards-list')[0], - group: 'issues', - disabled: this.disabled, - filter: '.board-list-count, .is-disabled', - dataIdAttr: 'data-issue-id', - onStart: (e) => { - const card = this.$refs.issue[e.oldIndex]; + }, + created() { + eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm); + }, + mounted() { + const options = gl.issueBoards.getBoardSortableDefaultOptions({ + scroll: document.querySelectorAll('.boards-list')[0], + group: 'issues', + disabled: this.disabled, + filter: '.board-list-count, .is-disabled', + dataIdAttr: 'data-issue-id', + onStart: (e) => { + const card = this.$refs.issue[e.oldIndex]; - card.showDetail = false; - Store.moving.list = card.list; - Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId); + card.showDetail = false; + Store.moving.list = card.list; + Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId); - gl.issueBoards.onStart(); - }, - onAdd: (e) => { - gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex); + gl.issueBoards.onStart(); + }, + onAdd: (e) => { + gl.issueBoards.BoardsStore + .moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex); - this.$nextTick(() => { - e.item.remove(); - }); - }, - onUpdate: (e) => { - const sortedArray = this.sortable.toArray().filter(id => id !== '-1'); - gl.issueBoards.BoardsStore.moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray); - }, - onMove(e) { - return !e.related.classList.contains('board-list-count'); - } - }); + this.$nextTick(() => { + e.item.remove(); + }); + }, + onUpdate: (e) => { + const sortedArray = this.sortable.toArray().filter(id => id !== '-1'); + gl.issueBoards.BoardsStore + .moveIssueInList(this.list, Store.moving.issue, e.oldIndex, e.newIndex, sortedArray); + }, + onMove(e) { + return !e.related.classList.contains('board-list-count'); + }, + }); - this.sortable = Sortable.create(this.$refs.list, options); + this.sortable = Sortable.create(this.$refs.list, options); - // Scroll event on list to load more - this.$refs.list.onscroll = () => { - if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { - this.loadNextPage(); - } - }; - }, - beforeDestroy() { - gl.IssueBoardsApp.$off(`hide-issue-form-${this.list.id}`, this.toggleForm); - }, - }); -})(); + // Scroll event on list to load more + this.$refs.list.addEventListener('scroll', this.onScroll); + }, + beforeDestroy() { + eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm); + this.$refs.list.removeEventListener('scroll', this.onScroll); + }, + template: ` + <div class="board-list-component"> + <div + class="board-list-loading text-center" + aria-label="Loading issues" + v-if="loading"> + <loading-icon /> + </div> + <board-new-issue + :list="list" + v-if="list.type !== 'closed' && showIssueForm"/> + <ul + class="board-list" + v-show="!loading" + ref="list" + :data-board="list.id" + :class="{ 'is-smaller': showIssueForm }"> + <board-card + v-for="(issue, index) in issues" + ref="issue" + :index="index" + :list="list" + :issue="issue" + :issue-link-base="issueLinkBase" + :root-path="rootPath" + :disabled="disabled" + :key="issue.id" /> + <li + class="board-list-count text-center" + v-if="showCount" + data-id="-1"> + + <loading-icon + v-show="list.loadingMore" + label="Loading more issues" + /> + + <span v-if="list.issues.length === list.issuesSize"> + Showing all issues + </span> + <span v-else> + Showing {{ list.issues.length }} of {{ list.issuesSize }} issues + </span> + </li> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js index b88f59dd6d4..1ce95b62138 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js +++ b/app/assets/javascripts/boards/components/board_new_issue.js @@ -1,4 +1,6 @@ /* global ListIssue */ +import eventHub from '../eventhub'; + const Store = gl.issueBoards.BoardsStore; export default { @@ -24,6 +26,7 @@ export default { title: this.title, labels, subscribed: true, + assignees: [], }); this.list.newIssue(issue) @@ -49,7 +52,7 @@ export default { }, cancel() { this.title = ''; - gl.IssueBoardsApp.$emit(`hide-issue-form-${this.list.id}`); + eventHub.$emit(`hide-issue-form-${this.list.id}`); }, }, mounted() { diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 3c080008244..386102032cb 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -3,71 +3,124 @@ /* global MilestoneSelect */ /* global LabelsSelect */ /* global Sidebar */ +/* global Flash */ import Vue from 'vue'; +import eventHub from '../../sidebar/event_hub'; +import AssigneeTitle from '../../sidebar/components/assignees/assignee_title'; +import Assignees from '../../sidebar/components/assignees/assignees'; +import './sidebar/remove_issue'; -require('./sidebar/remove_issue'); +const Store = gl.issueBoards.BoardsStore; -(() => { - const Store = gl.issueBoards.BoardsStore; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.BoardSidebar = Vue.extend({ - props: { - currentUser: Object - }, - data() { - return { - detail: Store.detail, - issue: {}, - list: {}, - }; +gl.issueBoards.BoardSidebar = Vue.extend({ + props: { + currentUser: Object + }, + data() { + return { + detail: Store.detail, + issue: {}, + list: {}, + loadingAssignees: false, + }; + }, + computed: { + showSidebar () { + return Object.keys(this.issue).length; }, - computed: { - showSidebar () { - return Object.keys(this.issue).length; - } + assigneeId() { + return this.issue.assignee ? this.issue.assignee.id : 0; }, - watch: { - detail: { - handler () { - if (this.issue.id !== this.detail.issue.id) { - $('.js-issue-board-sidebar', this.$el).each((i, el) => { - $(el).data('glDropdown').clearMenu(); + milestoneTitle() { + return this.issue.milestone ? this.issue.milestone.title : 'No Milestone'; + } + }, + watch: { + detail: { + handler () { + if (this.issue.id !== this.detail.issue.id) { + $('.block.assignee') + .find('input:not(.js-vue)[name="issue[assignee_ids][]"]') + .each((i, el) => { + $(el).remove(); }); - } - this.issue = this.detail.issue; - this.list = this.detail.list; - }, - deep: true - }, - issue () { - if (this.showSidebar) { - this.$nextTick(() => { - $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0); - $('.right-sidebar').getNiceScroll().resize(); + $('.js-issue-board-sidebar', this.$el).each((i, el) => { + $(el).data('glDropdown').clearMenu(); }); } - } + + this.issue = this.detail.issue; + this.list = this.detail.list; + + this.$nextTick(() => { + this.endpoint = this.$refs.assigneeDropdown.dataset.issueUpdate; + }); + }, + deep: true }, - methods: { - closeSidebar () { - this.detail.issue = {}; - } + }, + methods: { + closeSidebar () { + this.detail.issue = {}; }, - mounted () { - new IssuableContext(this.currentUser); - new MilestoneSelect(); - new gl.DueDateSelectors(); - new LabelsSelect(); - new Sidebar(); - gl.Subscription.bindAll('.subscription'); + assignSelf () { + // Notify gl dropdown that we are now assigning to current user + this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself')); + + this.addAssignee(this.currentUser); + this.saveAssignees(); + }, + removeAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.removeAssignee(a); + }, + addAssignee (a) { + gl.issueBoards.BoardsStore.detail.issue.addAssignee(a); }, - components: { - removeBtn: gl.issueBoards.RemoveIssueBtn, + removeAllAssignees () { + gl.issueBoards.BoardsStore.detail.issue.removeAllAssignees(); + }, + saveAssignees () { + this.loadingAssignees = true; + + gl.issueBoards.BoardsStore.detail.issue.update(this.endpoint) + .then(() => { + this.loadingAssignees = false; + }) + .catch(() => { + this.loadingAssignees = false; + return new Flash('An error occurred while saving assignees'); + }); }, - }); -})(); + }, + created () { + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); + }, + mounted () { + new IssuableContext(this.currentUser); + new MilestoneSelect(); + new gl.DueDateSelectors(); + new LabelsSelect(); + new Sidebar(); + gl.Subscription.bindAll('.subscription'); + }, + components: { + removeBtn: gl.issueBoards.RemoveIssueBtn, + 'assignee-title': AssigneeTitle, + assignees: Assignees, + }, +}); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index a4629b092bf..4699ef5a51c 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -1,114 +1,190 @@ import Vue from 'vue'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import eventHub from '../eventhub'; -(() => { - const Store = gl.issueBoards.BoardsStore; +const Store = gl.issueBoards.BoardsStore; - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.IssueCardInner = Vue.extend({ - props: { - issue: { - type: Object, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - list: { - type: Object, - required: false, - }, - rootPath: { - type: String, - required: true, - }, - updateFilters: { - type: Boolean, - required: false, - default: false, - }, - }, - methods: { - showLabel(label) { - if (!this.list) return true; +gl.issueBoards.IssueCardInner = Vue.extend({ + props: { + issue: { + type: Object, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + list: { + type: Object, + required: false, + default: () => ({}), + }, + rootPath: { + type: String, + required: true, + }, + updateFilters: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + limitBeforeCounter: 3, + maxRender: 4, + maxCounter: 99, + }; + }, + components: { + userAvatarLink, + }, + computed: { + numberOverLimit() { + return this.issue.assignees.length - this.limitBeforeCounter; + }, + assigneeCounterTooltip() { + return `${this.assigneeCounterLabel} more`; + }, + assigneeCounterLabel() { + if (this.numberOverLimit > this.maxCounter) { + return `${this.maxCounter}+`; + } - return !this.list.label || label.id !== this.list.label.id; - }, - filterByLabel(label, e) { - if (!this.updateFilters) return; + return `+${this.numberOverLimit}`; + }, + shouldRenderCounter() { + if (this.issue.assignees.length <= this.maxRender) { + return false; + } - const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); - const labelTitle = encodeURIComponent(label.title); - const param = `label_name[]=${labelTitle}`; - const labelIndex = filterPath.indexOf(param); - $(e.currentTarget).tooltip('hide'); + return this.issue.assignees.length > this.numberOverLimit; + }, + cardUrl() { + return `${this.issueLinkBase}/${this.issue.id}`; + }, + issueId() { + return `#${this.issue.id}`; + }, + showLabelFooter() { + return this.issue.labels.find(l => this.showLabel(l)) !== undefined; + }, + }, + methods: { + isIndexLessThanlimit(index) { + return index < this.limitBeforeCounter; + }, + shouldRenderAssignee(index) { + // Eg. maxRender is 4, + // Render up to all 4 assignees if there are only 4 assigness + // Otherwise render up to the limitBeforeCounter + if (this.issue.assignees.length <= this.maxRender) { + return index < this.maxRender; + } - if (labelIndex === -1) { - filterPath.push(param); - } else { - filterPath.splice(labelIndex, 1); - } + return index < this.limitBeforeCounter; + }, + assigneeUrl(assignee) { + return `${this.rootPath}${assignee.username}`; + }, + assigneeUrlTitle(assignee) { + return `Assigned to ${assignee.name}`; + }, + avatarUrlTitle(assignee) { + return `Avatar for ${assignee.name}`; + }, + showLabel(label) { + if (!this.list) return true; + + return !this.list.label || label.id !== this.list.label.id; + }, + filterByLabel(label, e) { + if (!this.updateFilters) return; - gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); + const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); + const labelTitle = encodeURIComponent(label.title); + const param = `label_name[]=${labelTitle}`; + const labelIndex = filterPath.indexOf(param); + $(e.currentTarget).tooltip('hide'); - Store.updateFiltersUrl(); + if (labelIndex === -1) { + filterPath.push(param); + } else { + filterPath.splice(labelIndex, 1); + } - eventHub.$emit('updateTokens'); - }, - labelStyle(label) { - return { - backgroundColor: label.color, - color: label.textColor, - }; - }, - }, - template: ` - <div> + gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); + + Store.updateFiltersUrl(); + + eventHub.$emit('updateTokens'); + }, + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.textColor, + }; + }, + }, + template: ` + <div> + <div class="card-header"> <h4 class="card-title"> <i class="fa fa-eye-slash confidential-icon" - v-if="issue.confidential"></i> + v-if="issue.confidential" + aria-hidden="true" + /> <a - :href="issueLinkBase + '/' + issue.id" - :title="issue.title"> - {{ issue.title }} - </a> - </h4> - <div class="card-footer"> + class="js-no-trigger" + :href="cardUrl" + :title="issue.title">{{ issue.title }}</a> <span class="card-number" - v-if="issue.id"> - #{{ issue.id }} + v-if="issue.id" + > + {{ issueId }} + </span> + </h4> + <div class="card-assignee"> + <user-avatar-link + v-for="(assignee, index) in issue.assignees" + v-if="shouldRenderAssignee(index)" + class="js-no-trigger" + :link-href="assigneeUrl(assignee)" + :img-alt="avatarUrlTitle(assignee)" + :img-src="assignee.avatar" + :tooltip-text="assigneeUrlTitle(assignee)" + tooltip-placement="bottom" + /> + <span + class="avatar-counter has-tooltip" + :title="assigneeCounterTooltip" + v-if="shouldRenderCounter" + > + {{ assigneeCounterLabel }} </span> - <a - class="card-assignee has-tooltip js-no-trigger" - :href="rootPath + issue.assignee.username" - :title="'Assigned to ' + issue.assignee.name" - v-if="issue.assignee" - data-container="body"> - <img - class="avatar avatar-inline s20 js-no-trigger" - :src="issue.assignee.avatar" - width="20" - height="20" - :alt="'Avatar for ' + issue.assignee.name" /> - </a> - <button - class="label color-label has-tooltip js-no-trigger" - v-for="label in issue.labels" - type="button" - v-if="showLabel(label)" - @click="filterByLabel(label, $event)" - :style="labelStyle(label)" - :title="label.description" - data-container="body"> - {{ label.title }} - </button> </div> </div> - `, - }); -})(); + <div + class="card-footer" + v-if="showLabelFooter" + > + <button + class="label color-label has-tooltip" + v-for="label in issue.labels" + type="button" + v-if="showLabel(label)" + @click="filterByLabel(label, $event)" + :style="labelStyle(label)" + :title="label.description" + data-container="body"> + {{ label.title }} + </button> + </div> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js index 823319df6e7..13569df0c20 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.js +++ b/app/assets/javascripts/boards/components/modal/empty_state.js @@ -1,71 +1,69 @@ import Vue from 'vue'; -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalEmptyState = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return ModalStore.store; +gl.issueBoards.ModalEmptyState = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + props: { + image: { + type: String, + required: true, }, - props: { - image: { - type: String, - required: true, - }, - newIssuePath: { - type: String, - required: true, - }, + newIssuePath: { + type: String, + required: true, }, - computed: { - contents() { - const obj = { - title: 'You haven\'t added any issues to your project yet', - content: ` - An issue can be a bug, a todo or a feature request that needs to be - discussed in a project. Besides, issues are searchable and filterable. - `, - }; + }, + computed: { + contents() { + const obj = { + title: 'You haven\'t added any issues to your project yet', + content: ` + An issue can be a bug, a todo or a feature request that needs to be + discussed in a project. Besides, issues are searchable and filterable. + `, + }; - if (this.activeTab === 'selected') { - obj.title = 'You haven\'t selected any issues yet'; - obj.content = ` - Go back to <strong>Open issues</strong> and select some issues - to add to your board. - `; - } + if (this.activeTab === 'selected') { + obj.title = 'You haven\'t selected any issues yet'; + obj.content = ` + Go back to <strong>Open issues</strong> and select some issues + to add to your board. + `; + } - return obj; - }, + return obj; }, - template: ` - <section class="empty-state"> - <div class="row"> - <div class="col-xs-12 col-sm-6 col-sm-push-6"> - <aside class="svg-content" v-html="image"></aside> - </div> - <div class="col-xs-12 col-sm-6 col-sm-pull-6"> - <div class="text-content"> - <h4>{{ contents.title }}</h4> - <p v-html="contents.content"></p> - <a - :href="newIssuePath" - class="btn btn-success btn-inverted" - v-if="activeTab === 'all'"> - New issue - </a> - <button - type="button" - class="btn btn-default" - @click="changeTab('all')" - v-if="activeTab === 'selected'"> - Open issues - </button> - </div> + }, + template: ` + <section class="empty-state"> + <div class="row"> + <div class="col-xs-12 col-sm-6 col-sm-push-6"> + <aside class="svg-content" v-html="image"></aside> + </div> + <div class="col-xs-12 col-sm-6 col-sm-pull-6"> + <div class="text-content"> + <h4>{{ contents.title }}</h4> + <p v-html="contents.content"></p> + <a + :href="newIssuePath" + class="btn btn-success btn-inverted" + v-if="activeTab === 'all'"> + New issue + </a> + <button + type="button" + class="btn btn-default" + @click="changeTab('all')" + v-if="activeTab === 'selected'"> + Open issues + </button> </div> </div> - </section> - `, - }); -})(); + </div> + </section> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js index 887ce373096..fe7ab2db85d 100644 --- a/app/assets/javascripts/boards/components/modal/footer.js +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -2,83 +2,80 @@ /* global Flash */ import Vue from 'vue'; +import './lists_dropdown'; -require('./lists_dropdown'); +const ModalStore = gl.issueBoards.ModalStore; -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalFooter = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return { - modal: ModalStore.store, - state: gl.issueBoards.BoardsStore.state, - }; +gl.issueBoards.ModalFooter = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + submitDisabled() { + return !ModalStore.selectedCount(); }, - computed: { - submitDisabled() { - return !ModalStore.selectedCount(); - }, - submitText() { - const count = ModalStore.selectedCount(); + submitText() { + const count = ModalStore.selectedCount(); - return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; - }, + return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; }, - methods: { - addIssues() { - const list = this.modal.selectedList || this.state.lists[0]; - const selectedIssues = ModalStore.getSelectedIssues(); - const issueIds = selectedIssues.map(issue => issue.globalId); - - // Post the data to the backend - gl.boardService.bulkUpdate(issueIds, { - add_label_ids: [list.label.id], - }).catch(() => { - new Flash('Failed to update issues, please try again.', 'alert'); + }, + methods: { + addIssues() { + const list = this.modal.selectedList || this.state.lists[0]; + const selectedIssues = ModalStore.getSelectedIssues(); + const issueIds = selectedIssues.map(issue => issue.globalId); - selectedIssues.forEach((issue) => { - list.removeIssue(issue); - list.issuesSize -= 1; - }); - }); + // Post the data to the backend + gl.boardService.bulkUpdate(issueIds, { + add_label_ids: [list.label.id], + }).catch(() => { + new Flash('Failed to update issues, please try again.', 'alert'); - // Add the issues on the frontend selectedIssues.forEach((issue) => { - list.addIssue(issue); - list.issuesSize += 1; + list.removeIssue(issue); + list.issuesSize -= 1; }); + }); - this.toggleModal(false); - }, - }, - components: { - 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, + // Add the issues on the frontend + selectedIssues.forEach((issue) => { + list.addIssue(issue); + list.issuesSize += 1; + }); + + this.toggleModal(false); }, - template: ` - <footer - class="form-actions add-issues-footer"> - <div class="pull-left"> - <button - class="btn btn-success" - type="button" - :disabled="submitDisabled" - @click="addIssues"> - {{ submitText }} - </button> - <span class="inline add-issues-footer-to-list"> - to list - </span> - <lists-dropdown></lists-dropdown> - </div> + }, + components: { + 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, + }, + template: ` + <footer + class="form-actions add-issues-footer"> + <div class="pull-left"> <button - class="btn btn-default pull-right" + class="btn btn-success" type="button" - @click="toggleModal(false)"> - Cancel + :disabled="submitDisabled" + @click="addIssues"> + {{ submitText }} </button> - </footer> - `, - }); -})(); + <span class="inline add-issues-footer-to-list"> + to list + </span> + <lists-dropdown></lists-dropdown> + </div> + <button + class="btn btn-default pull-right" + type="button" + @click="toggleModal(false)"> + Cancel + </button> + </footer> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js index 116e29cd177..31f59d295bf 100644 --- a/app/assets/javascripts/boards/components/modal/header.js +++ b/app/assets/javascripts/boards/components/modal/header.js @@ -1,82 +1,79 @@ import Vue from 'vue'; import modalFilters from './filters'; +import './tabs'; -require('./tabs'); +const ModalStore = gl.issueBoards.ModalStore; -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalHeader = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - props: { - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, +gl.issueBoards.ModalHeader = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + props: { + projectId: { + type: Number, + required: true, }, - data() { - return ModalStore.store; + milestonePath: { + type: String, + required: true, }, - computed: { - selectAllText() { - if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { - return 'Select all'; - } - - return 'Deselect all'; - }, - showSearch() { - return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; - }, + labelPath: { + type: String, + required: true, }, - methods: { - toggleAll() { - this.$refs.selectAllBtn.blur(); + }, + data() { + return ModalStore.store; + }, + computed: { + selectAllText() { + if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { + return 'Select all'; + } - ModalStore.toggleAll(); - }, + return 'Deselect all'; }, - components: { - 'modal-tabs': gl.issueBoards.ModalTabs, - modalFilters, + showSearch() { + return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; + }, + }, + methods: { + toggleAll() { + this.$refs.selectAllBtn.blur(); + + ModalStore.toggleAll(); }, - template: ` - <div> - <header class="add-issues-header form-actions"> - <h2> - Add issues - <button - type="button" - class="close" - data-dismiss="modal" - aria-label="Close" - @click="toggleModal(false)"> - <span aria-hidden="true">×</span> - </button> - </h2> - </header> - <modal-tabs v-if="!loading && issuesCount > 0"></modal-tabs> - <div - class="add-issues-search append-bottom-10" - v-if="showSearch"> - <modal-filters :store="filter" /> + }, + components: { + 'modal-tabs': gl.issueBoards.ModalTabs, + modalFilters, + }, + template: ` + <div> + <header class="add-issues-header form-actions"> + <h2> + Add issues <button type="button" - class="btn btn-success btn-inverted prepend-left-10" - ref="selectAllBtn" - @click="toggleAll"> - {{ selectAllText }} + class="close" + data-dismiss="modal" + aria-label="Close" + @click="toggleModal(false)"> + <span aria-hidden="true">×</span> </button> - </div> + </h2> + </header> + <modal-tabs v-if="!loading && issuesCount > 0"></modal-tabs> + <div + class="add-issues-search append-bottom-10" + v-if="showSearch"> + <modal-filters :store="filter" /> + <button + type="button" + class="btn btn-success btn-inverted prepend-left-10" + ref="selectAllBtn" + @click="toggleAll"> + {{ selectAllText }} + </button> </div> - `, - }); -})(); + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js index 91c08cde13a..6356c266ee2 100644 --- a/app/assets/javascripts/boards/components/modal/index.js +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -2,166 +2,171 @@ import Vue from 'vue'; import queryData from '../../utils/query_data'; +import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; +import './header'; +import './list'; +import './footer'; +import './empty_state'; -require('./header'); -require('./list'); -require('./footer'); -require('./empty_state'); +const ModalStore = gl.issueBoards.ModalStore; -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.IssuesModal = Vue.extend({ - props: { - blankStateImage: { - type: String, - required: true, - }, - newIssuePath: { - type: String, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, +gl.issueBoards.IssuesModal = Vue.extend({ + props: { + blankStateImage: { + type: String, + required: true, }, - data() { - return ModalStore.store; + newIssuePath: { + type: String, + required: true, }, - watch: { - page() { - this.loadIssues(); - }, - showAddIssuesModal() { - if (this.showAddIssuesModal && !this.issues.length) { - this.loading = true; + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + watch: { + page() { + this.loadIssues(); + }, + showAddIssuesModal() { + if (this.showAddIssuesModal && !this.issues.length) { + this.loading = true; + const loadingDone = () => { + this.loading = false; + }; - this.loadIssues() - .then(() => { - this.loading = false; - }); - } else if (!this.showAddIssuesModal) { - this.issues = []; - this.selectedIssues = []; - this.issuesCount = false; - } - }, - filter: { - handler() { - if (this.$el.tagName) { - this.page = 1; - this.filterLoading = true; + this.loadIssues() + .then(loadingDone) + .catch(loadingDone); + } else if (!this.showAddIssuesModal) { + this.issues = []; + this.selectedIssues = []; + this.issuesCount = false; + } + }, + filter: { + handler() { + if (this.$el.tagName) { + this.page = 1; + this.filterLoading = true; + const loadingDone = () => { + this.filterLoading = false; + }; - this.loadIssues(true) - .then(() => { - this.filterLoading = false; - }); - } - }, - deep: true, + this.loadIssues(true) + .then(loadingDone) + .catch(loadingDone); + } }, + deep: true, }, - methods: { - loadIssues(clearIssues = false) { - if (!this.showAddIssuesModal) return false; - - return gl.boardService.getBacklog(queryData(this.filter.path, { - page: this.page, - per: this.perPage, - })).then((res) => { - const data = res.json(); + }, + methods: { + loadIssues(clearIssues = false) { + if (!this.showAddIssuesModal) return false; - if (clearIssues) { - this.issues = []; - } + return gl.boardService.getBacklog(queryData(this.filter.path, { + page: this.page, + per: this.perPage, + })).then((res) => { + const data = res.json(); - data.issues.forEach((issueObj) => { - const issue = new ListIssue(issueObj); - const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = !!foundSelectedIssue; - - this.issues.push(issue); - }); + if (clearIssues) { + this.issues = []; + } - this.loadingNewPage = false; + data.issues.forEach((issueObj) => { + const issue = new ListIssue(issueObj); + const foundSelectedIssue = ModalStore.findSelectedIssue(issue); + issue.selected = !!foundSelectedIssue; - if (!this.issuesCount) { - this.issuesCount = data.size; - } + this.issues.push(issue); }); - }, - }, - computed: { - showList() { - if (this.activeTab === 'selected') { - return this.selectedIssues.length > 0; - } - return this.issuesCount > 0; - }, - showEmptyState() { - if (!this.loading && this.issuesCount === 0) { - return true; - } + this.loadingNewPage = false; - return this.activeTab === 'selected' && this.selectedIssues.length === 0; - }, + if (!this.issuesCount) { + this.issuesCount = data.size; + } + }).catch(() => { + // TODO: handle request error + }); }, - created() { - this.page = 1; + }, + computed: { + showList() { + if (this.activeTab === 'selected') { + return this.selectedIssues.length > 0; + } + + return this.issuesCount > 0; }, - components: { - 'modal-header': gl.issueBoards.ModalHeader, - 'modal-list': gl.issueBoards.ModalList, - 'modal-footer': gl.issueBoards.ModalFooter, - 'empty-state': gl.issueBoards.ModalEmptyState, + showEmptyState() { + if (!this.loading && this.issuesCount === 0) { + return true; + } + + return this.activeTab === 'selected' && this.selectedIssues.length === 0; }, - template: ` - <div - class="add-issues-modal" - v-if="showAddIssuesModal"> - <div class="add-issues-container"> - <modal-header - :project-id="projectId" - :milestone-path="milestonePath" - :label-path="labelPath"> - </modal-header> - <modal-list - :image="blankStateImage" - :issue-link-base="issueLinkBase" - :root-path="rootPath" - v-if="!loading && showList && !filterLoading"></modal-list> - <empty-state - v-if="showEmptyState" - :image="blankStateImage" - :new-issue-path="newIssuePath"></empty-state> - <section - class="add-issues-list text-center" - v-if="loading || filterLoading"> - <div class="add-issues-list-loading"> - <i class="fa fa-spinner fa-spin"></i> - </div> - </section> - <modal-footer></modal-footer> - </div> + }, + created() { + this.page = 1; + }, + components: { + 'modal-header': gl.issueBoards.ModalHeader, + 'modal-list': gl.issueBoards.ModalList, + 'modal-footer': gl.issueBoards.ModalFooter, + 'empty-state': gl.issueBoards.ModalEmptyState, + loadingIcon, + }, + template: ` + <div + class="add-issues-modal" + v-if="showAddIssuesModal"> + <div class="add-issues-container"> + <modal-header + :project-id="projectId" + :milestone-path="milestonePath" + :label-path="labelPath"> + </modal-header> + <modal-list + :image="blankStateImage" + :issue-link-base="issueLinkBase" + :root-path="rootPath" + v-if="!loading && showList && !filterLoading"></modal-list> + <empty-state + v-if="showEmptyState" + :image="blankStateImage" + :new-issue-path="newIssuePath"></empty-state> + <section + class="add-issues-list text-center" + v-if="loading || filterLoading"> + <div class="add-issues-list-loading"> + <loading-icon /> + </div> + </section> + <modal-footer></modal-footer> </div> - `, - }); -})(); + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js index aba56d4aa31..363269c0d5d 100644 --- a/app/assets/javascripts/boards/components/modal/list.js +++ b/app/assets/javascripts/boards/components/modal/list.js @@ -3,159 +3,157 @@ import Vue from 'vue'; -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalList = Vue.extend({ - props: { - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - image: { - type: String, - required: true, - }, +gl.issueBoards.ModalList = Vue.extend({ + props: { + issueLinkBase: { + type: String, + required: true, }, - data() { - return ModalStore.store; + rootPath: { + type: String, + required: true, }, - watch: { - activeTab() { - if (this.activeTab === 'all') { - ModalStore.purgeUnselectedIssues(); - } - }, + image: { + type: String, + required: true, }, - computed: { - loopIssues() { - if (this.activeTab === 'all') { - return this.issues; - } - - return this.selectedIssues; - }, - groupedIssues() { - const groups = []; - this.loopIssues.forEach((issue, i) => { - const index = i % this.columns; - - if (!groups[index]) { - groups.push([]); - } - - groups[index].push(issue); - }); + }, + data() { + return ModalStore.store; + }, + watch: { + activeTab() { + if (this.activeTab === 'all') { + ModalStore.purgeUnselectedIssues(); + } + }, + }, + computed: { + loopIssues() { + if (this.activeTab === 'all') { + return this.issues; + } - return groups; - }, + return this.selectedIssues; }, - methods: { - scrollHandler() { - const currentPage = Math.floor(this.issues.length / this.perPage); + groupedIssues() { + const groups = []; + this.loopIssues.forEach((issue, i) => { + const index = i % this.columns; - if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage - && currentPage === this.page) { - this.loadingNewPage = true; - this.page += 1; + if (!groups[index]) { + groups.push([]); } - }, - toggleIssue(e, issue) { - if (e.target.tagName !== 'A') { - ModalStore.toggleIssue(issue); - } - }, - listHeight() { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight() { - return this.$refs.list.scrollHeight; - }, - scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); - }, - showIssue(issue) { - if (this.activeTab === 'all') return true; - - const index = ModalStore.selectedIssueIndex(issue); - return index !== -1; - }, - setColumnCount() { - const breakpoint = bp.getBreakpointSize(); + groups[index].push(issue); + }); - if (breakpoint === 'lg' || breakpoint === 'md') { - this.columns = 3; - } else if (breakpoint === 'sm') { - this.columns = 2; - } else { - this.columns = 1; - } - }, + return groups; }, - mounted() { - this.scrollHandlerWrapper = this.scrollHandler.bind(this); - this.setColumnCountWrapper = this.setColumnCount.bind(this); - this.setColumnCount(); + }, + methods: { + scrollHandler() { + const currentPage = Math.floor(this.issues.length / this.perPage); - this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); - window.addEventListener('resize', this.setColumnCountWrapper); + if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage + && currentPage === this.page) { + this.loadingNewPage = true; + this.page += 1; + } + }, + toggleIssue(e, issue) { + if (e.target.tagName !== 'A') { + ModalStore.toggleIssue(issue); + } + }, + listHeight() { + return this.$refs.list.getBoundingClientRect().height; }, - beforeDestroy() { - this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); - window.removeEventListener('resize', this.setColumnCountWrapper); + scrollHeight() { + return this.$refs.list.scrollHeight; }, - components: { - 'issue-card-inner': gl.issueBoards.IssueCardInner, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); }, - template: ` - <section - class="add-issues-list add-issues-list-columns" - ref="list"> + showIssue(issue) { + if (this.activeTab === 'all') return true; + + const index = ModalStore.selectedIssueIndex(issue); + + return index !== -1; + }, + setColumnCount() { + const breakpoint = bp.getBreakpointSize(); + + if (breakpoint === 'lg' || breakpoint === 'md') { + this.columns = 3; + } else if (breakpoint === 'sm') { + this.columns = 2; + } else { + this.columns = 1; + } + }, + }, + mounted() { + this.scrollHandlerWrapper = this.scrollHandler.bind(this); + this.setColumnCountWrapper = this.setColumnCount.bind(this); + this.setColumnCount(); + + this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); + window.addEventListener('resize', this.setColumnCountWrapper); + }, + beforeDestroy() { + this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); + window.removeEventListener('resize', this.setColumnCountWrapper); + }, + components: { + 'issue-card-inner': gl.issueBoards.IssueCardInner, + }, + template: ` + <section + class="add-issues-list add-issues-list-columns" + ref="list"> + <div + class="empty-state add-issues-empty-state-filter text-center" + v-if="issuesCount > 0 && issues.length === 0"> <div - class="empty-state add-issues-empty-state-filter text-center" - v-if="issuesCount > 0 && issues.length === 0"> - <div - class="svg-content" - v-html="image"> - </div> - <div class="text-content"> - <h4> - There are no issues to show. - </h4> - </div> + class="svg-content" + v-html="image"> + </div> + <div class="text-content"> + <h4> + There are no issues to show. + </h4> </div> + </div> + <div + v-for="group in groupedIssues" + class="add-issues-list-column"> <div - v-for="group in groupedIssues" - class="add-issues-list-column"> + v-for="issue in group" + v-if="showIssue(issue)" + class="card-parent"> <div - v-for="issue in group" - v-if="showIssue(issue)" - class="card-parent"> - <div - class="card" - :class="{ 'is-active': issue.selected }" - @click="toggleIssue($event, issue)"> - <issue-card-inner - :issue="issue" - :issue-link-base="issueLinkBase" - :root-path="rootPath"> - </issue-card-inner> - <span - :aria-label="'Issue #' + issue.id + ' selected'" - aria-checked="true" - v-if="issue.selected" - class="issue-card-selected text-center"> - <i class="fa fa-check"></i> - </span> - </div> + class="card" + :class="{ 'is-active': issue.selected }" + @click="toggleIssue($event, issue)"> + <issue-card-inner + :issue="issue" + :issue-link-base="issueLinkBase" + :root-path="rootPath"> + </issue-card-inner> + <span + :aria-label="'Issue #' + issue.id + ' selected'" + aria-checked="true" + v-if="issue.selected" + class="issue-card-selected text-center"> + <i class="fa fa-check"></i> + </span> </div> </div> - </section> - `, - }); -})(); + </div> + </section> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js index 9e9ed46ab8d..8cd15df90fa 100644 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js @@ -1,57 +1,55 @@ import Vue from 'vue'; -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ - data() { - return { - modal: ModalStore.store, - state: gl.issueBoards.BoardsStore.state, - }; +gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + selected() { + return this.modal.selectedList || this.state.lists[0]; }, - computed: { - selected() { - return this.modal.selectedList || this.state.lists[0]; - }, - }, - destroyed() { - this.modal.selectedList = null; - }, - template: ` - <div class="dropdown inline"> - <button - class="dropdown-menu-toggle" - type="button" - data-toggle="dropdown" - aria-expanded="false"> - <span - class="dropdown-label-box" - :style="{ backgroundColor: selected.label.color }"> - </span> - {{ selected.title }} - <i class="fa fa-chevron-down"></i> - </button> - <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> - <ul> - <li - v-for="list in state.lists" - v-if="list.type == 'label'"> - <a - href="#" - role="button" - :class="{ 'is-active': list.id == selected.id }" - @click.prevent="modal.selectedList = list"> - <span - class="dropdown-label-box" - :style="{ backgroundColor: list.label.color }"> - </span> - {{ list.title }} - </a> - </li> - </ul> - </div> + }, + destroyed() { + this.modal.selectedList = null; + }, + template: ` + <div class="dropdown inline"> + <button + class="dropdown-menu-toggle" + type="button" + data-toggle="dropdown" + aria-expanded="false"> + <span + class="dropdown-label-box" + :style="{ backgroundColor: selected.label.color }"> + </span> + {{ selected.title }} + <i class="fa fa-chevron-down"></i> + </button> + <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> + <ul> + <li + v-for="list in state.lists" + v-if="list.type == 'label'"> + <a + href="#" + role="button" + :class="{ 'is-active': list.id == selected.id }" + @click.prevent="modal.selectedList = list"> + <span + class="dropdown-label-box" + :style="{ backgroundColor: list.label.color }"> + </span> + {{ list.title }} + </a> + </li> + </ul> </div> - `, - }); -})(); + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js index 23cb1b13d11..3e5d08e3d75 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.js +++ b/app/assets/javascripts/boards/components/modal/tabs.js @@ -1,48 +1,46 @@ import Vue from 'vue'; -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalTabs = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return ModalStore.store; +gl.issueBoards.ModalTabs = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + computed: { + selectedCount() { + return ModalStore.selectedCount(); }, - computed: { - selectedCount() { - return ModalStore.selectedCount(); - }, - }, - destroyed() { - this.activeTab = 'all'; - }, - template: ` - <div class="top-area prepend-top-10 append-bottom-10"> - <ul class="nav-links issues-state-filters"> - <li :class="{ 'active': activeTab == 'all' }"> - <a - href="#" - role="button" - @click.prevent="changeTab('all')"> - Open issues - <span class="badge"> - {{ issuesCount }} - </span> - </a> - </li> - <li :class="{ 'active': activeTab == 'selected' }"> - <a - href="#" - role="button" - @click.prevent="changeTab('selected')"> - Selected issues - <span class="badge"> - {{ selectedCount }} - </span> - </a> - </li> - </ul> - </div> - `, - }); -})(); + }, + destroyed() { + this.activeTab = 'all'; + }, + template: ` + <div class="top-area prepend-top-10 append-bottom-10"> + <ul class="nav-links issues-state-filters"> + <li :class="{ 'active': activeTab == 'all' }"> + <a + href="#" + role="button" + @click.prevent="changeTab('all')"> + Open issues + <span class="badge"> + {{ issuesCount }} + </span> + </a> + </li> + <li :class="{ 'active': activeTab == 'selected' }"> + <a + href="#" + role="button" + @click.prevent="changeTab('selected')"> + Selected issues + <span class="badge"> + {{ selectedCount }} + </span> + </a> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 556826a9148..f29b6caa1ac 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -1,76 +1,77 @@ -/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var */ +/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var, + promise/catch-or-return */ -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - const Store = gl.issueBoards.BoardsStore; +const Store = gl.issueBoards.BoardsStore; - $(document).off('created.label').on('created.label', (e, label) => { - Store.new({ +$(document).off('created.label').on('created.label', (e, label) => { + Store.new({ + title: label.title, + position: Store.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, title: label.title, - position: Store.state.lists.length - 2, - list_type: 'label', - label: { - id: label.id, - title: label.title, - color: label.color - } - }); + color: label.color + } }); +}); - gl.issueBoards.newListDropdownInit = () => { - $('.js-new-board-list').each(function () { - const $this = $(this); - new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path')); +gl.issueBoards.newListDropdownInit = () => { + $('.js-new-board-list').each(function () { + const $this = $(this); + new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path')); - $this.glDropdown({ - data(term, callback) { - $.get($this.attr('data-labels')) - .then((resp) => { - callback(resp); - }); - }, - renderRow (label) { - const active = Store.findList('title', label.title); - const $li = $('<li />'); - const $a = $('<a />', { - class: (active ? `is-active js-board-list-${active.id}` : ''), - text: label.title, - href: '#' - }); - const $labelColor = $('<span />', { - class: 'dropdown-label-box', - style: `background-color: ${label.color}` + $this.glDropdown({ + data(term, callback) { + $.get($this.attr('data-labels')) + .then((resp) => { + callback(resp); }); + }, + renderRow (label) { + const active = Store.findList('title', label.title); + const $li = $('<li />'); + const $a = $('<a />', { + class: (active ? `is-active js-board-list-${active.id}` : ''), + text: label.title, + href: '#' + }); + const $labelColor = $('<span />', { + class: 'dropdown-label-box', + style: `background-color: ${label.color}` + }); - return $li.append($a.prepend($labelColor)); - }, - search: { - fields: ['title'] - }, - filterable: true, - selectable: true, - multiSelect: true, - clicked (label, $el, e) { - e.preventDefault(); + return $li.append($a.prepend($labelColor)); + }, + search: { + fields: ['title'] + }, + filterable: true, + selectable: true, + multiSelect: true, + clicked (options) { + const { e } = options; + const label = options.selectedObj; + e.preventDefault(); - if (!Store.findList('title', label.title)) { - Store.new({ + if (!Store.findList('title', label.title)) { + Store.new({ + title: label.title, + position: Store.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, title: label.title, - position: Store.state.lists.length - 2, - list_type: 'label', - label: { - id: label.id, - title: label.title, - color: label.color - } - }); + color: label.color + } + }); - Store.state.lists = _.sortBy(Store.state.lists, 'position'); - } + Store.state.lists = _.sortBy(Store.state.lists, 'position'); } - }); + } }); - }; -})(); + }); +}; diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js index 772ea4c5565..5597f128b80 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js @@ -3,59 +3,57 @@ import Vue from 'vue'; -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.RemoveIssueBtn = Vue.extend({ - props: { - issue: { - type: Object, - required: true, - }, - list: { - type: Object, - required: true, - }, +const Store = gl.issueBoards.BoardsStore; + +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; + +gl.issueBoards.RemoveIssueBtn = Vue.extend({ + props: { + issue: { + type: Object, + required: true, }, - methods: { - removeIssue() { - const issue = this.issue; - const lists = issue.getLists(); - const labelIds = lists.map(list => list.label.id); - - // Post the remove data - gl.boardService.bulkUpdate([issue.globalId], { - remove_label_ids: labelIds, - }).catch(() => { - new Flash('Failed to remove issue from board, please try again.', 'alert'); - - lists.forEach((list) => { - list.addIssue(issue); - }); - }); + list: { + type: Object, + required: true, + }, + }, + methods: { + removeIssue() { + const issue = this.issue; + const lists = issue.getLists(); + const labelIds = lists.map(list => list.label.id); + + // Post the remove data + gl.boardService.bulkUpdate([issue.globalId], { + remove_label_ids: labelIds, + }).catch(() => { + new Flash('Failed to remove issue from board, please try again.', 'alert'); - // Remove from the frontend store lists.forEach((list) => { - list.removeIssue(issue); + list.addIssue(issue); }); + }); + + // Remove from the frontend store + lists.forEach((list) => { + list.removeIssue(issue); + }); - Store.detail.issue = {}; - }, + Store.detail.issue = {}; }, - template: ` - <div - class="block list" - v-if="list.type !== 'closed'"> - <button - class="btn btn-default btn-block" - type="button" - @click="removeIssue"> - Remove from board - </button> - </div> - `, - }); -})(); + }, + template: ` + <div + class="block list" + v-if="list.type !== 'closed'"> + <button + class="btn btn-default btn-block" + type="button" + @click="removeIssue"> + Remove from board + </button> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js index d378b7d4baf..2b0a1aaa89f 100644 --- a/app/assets/javascripts/boards/mixins/modal_mixins.js +++ b/app/assets/javascripts/boards/mixins/modal_mixins.js @@ -1,14 +1,12 @@ -(() => { - const ModalStore = gl.issueBoards.ModalStore; +const ModalStore = gl.issueBoards.ModalStore; - gl.issueBoards.ModalMixins = { - methods: { - toggleModal(toggle) { - ModalStore.store.showAddIssuesModal = toggle; - }, - changeTab(tab) { - ModalStore.store.activeTab = tab; - }, +gl.issueBoards.ModalMixins = { + methods: { + toggleModal(toggle) { + ModalStore.store.showAddIssuesModal = toggle; }, - }; -})(); + changeTab(tab) { + ModalStore.store.activeTab = tab; + }, + }, +}; diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index b6c6d17274f..38a0eb12f92 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,39 +1,37 @@ /* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */ /* global DocumentTouch */ -((w) => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.onStart = () => { - $('.has-tooltip').tooltip('hide') - .tooltip('disable'); - document.body.classList.add('is-dragging'); - }; - - gl.issueBoards.onEnd = () => { - $('.has-tooltip').tooltip('enable'); - document.body.classList.remove('is-dragging'); - }; +gl.issueBoards.onStart = () => { + $('.has-tooltip').tooltip('hide') + .tooltip('disable'); + document.body.classList.add('is-dragging'); +}; - gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; +gl.issueBoards.onEnd = () => { + $('.has-tooltip').tooltip('enable'); + document.body.classList.remove('is-dragging'); +}; - gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { - const defaultSortOptions = { - animation: 200, - forceFallback: true, - fallbackClass: 'is-dragging', - fallbackOnBody: true, - ghostClass: 'is-ghost', - filter: '.board-delete, .btn', - delay: gl.issueBoards.touchEnabled ? 100 : 0, - scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, - scrollSpeed: 20, - onStart: gl.issueBoards.onStart, - onEnd: gl.issueBoards.onEnd - }; +gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; - Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); - return defaultSortOptions; +gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { + const defaultSortOptions = { + animation: 200, + forceFallback: true, + fallbackClass: 'is-dragging', + fallbackOnBody: true, + ghostClass: 'is-ghost', + filter: '.board-delete, .btn', + delay: gl.issueBoards.touchEnabled ? 100 : 0, + scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, + scrollSpeed: 20, + onStart: gl.issueBoards.onStart, + onEnd: gl.issueBoards.onEnd }; -})(window); + + Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); + return defaultSortOptions; +}; diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js new file mode 100644 index 00000000000..05dd449e4fd --- /dev/null +++ b/app/assets/javascripts/boards/models/assignee.js @@ -0,0 +1,12 @@ +/* eslint-disable no-unused-vars */ + +class ListAssignee { + constructor(user, defaultAvatar) { + this.id = user.id; + this.name = user.name; + this.username = user.username; + this.avatar = user.avatar_url || defaultAvatar; + } +} + +window.ListAssignee = ListAssignee; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index d6175069e37..6c2d8a3781b 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -1,12 +1,12 @@ /* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ /* global ListLabel */ /* global ListMilestone */ -/* global ListUser */ +/* global ListAssignee */ import Vue from 'vue'; class ListIssue { - constructor (obj) { + constructor (obj, defaultAvatar) { this.globalId = obj.id; this.id = obj.iid; this.title = obj.title; @@ -14,14 +14,10 @@ class ListIssue { this.dueDate = obj.due_date; this.subscribed = obj.subscribed; this.labels = []; + this.assignees = []; this.selected = false; - this.assignee = false; this.position = obj.relative_position || Infinity; - if (obj.assignee) { - this.assignee = new ListUser(obj.assignee); - } - if (obj.milestone) { this.milestone = new ListMilestone(obj.milestone); } @@ -29,6 +25,8 @@ class ListIssue { obj.labels.forEach((label) => { this.labels.push(new ListLabel(label)); }); + + this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar)); } addLabel (label) { @@ -51,6 +49,26 @@ class ListIssue { labels.forEach(this.removeLabel.bind(this)); } + addAssignee (assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(new ListAssignee(assignee)); + } + } + + findAssignee (findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee (removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees () { + this.assignees = []; + } + getLists () { return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); } @@ -60,7 +78,7 @@ class ListIssue { issue: { milestone_id: this.milestone ? this.milestone.id : null, due_date: this.dueDate, - assignee_id: this.assignee ? this.assignee.id : null, + assignee_ids: this.assignees.length > 0 ? this.assignees.map((u) => u.id) : [0], label_ids: this.labels.map((label) => label.id) } }; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 91e5fb2a666..90561d0f7a8 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -3,8 +3,10 @@ /* global ListLabel */ import queryData from '../utils/query_data'; +const PER_PAGE = 20; + class List { - constructor (obj) { + constructor (obj, defaultAvatar) { this.id = obj.id; this._uid = this.guid(); this.position = obj.position; @@ -16,13 +18,16 @@ class List { this.loadingMore = false; this.issues = []; this.issuesSize = 0; + this.defaultAvatar = defaultAvatar; if (obj.label) { this.label = new ListLabel(obj.label); } if (this.type !== 'blank' && this.id) { - this.getIssues(); + this.getIssues().catch(() => { + // TODO: handle request error + }); } } @@ -49,16 +54,24 @@ class List { gl.issueBoards.BoardsStore.state.lists.splice(index, 1); gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); - gl.boardService.destroyList(this.id); + gl.boardService.destroyList(this.id) + .catch(() => { + // TODO: handle request error + }); } update () { - gl.boardService.updateList(this.id, this.position); + gl.boardService.updateList(this.id, this.position) + .catch(() => { + // TODO: handle request error + }); } nextPage () { if (this.issuesSize > this.issues.length) { - this.page += 1; + if (this.issues.length / PER_PAGE >= 1) { + this.page += 1; + } return this.getIssues(false); } @@ -102,7 +115,7 @@ class List { createIssues (data) { data.forEach((issueObj) => { - this.addIssue(new ListIssue(issueObj)); + this.addIssue(new ListIssue(issueObj, this.defaultAvatar)); }); } @@ -141,13 +154,16 @@ class List { this.issues.splice(oldIndex, 1); this.issues.splice(newIndex, 0, issue); - gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid); + gl.boardService.moveIssue(issue.id, null, null, moveBeforeIid, moveAfterIid) + .catch(() => { + // TODO: handle request error + }); } updateIssueLabel(issue, listFrom, moveBeforeIid, moveAfterIid) { gl.boardService.moveIssue(issue.id, listFrom.id, this.id, moveBeforeIid, moveAfterIid) - .then(() => { - listFrom.getIssues(false); + .catch(() => { + // TODO: handle request error }); } diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js deleted file mode 100644 index 8e9de4d4cbb..00000000000 --- a/app/assets/javascripts/boards/models/user.js +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable no-unused-vars */ - -class ListUser { - constructor(user) { - this.id = user.id; - this.name = user.name; - this.username = user.username; - this.avatar = user.avatar_url; - } -} - -window.ListUser = ListUser; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index bcda70d0638..ad9997ac334 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -3,125 +3,126 @@ import Cookies from 'js-cookie'; -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; - gl.issueBoards.BoardsStore = { - disabled: false, - filter: { - path: '', - }, - state: {}, - detail: { - issue: {} - }, - moving: { - issue: {}, - list: {} - }, - create () { - this.state.lists = []; - this.filter.path = gl.utils.getUrlParamsArray().join('&'); - }, - addList (listObj) { - const list = new List(listObj); - this.state.lists.push(list); +gl.issueBoards.BoardsStore = { + disabled: false, + filter: { + path: '', + }, + state: {}, + detail: { + issue: {} + }, + moving: { + issue: {}, + list: {} + }, + create () { + this.state.lists = []; + this.filter.path = gl.utils.getUrlParamsArray().join('&'); + }, + addList (listObj, defaultAvatar) { + const list = new List(listObj, defaultAvatar); + this.state.lists.push(list); - return list; - }, - new (listObj) { - const list = this.addList(listObj); + return list; + }, + new (listObj) { + const list = this.addList(listObj); - list - .save() - .then(() => { - this.state.lists = _.sortBy(this.state.lists, 'position'); - }); - this.removeBlankState(); - }, - updateNewListDropdown (listId) { - $(`.js-board-list-${listId}`).removeClass('is-active'); - }, - shouldAddBlankState () { - // Decide whether to add the blank state - return !(this.state.lists.filter(list => list.type !== 'closed')[0]); - }, - addBlankState () { - if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; - - this.addList({ - id: 'blank', - list_type: 'blank', - title: 'Welcome to your Issue Board!', - position: 0 + list + .save() + .then(() => { + this.state.lists = _.sortBy(this.state.lists, 'position'); + }) + .catch(() => { + // https://gitlab.com/gitlab-org/gitlab-ce/issues/30821 }); + this.removeBlankState(); + }, + updateNewListDropdown (listId) { + $(`.js-board-list-${listId}`).removeClass('is-active'); + }, + shouldAddBlankState () { + // Decide whether to add the blank state + return !(this.state.lists.filter(list => list.type !== 'closed')[0]); + }, + addBlankState () { + if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; - this.state.lists = _.sortBy(this.state.lists, 'position'); - }, - removeBlankState () { - this.removeList('blank'); - - Cookies.set('issue_board_welcome_hidden', 'true', { - expires: 365 * 10, - path: '' - }); - }, - welcomeIsHidden () { - return Cookies.get('issue_board_welcome_hidden') === 'true'; - }, - removeList (id, type = 'blank') { - const list = this.findList('id', id, type); + this.addList({ + id: 'blank', + list_type: 'blank', + title: 'Welcome to your Issue Board!', + position: 0 + }); - if (!list) return; + this.state.lists = _.sortBy(this.state.lists, 'position'); + }, + removeBlankState () { + this.removeList('blank'); - this.state.lists = this.state.lists.filter(list => list.id !== id); - }, - moveList (listFrom, orderLists) { - orderLists.forEach((id, i) => { - const list = this.findList('id', parseInt(id, 10)); + Cookies.set('issue_board_welcome_hidden', 'true', { + expires: 365 * 10, + path: '' + }); + }, + welcomeIsHidden () { + return Cookies.get('issue_board_welcome_hidden') === 'true'; + }, + removeList (id, type = 'blank') { + const list = this.findList('id', id, type); - list.position = i; - }); - listFrom.update(); - }, - moveIssueToList (listFrom, listTo, issue, newIndex) { - const issueTo = listTo.findIssue(issue.id); - const issueLists = issue.getLists(); - const listLabels = issueLists.map(listIssue => listIssue.label); + if (!list) return; - if (!issueTo) { - // Add to new lists issues if it doesn't already exist - listTo.addIssue(issue, listFrom, newIndex); - } else { - listTo.updateIssueLabel(issue, listFrom); - issueTo.removeLabel(listFrom.label); - } + this.state.lists = this.state.lists.filter(list => list.id !== id); + }, + moveList (listFrom, orderLists) { + orderLists.forEach((id, i) => { + const list = this.findList('id', parseInt(id, 10)); - if (listTo.type === 'closed') { - issueLists.forEach((list) => { - list.removeIssue(issue); - }); - issue.removeLabels(listLabels); - } else { - listFrom.removeIssue(issue); - } - }, - moveIssueInList (list, issue, oldIndex, newIndex, idArray) { - const beforeId = parseInt(idArray[newIndex - 1], 10) || null; - const afterId = parseInt(idArray[newIndex + 1], 10) || null; + list.position = i; + }); + listFrom.update(); + }, + moveIssueToList (listFrom, listTo, issue, newIndex) { + const issueTo = listTo.findIssue(issue.id); + const issueLists = issue.getLists(); + const listLabels = issueLists.map(listIssue => listIssue.label); - list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); - }, - findList (key, val, type = 'label') { - return this.state.lists.filter((list) => { - const byType = type ? list['type'] === type : true; + if (!issueTo) { + // Add to new lists issues if it doesn't already exist + listTo.addIssue(issue, listFrom, newIndex); + } else { + listTo.updateIssueLabel(issue, listFrom); + issueTo.removeLabel(listFrom.label); + } - return list[key] === val && byType; - })[0]; - }, - updateFiltersUrl () { - history.pushState(null, null, `?${this.filter.path}`); + if (listTo.type === 'closed') { + issueLists.forEach((list) => { + list.removeIssue(issue); + }); + issue.removeLabels(listLabels); + } else { + listFrom.removeIssue(issue); } - }; -})(); + }, + moveIssueInList (list, issue, oldIndex, newIndex, idArray) { + const beforeId = parseInt(idArray[newIndex - 1], 10) || null; + const afterId = parseInt(idArray[newIndex + 1], 10) || null; + + list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); + }, + findList (key, val, type = 'label') { + return this.state.lists.filter((list) => { + const byType = type ? list['type'] === type : true; + + return list[key] === val && byType; + })[0]; + }, + updateFiltersUrl () { + history.pushState(null, null, `?${this.filter.path}`); + } +}; diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js index 9b009483a3c..4fdc925c825 100644 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ b/app/assets/javascripts/boards/stores/modal_store.js @@ -1,100 +1,98 @@ -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - class ModalStore { - constructor() { - this.store = { - columns: 3, - issues: [], - issuesCount: false, - selectedIssues: [], - showAddIssuesModal: false, - activeTab: 'all', - selectedList: null, - searchTerm: '', - loading: false, - loadingNewPage: false, - filterLoading: false, - page: 1, - perPage: 50, - filter: { - path: '', - }, - }; - } +window.gl = window.gl || {}; +window.gl.issueBoards = window.gl.issueBoards || {}; + +class ModalStore { + constructor() { + this.store = { + columns: 3, + issues: [], + issuesCount: false, + selectedIssues: [], + showAddIssuesModal: false, + activeTab: 'all', + selectedList: null, + searchTerm: '', + loading: false, + loadingNewPage: false, + filterLoading: false, + page: 1, + perPage: 50, + filter: { + path: '', + }, + }; + } - selectedCount() { - return this.getSelectedIssues().length; - } + selectedCount() { + return this.getSelectedIssues().length; + } - toggleIssue(issueObj) { - const issue = issueObj; - const selected = issue.selected; + toggleIssue(issueObj) { + const issue = issueObj; + const selected = issue.selected; - issue.selected = !selected; + issue.selected = !selected; - if (!selected) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } + if (!selected) { + this.addSelectedIssue(issue); + } else { + this.removeSelectedIssue(issue); } + } - toggleAll() { - const select = this.selectedCount() !== this.store.issues.length; + toggleAll() { + const select = this.selectedCount() !== this.store.issues.length; - this.store.issues.forEach((issue) => { - const issueUpdate = issue; + this.store.issues.forEach((issue) => { + const issueUpdate = issue; - if (issueUpdate.selected !== select) { - issueUpdate.selected = select; + if (issueUpdate.selected !== select) { + issueUpdate.selected = select; - if (select) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } + if (select) { + this.addSelectedIssue(issue); + } else { + this.removeSelectedIssue(issue); } - }); - } + } + }); + } - getSelectedIssues() { - return this.store.selectedIssues.filter(issue => issue.selected); - } + getSelectedIssues() { + return this.store.selectedIssues.filter(issue => issue.selected); + } - addSelectedIssue(issue) { - const index = this.selectedIssueIndex(issue); + addSelectedIssue(issue) { + const index = this.selectedIssueIndex(issue); - if (index === -1) { - this.store.selectedIssues.push(issue); - } + if (index === -1) { + this.store.selectedIssues.push(issue); } + } - removeSelectedIssue(issue, forcePurge = false) { - if (this.store.activeTab === 'all' || forcePurge) { - this.store.selectedIssues = this.store.selectedIssues - .filter(fIssue => fIssue.id !== issue.id); - } + removeSelectedIssue(issue, forcePurge = false) { + if (this.store.activeTab === 'all' || forcePurge) { + this.store.selectedIssues = this.store.selectedIssues + .filter(fIssue => fIssue.id !== issue.id); } + } - purgeUnselectedIssues() { - this.store.selectedIssues.forEach((issue) => { - if (!issue.selected) { - this.removeSelectedIssue(issue, true); - } - }); - } + purgeUnselectedIssues() { + this.store.selectedIssues.forEach((issue) => { + if (!issue.selected) { + this.removeSelectedIssue(issue, true); + } + }); + } - selectedIssueIndex(issue) { - return this.store.selectedIssues.indexOf(issue); - } + selectedIssueIndex(issue) { + return this.store.selectedIssues.indexOf(issue); + } - findSelectedIssue(issue) { - return this.store.selectedIssues - .filter(filteredIssue => filteredIssue.id === issue.id)[0]; - } + findSelectedIssue(issue) { + return this.store.selectedIssues + .filter(filteredIssue => filteredIssue.id === issue.id)[0]; } +} - gl.issueBoards.ModalStore = new ModalStore(); -})(); +gl.issueBoards.ModalStore = new ModalStore(); diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js new file mode 100644 index 00000000000..af8bcdc1794 --- /dev/null +++ b/app/assets/javascripts/branches/branches_delete_modal.js @@ -0,0 +1,36 @@ +const MODAL_SELECTOR = '#modal-delete-branch'; + +class DeleteModal { + constructor() { + this.$modal = $(MODAL_SELECTOR); + this.$toggleBtns = $(`[data-target="${MODAL_SELECTOR}"]`); + this.$branchName = $('.js-branch-name', this.$modal); + this.$confirmInput = $('.js-delete-branch-input', this.$modal); + this.$deleteBtn = $('.js-delete-branch', this.$modal); + this.bindEvents(); + } + + bindEvents() { + this.$toggleBtns.on('click', this.setModalData.bind(this)); + this.$confirmInput.on('input', this.setDeleteDisabled.bind(this)); + } + + setModalData(e) { + this.branchName = e.currentTarget.dataset.branchName || ''; + this.deletePath = e.currentTarget.dataset.deletePath || ''; + this.updateModal(); + } + + setDeleteDisabled(e) { + this.$deleteBtn.attr('disabled', e.currentTarget.value !== this.branchName); + } + + updateModal() { + this.$branchName.text(this.branchName); + this.$confirmInput.val(''); + this.$deleteBtn.attr('href', this.deletePath); + this.$deleteBtn.attr('disabled', true); + } +} + +export default DeleteModal; diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 6efd26ccc37..97f279e4be4 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -1,24 +1,31 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-param-reassign, quotes, yoda, no-else-return, consistent-return, comma-dangle, object-shorthand, prefer-template, one-var, one-var-declaration-per-line, no-unused-vars, max-len, vars-on-top */ +/* eslint-disable func-names, wrap-iife, no-use-before-define, +consistent-return, prefer-rest-params */ /* global Breakpoints */ -var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; -var AUTO_SCROLL_OFFSET = 75; -var DOWN_BUILD_TRACE = '#down-build-trace'; +import { bytesToKiB } from './lib/utils/number_utils'; -window.Build = (function() { +const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; }; +const AUTO_SCROLL_OFFSET = 75; +const DOWN_BUILD_TRACE = '#down-build-trace'; + +window.Build = (function () { Build.timeout = null; Build.state = null; function Build(options) { - options = options || $('.js-build-options').data(); - this.pageUrl = options.pageUrl; - this.buildUrl = options.buildUrl; - this.buildStatus = options.buildStatus; - this.state = options.logState; - this.buildStage = options.buildStage; - this.updateDropdown = bind(this.updateDropdown, this); + this.options = options || $('.js-build-options').data(); + + this.pageUrl = this.options.pageUrl; + this.buildUrl = this.options.buildUrl; + this.buildStatus = this.options.buildStatus; + this.state = this.options.logState; + this.buildStage = this.options.buildStage; this.$document = $(document); + this.logBytes = 0; + + this.updateDropdown = bind(this.updateDropdown, this); + this.$body = $('body'); this.$buildTrace = $('#build-trace'); this.$autoScrollContainer = $('.autoscroll-container'); @@ -29,111 +36,119 @@ window.Build = (function() { this.$scrollTopBtn = $('#scroll-top'); this.$scrollBottomBtn = $('#scroll-bottom'); this.$buildRefreshAnimation = $('.js-build-refresh'); + this.$buildScroll = $('#js-build-scroll'); + this.$truncatedInfo = $('.js-truncated-info'); clearTimeout(Build.timeout); // Init breakpoint checker this.bp = Breakpoints.get(); this.initSidebar(); - this.$buildScroll = $('#js-build-scroll'); - this.populateJobs(this.buildStage); this.updateStageDropdownText(this.buildStage); this.sidebarOnResize(); - this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this)); - this.$document.off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown); + this.$document + .off('click', '.js-sidebar-build-toggle') + .on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this)); + + this.$document + .off('click', '.stage-item') + .on('click', '.stage-item', this.updateDropdown); + this.$document.on('scroll', this.initScrollMonitor.bind(this)); - $(window).off('resize.build').on('resize.build', this.sidebarOnResize.bind(this)); - $('a', this.$buildScroll).off('click.stepTrace').on('click.stepTrace', this.stepTrace); + + $(window) + .off('resize.build') + .on('resize.build', this.sidebarOnResize.bind(this)); + + $('a', this.$buildScroll) + .off('click.stepTrace') + .on('click.stepTrace', this.stepTrace); + this.updateArtifactRemoveDate(); - if ($('#build-trace').length) { - this.getInitialBuildTrace(); - this.initScrollButtonAffix(); - } + this.initScrollButtonAffix(); this.invokeBuildTrace(); } - Build.prototype.initSidebar = function() { + Build.prototype.initSidebar = function () { this.$sidebar = $('.js-build-sidebar'); this.$sidebar.niceScroll(); - this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); - }; - - Build.prototype.location = function() { - return window.location.href.split("#")[0]; + this.$document + .off('click', '.js-sidebar-build-toggle') + .on('click', '.js-sidebar-build-toggle', this.toggleSidebar); }; - Build.prototype.invokeBuildTrace = function() { - var continueRefreshStatuses = ['running', 'pending']; - // Continue to update build trace when build is running or pending - if (continueRefreshStatuses.indexOf(this.buildStatus) !== -1) { - // Check for new build output if user still watching build page - // Only valid for runnig build when output changes during time - Build.timeout = setTimeout((function(_this) { - return function() { - if (_this.location() === _this.pageUrl) { - return _this.getBuildTrace(); - } - }; - })(this), 4000); - } + Build.prototype.invokeBuildTrace = function () { + return this.getBuildTrace(); }; - Build.prototype.getInitialBuildTrace = function() { - var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']; - + Build.prototype.getBuildTrace = function () { return $.ajax({ - url: this.buildUrl, + url: `${this.pageUrl}/trace.json`, dataType: 'json', - success: function(buildData) { - $('.js-build-output').html(buildData.trace_html); - if (window.location.hash === DOWN_BUILD_TRACE) { - $("html,body").scrollTop(this.$buildTrace.height()); + data: { + state: this.state, + }, + success: ((log) => { + const $buildContainer = $('.js-build-output'); + + gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`); + + if (log.state) { + this.state = log.state; + } + + if (log.append) { + $buildContainer.append(log.html); + this.logBytes += log.size; + } else { + $buildContainer.html(log.html); + this.logBytes = log.size; } - if (removeRefreshStatuses.indexOf(buildData.status) !== -1) { + + // if the incremental sum of logBytes we received is less than the total + // we need to show a message warning the user about that. + if (this.logBytes < log.total) { + // size is in bytes, we need to calculate KiB + const size = bytesToKiB(this.logBytes); + $('.js-truncated-info-size').html(`${size}`); + this.$truncatedInfo.removeClass('hidden'); + this.initAffixTruncatedInfo(); + } else { + this.$truncatedInfo.addClass('hidden'); + } + + this.checkAutoscroll(); + + if (!log.complete) { + Build.timeout = setTimeout(() => { + this.invokeBuildTrace(); + }, 4000); + } else { this.$buildRefreshAnimation.remove(); - return this.initScrollMonitor(); } - }.bind(this) - }); - }; - Build.prototype.getBuildTrace = function() { - return $.ajax({ - url: this.pageUrl + "/trace.json?state=" + (encodeURIComponent(this.state)), - dataType: "json", - success: (function(_this) { - return function(log) { - var pageUrl; - - if (log.state) { - _this.state = log.state; - } - _this.invokeBuildTrace(); - if (log.status === "running") { - if (log.append) { - $('.js-build-output').append(log.html); - } else { - $('.js-build-output').html(log.html); - } - return _this.checkAutoscroll(); - } else if (log.status !== _this.buildStatus) { - pageUrl = _this.pageUrl; - if (_this.$autoScrollStatus.data('state') === 'enabled') { - pageUrl += DOWN_BUILD_TRACE; - } - - return gl.utils.visitUrl(pageUrl); + if (log.status !== this.buildStatus) { + let pageUrl = this.pageUrl; + + if (this.$autoScrollStatus.data('state') === 'enabled') { + pageUrl += DOWN_BUILD_TRACE; } - }; - })(this) + + gl.utils.visitUrl(pageUrl); + } + }), + error: () => { + this.$buildRefreshAnimation.remove(); + return this.initScrollMonitor(); + }, }); }; - Build.prototype.checkAutoscroll = function() { - if (this.$autoScrollStatus.data("state") === "enabled") { - return $("html,body").scrollTop(this.$buildTrace.height()); + Build.prototype.checkAutoscroll = function () { + if (this.$autoScrollStatus.data('state') === 'enabled') { + return $('html,body').scrollTop(this.$buildTrace.height()); } // Handle a situation where user started new build @@ -145,7 +160,7 @@ window.Build = (function() { } }; - Build.prototype.initScrollButtonAffix = function() { + Build.prototype.initScrollButtonAffix = function () { // Hide everything initially this.$scrollTopBtn.hide(); this.$scrollBottomBtn.hide(); @@ -166,15 +181,17 @@ window.Build = (function() { // - Show Top Arrow button // - Show Bottom Arrow button // - Disable Autoscroll and hide indicator (when build is running) - Build.prototype.initScrollMonitor = function() { - if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + Build.prototype.initScrollMonitor = function () { + if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && + !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { // User is somewhere in middle of Build Log this.$scrollTopBtn.show(); if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed this.$scrollBottomBtn.show(); - } else if (this.$buildRefreshAnimation.is(':visible') && !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { + } else if (this.$buildRefreshAnimation.is(':visible') && + !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { this.$scrollBottomBtn.show(); } else { this.$scrollBottomBtn.hide(); @@ -185,10 +202,13 @@ window.Build = (function() { this.$autoScrollContainer.hide(); this.$autoScrollStatusText.removeClass('animate'); } else { - this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); + this.$autoScrollContainer.css({ + top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET, + }).show(); this.$autoScrollStatusText.addClass('animate'); } - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && + !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { // User is at Top of Build Log this.$scrollTopBtn.hide(); @@ -196,17 +216,22 @@ window.Build = (function() { this.$autoScrollContainer.hide(); this.$autoScrollStatusText.removeClass('animate'); - } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) || - (this.$buildRefreshAnimation.is(':visible') && gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { + } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && + gl.utils.isInViewport(this.$downBuildTrace.get(0))) || + (this.$buildRefreshAnimation.is(':visible') && + gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { // User is at Bottom of Build Log this.$scrollTopBtn.show(); this.$scrollBottomBtn.hide(); // Show and Reposition Autoscroll Status Indicator - this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); + this.$autoScrollContainer.css({ + top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET, + }).show(); this.$autoScrollStatusText.addClass('animate'); - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && + gl.utils.isInViewport(this.$downBuildTrace.get(0))) { // Build Log height is small this.$scrollTopBtn.hide(); @@ -217,65 +242,81 @@ window.Build = (function() { this.$autoScrollStatusText.removeClass('animate'); } - if (this.buildStatus === "running" || this.buildStatus === "pending") { + if (this.buildStatus === 'running' || this.buildStatus === 'pending') { // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise. - this.$autoScrollStatus.data("state", gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled'); + this.$autoScrollStatus.data( + 'state', + gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled', + ); } }; - Build.prototype.shouldHideSidebarForViewport = function() { - var bootstrapBreakpoint; - bootstrapBreakpoint = this.bp.getBreakpointSize(); + Build.prototype.shouldHideSidebarForViewport = function () { + const bootstrapBreakpoint = this.bp.getBreakpointSize(); return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; }; - Build.prototype.toggleSidebar = function(shouldHide) { - var shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + Build.prototype.toggleSidebar = function (shouldHide) { + const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + this.$buildScroll.toggleClass('sidebar-expanded', shouldShow) .toggleClass('sidebar-collapsed', shouldHide); + this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow) + .toggleClass('sidebar-collapsed', shouldHide); this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow) .toggleClass('right-sidebar-collapsed', shouldHide); }; - Build.prototype.sidebarOnResize = function() { + Build.prototype.sidebarOnResize = function () { this.toggleSidebar(this.shouldHideSidebarForViewport()); }; - Build.prototype.sidebarOnClick = function() { + Build.prototype.sidebarOnClick = function () { if (this.shouldHideSidebarForViewport()) this.toggleSidebar(); }; - Build.prototype.updateArtifactRemoveDate = function() { - var $date, date; - $date = $('.js-artifacts-remove'); + Build.prototype.updateArtifactRemoveDate = function () { + const $date = $('.js-artifacts-remove'); if ($date.length) { - date = $date.text(); - return $date.text(gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' ')); + const date = $date.text(); + return $date.text( + gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '), + ); } }; - Build.prototype.populateJobs = function(stage) { + Build.prototype.populateJobs = function (stage) { $('.build-job').hide(); - $('.build-job[data-stage="' + stage + '"]').show(); + $(`.build-job[data-stage="${stage}"]`).show(); }; - Build.prototype.updateStageDropdownText = function(stage) { + Build.prototype.updateStageDropdownText = function (stage) { $('.stage-selection').text(stage); }; - Build.prototype.updateDropdown = function(e) { + Build.prototype.updateDropdown = function (e) { e.preventDefault(); - var stage = e.currentTarget.text; + const stage = e.currentTarget.text; this.updateStageDropdownText(stage); this.populateJobs(stage); }; - Build.prototype.stepTrace = function(e) { - var $currentTarget; + Build.prototype.stepTrace = function (e) { e.preventDefault(); - $currentTarget = $(e.currentTarget); + + const $currentTarget = $(e.currentTarget); $.scrollTo($currentTarget.attr('href'), { - offset: 0 + offset: 0, + }); + }; + + Build.prototype.initAffixTruncatedInfo = function () { + const offsetTop = this.$buildTrace.offset().top; + + this.$truncatedInfo.affix({ + offset: { + top: offsetTop, + }, }); }; diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js new file mode 100644 index 00000000000..df0ba86198c --- /dev/null +++ b/app/assets/javascripts/comment_type_toggle.js @@ -0,0 +1,60 @@ +import DropLab from './droplab/drop_lab'; +import InputSetter from './droplab/plugins/input_setter'; + +class CommentTypeToggle { + constructor(opts = {}) { + this.dropdownTrigger = opts.dropdownTrigger; + this.dropdownList = opts.dropdownList; + this.noteTypeInput = opts.noteTypeInput; + this.submitButton = opts.submitButton; + this.closeButton = opts.closeButton; + this.reopenButton = opts.reopenButton; + } + + initDroplab() { + this.droplab = new DropLab(); + + const config = this.setConfig(); + + this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config); + } + + setConfig() { + const config = { + InputSetter: [{ + input: this.noteTypeInput, + valueAttribute: 'data-value', + }, + { + input: this.submitButton, + valueAttribute: 'data-submit-text', + }], + }; + + if (this.closeButton) { + config.InputSetter.push({ + input: this.closeButton, + valueAttribute: 'data-close-text', + }, { + input: this.closeButton, + valueAttribute: 'data-close-text', + inputAttribute: 'data-alternative-text', + }); + } + + if (this.reopenButton) { + config.InputSetter.push({ + input: this.reopenButton, + valueAttribute: 'data-reopen-text', + }, { + input: this.reopenButton, + valueAttribute: 'data-reopen-text', + inputAttribute: 'data-alternative-text', + }); + } + + return config; + } +} + +export default CommentTypeToggle; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index a92e068ca5a..86d99dd87da 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -8,25 +8,22 @@ Vue.use(VueResource); /** * Commits View > Pipelines Tab > Pipelines Table. - * Merge Request View > Pipelines Tab > Pipelines Table. * * Renders Pipelines table in pipelines tab in the commits show view. - * Renders Pipelines table in pipelines tab in the merge request show view. */ +// export for use in merge_request_tabs.js (TODO: remove this hack) +window.gl = window.gl || {}; +window.gl.CommitPipelinesTable = CommitPipelinesTable; + $(() => { - window.gl = window.gl || {}; gl.commits = gl.commits || {}; gl.commits.pipelines = gl.commits.pipelines || {}; - if (gl.commits.PipelinesTableBundle) { - gl.commits.PipelinesTableBundle.$destroy(true); - } - const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); - gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable(); if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) { - gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl); + gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable().$mount(); + pipelineTableViewEl.appendChild(gl.commits.pipelines.PipelinesTableBundle.$el); } }); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index 4d5a857d705..98698143d22 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -1,12 +1,15 @@ import Vue from 'vue'; -import PipelinesTableComponent from '../../vue_shared/components/pipelines_table'; -import PipelinesService from '../../vue_pipelines_index/services/pipelines_service'; -import PipelineStore from '../../vue_pipelines_index/stores/pipelines_store'; -import eventHub from '../../vue_pipelines_index/event_hub'; -import EmptyState from '../../vue_pipelines_index/components/empty_state'; -import ErrorState from '../../vue_pipelines_index/components/error_state'; +import Visibility from 'visibilityjs'; +import pipelinesTableComponent from '../../vue_shared/components/pipelines_table'; +import PipelinesService from '../../pipelines/services/pipelines_service'; +import PipelineStore from '../../pipelines/stores/pipelines_store'; +import eventHub from '../../pipelines/event_hub'; +import emptyState from '../../pipelines/components/empty_state.vue'; +import errorState from '../../pipelines/components/error_state.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import '../../lib/utils/common_utils'; import '../../vue_shared/vue_resource_interceptor'; +import Poll from '../../lib/utils/poll'; /** * @@ -15,15 +18,15 @@ import '../../vue_shared/vue_resource_interceptor'; * We need a store to store the received environemnts. * We need a service to communicate with the server. * - * Necessary SVG in the table are provided as props. This should be refactored - * as soon as we have Webpack and can load them directly into JS files. */ export default Vue.component('pipelines-table', { + components: { - 'pipelines-table-component': PipelinesTableComponent, - 'error-state': ErrorState, - 'empty-state': EmptyState, + pipelinesTableComponent, + errorState, + emptyState, + loadingIcon, }, /** @@ -42,6 +45,9 @@ export default Vue.component('pipelines-table', { state: store.state, isLoading: false, hasError: false, + isMakingRequest: false, + updateGraphDropdown: false, + hasMadeRequest: false, }; }, @@ -50,8 +56,22 @@ export default Vue.component('pipelines-table', { return this.hasError && !this.isLoading; }, + /** + * Empty state is only rendered if after the first request we receive no pipelines. + * + * @return {Boolean} + */ shouldRenderEmptyState() { - return !this.state.pipelines.length && !this.isLoading; + return !this.state.pipelines.length && + !this.isLoading && + this.hasMadeRequest && + !this.hasError; + }, + + shouldRenderTable() { + return !this.isLoading && + this.state.pipelines.length > 0 && + !this.hasError; }, }, @@ -64,48 +84,92 @@ export default Vue.component('pipelines-table', { * */ beforeMount() { - this.endpoint = this.$el.dataset.endpoint; - this.helpPagePath = this.$el.dataset.helpPagePath; - this.service = new PipelinesService(this.endpoint); + const element = document.querySelector('#commit-pipeline-table-view'); - this.fetchPipelines(); + this.endpoint = element.dataset.endpoint; + this.helpPagePath = element.dataset.helpPagePath; + this.service = new PipelinesService(this.endpoint); - eventHub.$on('refreshPipelines', this.fetchPipelines); - }, + this.poll = new Poll({ + resource: this.service, + method: 'getPipelines', + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: this.setIsMakingRequest, + }); - beforeUpdate() { - if (this.state.pipelines.length && this.$children) { - this.store.startTimeAgoLoops.call(this, Vue); + if (!Visibility.hidden()) { + this.isLoading = true; + this.poll.makeRequest(); + } else { + // If tab is not visible we need to make the first request so we don't show the empty + // state without knowing if there are any pipelines + this.fetchPipelines(); } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + + eventHub.$on('refreshPipelines', this.fetchPipelines); }, beforeDestroyed() { eventHub.$off('refreshPipelines'); }, + destroyed() { + this.poll.stop(); + }, + methods: { fetchPipelines() { this.isLoading = true; + return this.service.getPipelines() - .then(response => response.json()) - .then((json) => { - // depending of the endpoint the response can either bring a `pipelines` key or not. - const pipelines = json.pipelines || json; - this.store.storePipelines(pipelines); - this.isLoading = false; - }) - .catch(() => { - this.hasError = true; - this.isLoading = false; - }); + .then(response => this.successCallback(response)) + .catch(() => this.errorCallback()); + }, + + successCallback(resp) { + const response = resp.json(); + + this.hasMadeRequest = true; + + // depending of the endpoint the response can either bring a `pipelines` key or not. + const pipelines = response.pipelines || response; + this.store.storePipelines(pipelines); + this.isLoading = false; + this.updateGraphDropdown = true; + }, + + errorCallback() { + this.hasError = true; + this.isLoading = false; + this.updateGraphDropdown = false; + }, + + setIsMakingRequest(isMakingRequest) { + this.isMakingRequest = isMakingRequest; + + if (isMakingRequest) { + this.updateGraphDropdown = false; + } }, }, template: ` <div class="content-list pipelines"> - <div class="realtime-loading" v-if="isLoading"> - <i class="fa fa-spinner fa-spin"></i> - </div> + + <loading-icon + label="Loading pipelines" + size="3" + v-if="isLoading" + /> <empty-state v-if="shouldRenderEmptyState" @@ -113,11 +177,14 @@ export default Vue.component('pipelines-table', { <error-state v-if="shouldRenderErrorState" /> - <div class="table-holder" - v-if="!isLoading && state.pipelines.length > 0"> + <div + class="table-holder" + v-if="shouldRenderTable"> <pipelines-table-component :pipelines="state.pipelines" - :service="service" /> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </div> </div> `, diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index 3253eebd9b5..cb054a2a197 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -1,6 +1,7 @@ // ECMAScript polyfills import 'core-js/fn/array/find'; import 'core-js/fn/array/from'; +import 'core-js/fn/array/includes'; import 'core-js/fn/object/assign'; import 'core-js/fn/promise'; import 'core-js/fn/string/code-point-at'; diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js index 570799c030e..459cdd53f9b 100644 --- a/app/assets/javascripts/copy_as_gfm.js +++ b/app/assets/javascripts/copy_as_gfm.js @@ -1,6 +1,6 @@ /* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */ -require('./lib/utils/common_utils'); +import './lib/utils/common_utils'; const gfmRules = { // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js index 6dbec50b890..ab9a8e43dd1 100644 --- a/app/assets/javascripts/copy_to_clipboard.js +++ b/app/assets/javascripts/copy_to_clipboard.js @@ -38,9 +38,35 @@ showTooltip = function(target, title) { }; $(function() { - var clipboard; - - clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); + const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); clipboard.on('success', genericSuccess); - return clipboard.on('error', genericError); + clipboard.on('error', genericError); + + // This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM. + // The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and `gfm` keys into the `data-clipboard-text` + // attribute that ClipboardJS reads from. + // When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value + // to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command, + // this code intercepts the copy command/event at the last minute to deconstruct this JSON hash and set the + // `text/plain` and `text/x-gfm` copy data types to the intended values. + $(document).on('copy', 'body > textarea[readonly]', function(e) { + const clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; + + const text = e.target.value; + + let json; + try { + json = JSON.parse(text); + } catch (ex) { + return; + } + + if (!json.text || !json.gfm) return; + + e.preventDefault(); + + clipboardData.setData('text/plain', json.text); + clipboardData.setData('text/x-gfm', json.gfm); + }); }); diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index 121d64db789..907b468e576 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -1,5 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */ -/* global Api */ +import Api from './api'; class CreateLabelDropdown { constructor ($el, namespacePath, projectPath) { diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js new file mode 100644 index 00000000000..ff2f2c81971 --- /dev/null +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -0,0 +1,193 @@ +/* eslint-disable no-new */ +/* global Flash */ +import DropLab from './droplab/drop_lab'; +import ISetter from './droplab/plugins/input_setter'; + +// Todo: Remove this when fixing issue in input_setter plugin +const InputSetter = Object.assign({}, ISetter); + +const CREATE_MERGE_REQUEST = 'create-mr'; +const CREATE_BRANCH = 'create-branch'; + +export default class CreateMergeRequestDropdown { + constructor(wrapperEl) { + this.wrapperEl = wrapperEl; + this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request'); + this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); + this.availableButton = this.wrapperEl.querySelector('.available'); + this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); + this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa'); + this.unavailableButtonText = this.unavailableButton.querySelector('.text'); + + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.canCreatePath = this.wrapperEl.dataset.canCreatePath; + this.createMrPath = this.wrapperEl.dataset.createMrPath; + this.droplabInitialized = false; + this.isCreatingMergeRequest = false; + this.mergeRequestCreated = false; + this.isCreatingBranch = false; + this.branchCreated = false; + + this.init(); + } + + init() { + this.checkAbilityToCreateBranch(); + } + + available() { + this.availableButton.classList.remove('hide'); + this.unavailableButton.classList.add('hide'); + } + + unavailable() { + this.availableButton.classList.add('hide'); + this.unavailableButton.classList.remove('hide'); + } + + enable() { + this.createMergeRequestButton.classList.remove('disabled'); + this.createMergeRequestButton.removeAttribute('disabled'); + + this.dropdownToggle.classList.remove('disabled'); + this.dropdownToggle.removeAttribute('disabled'); + } + + disable() { + this.createMergeRequestButton.classList.add('disabled'); + this.createMergeRequestButton.setAttribute('disabled', 'disabled'); + + this.dropdownToggle.classList.add('disabled'); + this.dropdownToggle.setAttribute('disabled', 'disabled'); + } + + hide() { + this.wrapperEl.classList.add('hide'); + } + + setUnavailableButtonState(isLoading = true) { + if (isLoading) { + this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin'); + this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = 'Checking branch availability…'; + } else { + this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin'); + this.unavailableButtonArrow.classList.add('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = 'New branch unavailable'; + } + } + + checkAbilityToCreateBranch() { + return $.ajax({ + type: 'GET', + dataType: 'json', + url: this.canCreatePath, + beforeSend: () => this.setUnavailableButtonState(), + }) + .done((data) => { + this.setUnavailableButtonState(false); + + if (data.can_create_branch) { + this.available(); + this.enable(); + + if (!this.droplabInitialized) { + this.droplabInitialized = true; + this.initDroplab(); + this.bindEvents(); + } + } else if (data.has_related_branch) { + this.hide(); + } + }).fail(() => { + this.unavailable(); + this.disable(); + new Flash('Failed to check if a new branch can be created.'); + }); + } + + initDroplab() { + this.droplab = new DropLab(); + + this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter], + this.getDroplabConfig()); + } + + getDroplabConfig() { + return { + InputSetter: [{ + input: this.createMergeRequestButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, { + input: this.createMergeRequestButton, + valueAttribute: 'data-text', + }], + }; + } + + bindEvents() { + this.createMergeRequestButton + .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + } + + isBusy() { + return this.isCreatingMergeRequest || + this.mergeRequestCreated || + this.isCreatingBranch || + this.branchCreated; + } + + onClickCreateMergeRequestButton(e) { + let xhr = null; + e.preventDefault(); + + if (this.isBusy()) { + return; + } + + if (e.target.dataset.action === CREATE_MERGE_REQUEST) { + xhr = this.createMergeRequest(); + } else if (e.target.dataset.action === CREATE_BRANCH) { + xhr = this.createBranch(); + } + + xhr.fail(() => { + this.isCreatingMergeRequest = false; + this.isCreatingBranch = false; + }); + + xhr.always(() => this.enable()); + + this.disable(); + } + + createMergeRequest() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createMrPath, + beforeSend: () => (this.isCreatingMergeRequest = true), + }) + .done((data) => { + this.mergeRequestCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create Merge Request. Please try again.')); + } + + createBranch() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createBranchPath, + beforeSend: () => (this.isCreatingBranch = true), + }) + .done((data) => { + this.branchCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); + } +} diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js index abe48572347..8d3d34f836f 100644 --- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js +++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.js @@ -9,9 +9,9 @@ export default { <span v-if="count === 50" class="events-info pull-right"> <i class="fa fa-warning has-tooltip" aria-hidden="true" - title="Limited to showing 50 events at most" + :title="n__('Limited to showing %d event at most', 'Limited to showing %d events at most', 50)" data-placement="top"></i> - Showing 50 events + {{ n__('Showing %d event', 'Showing %d events', 50) }} </span> `, }; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js index 3f419a96ff9..7c32a38fbe7 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -1,47 +1,51 @@ /* eslint-disable no-param-reassign */ import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageCodeComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="mergeRequest in items" class="stage-event-item"> - <div class="item-details"> - <img class="avatar" :src="mergeRequest.author.avatarUrl"> - <h5 class="item-title merge-merquest-title"> - <a :href="mergeRequest.url"> - {{ mergeRequest.title }} - </a> - </h5> - <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> - · - <span> - Opened - <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> - </span> - <span> - by - <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> - </span> - </div> - <div class="item-time"> - <total-time :time="mergeRequest.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageCodeComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + components: { + userAvatarImage, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="mergeRequest in items" class="stage-event-item"> + <div class="item-details"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/> + <h5 class="item-title merge-merquest-title"> + <a :href="mergeRequest.url"> + {{ mergeRequest.title }} + </a> + </h5> + <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> + · + <span> + {{ s__('OpenedNDaysAgo|Opened') }} + <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> + </span> + <span> + {{ s__('ByAuthor|by') }} + <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> + </span> + </div> + <div class="item-time"> + <total-time :time="mergeRequest.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js index 7ffa38edd9e..5f4a0ac8590 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -1,49 +1,52 @@ /* eslint-disable no-param-reassign */ - import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageIssueComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="issue in items" class="stage-event-item"> - <div class="item-details"> - <img class="avatar" :src="issue.author.avatarUrl"> - <h5 class="item-title issue-title"> - <a class="issue-title" :href="issue.url"> - {{ issue.title }} - </a> - </h5> - <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> - · - <span> - Opened - <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> - </span> - <span> - by - <a :href="issue.author.webUrl" class="issue-author-link"> - {{ issue.author.name }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="issue.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageIssueComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + components: { + userAvatarImage, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="issue in items" class="stage-event-item"> + <div class="item-details"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="issue.author.avatarUrl"/> + <h5 class="item-title issue-title"> + <a class="issue-title" :href="issue.url"> + {{ issue.title }} + </a> + </h5> + <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> + · + <span> + {{ s__('OpenedNDaysAgo|Opened') }} + <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> + </span> + <span> + {{ s__('ByAuthor|by') }} + <a :href="issue.author.webUrl" class="issue-author-link"> + {{ issue.author.name }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="issue.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js index d736c8b0c28..11fee5410d9 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -1,51 +1,53 @@ /* eslint-disable no-param-reassign */ import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; import iconCommit from '../svg/icon_commit.svg'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StagePlanComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - - data() { - return { iconCommit }; - }, - - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="commit in items" class="stage-event-item"> - <div class="item-details item-conmmit-component"> - <img class="avatar" :src="commit.author.avatarUrl"> - <h5 class="item-title commit-title"> - <a :href="commit.commitUrl"> - {{ commit.title }} - </a> - </h5> - <span> - First - <span class="commit-icon">${iconCommit}</span> - <a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a> - pushed by - <a :href="commit.author.webUrl" class="commit-author-link"> - {{ commit.author.name }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="commit.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StagePlanComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + components: { + userAvatarImage, + }, + data() { + return { iconCommit }; + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="commit in items" class="stage-event-item"> + <div class="item-details item-conmmit-component"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="commit.author.avatarUrl"/> + <h5 class="item-title commit-title"> + <a :href="commit.commitUrl"> + {{ commit.title }} + </a> + </h5> + <span> + {{ s__('FirstPushedBy|First') }} + <span class="commit-icon">${iconCommit}</span> + <a :href="commit.commitUrl" class="commit-hash-link commit-sha">{{ commit.shortSha }}</a> + {{ s__('FirstPushedBy|pushed by') }} + <a :href="commit.author.webUrl" class="commit-author-link"> + {{ commit.author.name }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="commit.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js index 698a79ca68c..b7ba9360f70 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -1,49 +1,52 @@ /* eslint-disable no-param-reassign */ - import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageProductionComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="issue in items" class="stage-event-item"> - <div class="item-details"> - <img class="avatar" :src="issue.author.avatarUrl"> - <h5 class="item-title issue-title"> - <a class="issue-title" :href="issue.url"> - {{ issue.title }} - </a> - </h5> - <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> - · - <span> - Opened - <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> - </span> - <span> - by - <a :href="issue.author.webUrl" class="issue-author-link"> - {{ issue.author.name }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="issue.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageProductionComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + components: { + userAvatarImage, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="issue in items" class="stage-event-item"> + <div class="item-details"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="issue.author.avatarUrl"/> + <h5 class="item-title issue-title"> + <a class="issue-title" :href="issue.url"> + {{ issue.title }} + </a> + </h5> + <a :href="issue.url" class="issue-link">#{{ issue.iid }}</a> + · + <span> + {{ s__('OpenedNDaysAgo|Opened') }} + <a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a> + </span> + <span> + {{ s__('ByAuthor|by') }} + <a :href="issue.author.webUrl" class="issue-author-link"> + {{ issue.author.name }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="issue.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js index e63c41f2a57..f41a0d0e4ff 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -1,59 +1,62 @@ /* eslint-disable no-param-reassign */ - import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageReviewComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="mergeRequest in items" class="stage-event-item"> - <div class="item-details"> - <img class="avatar" :src="mergeRequest.author.avatarUrl"> - <h5 class="item-title merge-merquest-title"> - <a :href="mergeRequest.url"> - {{ mergeRequest.title }} - </a> - </h5> - <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> - · - <span> - Opened - <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> +global.cycleAnalytics.StageReviewComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + components: { + userAvatarImage, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> + </div> + <ul class="stage-event-list"> + <li v-for="mergeRequest in items" class="stage-event-item"> + <div class="item-details"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="mergeRequest.author.avatarUrl"/> + <h5 class="item-title merge-merquest-title"> + <a :href="mergeRequest.url"> + {{ mergeRequest.title }} + </a> + </h5> + <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> + · + <span> + {{ s__('OpenedNDaysAgo|Opened') }} + <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> + </span> + <span> + {{ s__('ByAuthor|by') }} + <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> + </span> + <template v-if="mergeRequest.state === 'closed'"> + <span class="merge-request-state"> + <i class="fa fa-ban"></i> + {{ mergeRequest.state.toUpperCase() }} </span> - <span> - by - <a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> + </template> + <template v-else> + <span class="merge-request-branch" v-if="mergeRequest.branch"> + <i class= "fa fa-code-fork"></i> + <a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a> </span> - <template v-if="mergeRequest.state === 'closed'"> - <span class="merge-request-state"> - <i class="fa fa-ban"></i> - {{ mergeRequest.state.toUpperCase() }} - </span> - </template> - <template v-else> - <span class="merge-request-branch" v-if="mergeRequest.branch"> - <i class= "fa fa-code-fork"></i> - <a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a> - </span> - </template> - </div> - <div class="item-time"> - <total-time :time="mergeRequest.totalTime"></total-time> - </div> - </li> - </ul> - </div> - `, - }); -})(window.gl || (window.gl = {})); + </template> + </div> + <div class="item-time"> + <total-time :time="mergeRequest.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js index d51f7134e25..d7c906c9d39 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -1,49 +1,53 @@ /* eslint-disable no-param-reassign */ import Vue from 'vue'; +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; import iconBranch from '../svg/icon_branch.svg'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageStagingComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - data() { - return { iconBranch }; - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="build in items" class="stage-event-item item-build-component"> - <div class="item-details"> - <img class="avatar" :src="build.author.avatarUrl"> - <h5 class="item-title"> - <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> - <i class="fa fa-code-fork"></i> - <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> - <span class="icon-branch">${iconBranch}</span> - <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> - </h5> - <span> - <a :href="build.url" class="build-date">{{ build.date }}</a> - by - <a :href="build.author.webUrl" class="issue-author-link"> - {{ build.author.name }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="build.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageStagingComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + data() { + return { iconBranch }; + }, + components: { + userAvatarImage, + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="build in items" class="stage-event-item item-build-component"> + <div class="item-details"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image :img-src="build.author.avatarUrl"/> + <h5 class="item-title"> + <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> + <i class="fa fa-code-fork"></i> + <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a> + <span class="icon-branch">${iconBranch}</span> + <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a> + </h5> + <span> + <a :href="build.url" class="build-date">{{ build.date }}</a> + {{ s__('ByAuthor|by') }} + <a :href="build.author.webUrl" class="issue-author-link"> + {{ build.author.name }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="build.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js index 17ae3a9ddc1..78cc97eea0b 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js +++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js @@ -3,48 +3,47 @@ import Vue from 'vue'; import iconBuildStatus from '../svg/icon_build_status.svg'; import iconBranch from '../svg/icon_branch.svg'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.StageTestComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - data() { - return { iconBuildStatus, iconBranch }; - }, - template: ` - <div> - <div class="events-description"> - {{ stage.description }} - <limit-warning :count="items.length" /> - </div> - <ul class="stage-event-list"> - <li v-for="build in items" class="stage-event-item item-build-component"> - <div class="item-details"> - <h5 class="item-title"> - <span class="icon-build-status">${iconBuildStatus}</span> - <a :href="build.url" class="item-build-name">{{ build.name }}</a> - · - <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> - <i class="fa fa-code-fork"></i> - <a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a> - <span class="icon-branch">${iconBranch}</span> - <a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a> - </h5> - <span> - <a :href="build.url" class="issue-date"> - {{ build.date }} - </a> - </span> - </div> - <div class="item-time"> - <total-time :time="build.totalTime"></total-time> - </div> - </li> - </ul> +global.cycleAnalytics.StageTestComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + data() { + return { iconBuildStatus, iconBranch }; + }, + template: ` + <div> + <div class="events-description"> + {{ stage.description }} + <limit-warning :count="items.length" /> </div> - `, - }); -})(window.gl || (window.gl = {})); + <ul class="stage-event-list"> + <li v-for="build in items" class="stage-event-item item-build-component"> + <div class="item-details"> + <h5 class="item-title"> + <span class="icon-build-status">${iconBuildStatus}</span> + <a :href="build.url" class="item-build-name">{{ build.name }}</a> + · + <a :href="build.url" class="pipeline-id">#{{ build.id }}</a> + <i class="fa fa-code-fork"></i> + <a :href="build.branch.url" class="ref-name">{{ build.branch.name }}</a> + <span class="icon-branch">${iconBranch}</span> + <a :href="build.commitUrl" class="commit-sha">{{ build.shortSha }}</a> + </h5> + <span> + <a :href="build.url" class="issue-date"> + {{ build.date }} + </a> + </span> + </div> + <div class="item-time"> + <total-time :time="build.totalTime"></total-time> + </div> + </li> + </ul> + </div> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js index b4442ea5566..d5e6167b2a8 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -2,25 +2,24 @@ import Vue from 'vue'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - global.cycleAnalytics.TotalTimeComponent = Vue.extend({ - props: { - time: Object, - }, - template: ` - <span class="total-time"> - <template v-if="Object.keys(time).length"> - <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template> - <template v-if="time.hours">{{ time.hours }} <span>hr</span></template> - <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template> - <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> - </template> - <template v-else> - -- - </template> - </span> - `, - }); -})(window.gl || (window.gl = {})); +global.cycleAnalytics.TotalTimeComponent = Vue.extend({ + props: { + time: Object, + }, + template: ` + <span class="total-time"> + <template v-if="Object.keys(time).length"> + <template v-if="time.days">{{ time.days }} <span>{{ n__('day', 'days', time.days) }}</span></template> + <template v-if="time.hours">{{ time.hours }} <span>{{ n__('Time|hr', 'Time|hrs', time.hours) }}</span></template> + <template v-if="time.mins && !time.days">{{ time.mins }} <span>{{ n__('Time|min', 'Time|mins', time.mins) }}</span></template> + <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>{{ s__('Time|s') }}</span></template> + </template> + <template v-else> + -- + </template> + </span> + `, +}); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index b099b39e58f..44791a93936 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -2,19 +2,20 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; +import Translate from '../vue_shared/translate'; import LimitWarningComponent from './components/limit_warning_component'; +import './components/stage_code_component'; +import './components/stage_issue_component'; +import './components/stage_plan_component'; +import './components/stage_production_component'; +import './components/stage_review_component'; +import './components/stage_staging_component'; +import './components/stage_test_component'; +import './components/total_time_component'; +import './cycle_analytics_service'; +import './cycle_analytics_store'; -require('./components/stage_code_component'); -require('./components/stage_issue_component'); -require('./components/stage_plan_component'); -require('./components/stage_production_component'); -require('./components/stage_review_component'); -require('./components/stage_staging_component'); -require('./components/stage_test_component'); -require('./components/total_time_component'); -require('./cycle_analytics_service'); -require('./cycle_analytics_store'); -require('./default_event_objects'); +Vue.use(Translate); $(() => { const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; @@ -125,7 +126,7 @@ $(() => { }, dismissOverviewDialog() { this.isOverviewDialogDismissed = true; - Cookies.set(OVERVIEW_DIALOG_COOKIE, '1'); + Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 }); }, }, }); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js index 9f74b14c4b9..6504d7db2f2 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -1,41 +1,41 @@ /* eslint-disable no-param-reassign */ -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - class CycleAnalyticsService { - constructor(options) { - this.requestPath = options.requestPath; - } +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - fetchCycleAnalyticsData(options) { - options = options || { startDate: 30 }; - - return $.ajax({ - url: this.requestPath, - method: 'GET', - dataType: 'json', - contentType: 'application/json', - data: { - cycle_analytics: { - start_date: options.startDate, - }, - }, - }); - } +class CycleAnalyticsService { + constructor(options) { + this.requestPath = options.requestPath; + } - fetchStageData(options) { - const { - stage, - startDate, - } = options; + fetchCycleAnalyticsData(options) { + options = options || { startDate: 30 }; - return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { + return $.ajax({ + url: this.requestPath, + method: 'GET', + dataType: 'json', + contentType: 'application/json', + data: { cycle_analytics: { - start_date: startDate, + start_date: options.startDate, }, - }); - } + }, + }); + } + + fetchStageData(options) { + const { + stage, + startDate, + } = options; + + return $.get(`${this.requestPath}/events/${stage.name}.json`, { + cycle_analytics: { + start_date: startDate, + }, + }); } +} - global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService; -})(window.gl || (window.gl = {})); +global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 7ae9de7297c..991f8c1f6fd 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -1,104 +1,104 @@ /* eslint-disable no-param-reassign */ -require('../lib/utils/text_utility'); -const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); +import { __ } from '../locale'; +import '../lib/utils/text_utility'; +import DEFAULT_EVENT_OBJECTS from './default_event_objects'; -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; +const global = window.gl || (window.gl = {}); +global.cycleAnalytics = global.cycleAnalytics || {}; - const EMPTY_STAGE_TEXTS = { - issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', - }; +const EMPTY_STAGE_TEXTS = { + issue: __('The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.'), + plan: __('The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.'), + code: __('The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.'), + test: __('The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.'), + review: __('The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.'), + staging: __('The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.'), + production: __('The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.'), +}; - global.cycleAnalytics.CycleAnalyticsStore = { - state: { - summary: '', - stats: '', - analytics: '', - events: [], - stages: [], - }, - setCycleAnalyticsData(data) { - this.state = Object.assign(this.state, this.decorateData(data)); - }, - decorateData(data) { - const newData = {}; +global.cycleAnalytics.CycleAnalyticsStore = { + state: { + summary: '', + stats: '', + analytics: '', + events: [], + stages: [], + }, + setCycleAnalyticsData(data) { + this.state = Object.assign(this.state, this.decorateData(data)); + }, + decorateData(data) { + const newData = {}; - newData.stages = data.stats || []; - newData.summary = data.summary || []; + newData.stages = data.stats || []; + newData.summary = data.summary || []; - newData.summary.forEach((item) => { - item.value = item.value || '-'; - }); + newData.summary.forEach((item) => { + item.value = item.value || '-'; + }); - newData.stages.forEach((item) => { - const stageSlug = gl.text.dasherize(item.title.toLowerCase()); - item.active = false; - item.isUserAllowed = data.permissions[stageSlug]; - item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; - item.component = `stage-${stageSlug}-component`; - item.slug = stageSlug; - }); - newData.analytics = data; - return newData; - }, - setLoadingState(state) { - this.state.isLoading = state; - }, - setErrorState(state) { - this.state.hasError = state; - }, - deactivateAllStages() { - this.state.stages.forEach((stage) => { - stage.active = false; - }); - }, - setActiveStage(stage) { - this.deactivateAllStages(); - stage.active = true; - }, - setStageEvents(events, stage) { - this.state.events = this.decorateEvents(events, stage); - }, - decorateEvents(events, stage) { - const newEvents = []; + newData.stages.forEach((item) => { + const stageSlug = gl.text.dasherize(item.name.toLowerCase()); + item.active = false; + item.isUserAllowed = data.permissions[stageSlug]; + item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; + item.component = `stage-${stageSlug}-component`; + item.slug = stageSlug; + }); + newData.analytics = data; + return newData; + }, + setLoadingState(state) { + this.state.isLoading = state; + }, + setErrorState(state) { + this.state.hasError = state; + }, + deactivateAllStages() { + this.state.stages.forEach((stage) => { + stage.active = false; + }); + }, + setActiveStage(stage) { + this.deactivateAllStages(); + stage.active = true; + }, + setStageEvents(events, stage) { + this.state.events = this.decorateEvents(events, stage); + }, + decorateEvents(events, stage) { + const newEvents = []; - events.forEach((item) => { - if (!item) return; + events.forEach((item) => { + if (!item) return; - const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); + const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); - eventItem.totalTime = eventItem.total_time; + eventItem.totalTime = eventItem.total_time; - if (eventItem.author) { - eventItem.author.webUrl = eventItem.author.web_url; - eventItem.author.avatarUrl = eventItem.author.avatar_url; - } + if (eventItem.author) { + eventItem.author.webUrl = eventItem.author.web_url; + eventItem.author.avatarUrl = eventItem.author.avatar_url; + } - if (eventItem.created_at) eventItem.createdAt = eventItem.created_at; - if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha; - if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url; + if (eventItem.created_at) eventItem.createdAt = eventItem.created_at; + if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha; + if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url; - delete eventItem.author.web_url; - delete eventItem.author.avatar_url; - delete eventItem.total_time; - delete eventItem.created_at; - delete eventItem.short_sha; - delete eventItem.commit_url; + delete eventItem.author.web_url; + delete eventItem.author.avatar_url; + delete eventItem.total_time; + delete eventItem.created_at; + delete eventItem.short_sha; + delete eventItem.commit_url; - newEvents.push(eventItem); - }); + newEvents.push(eventItem); + }); - return newEvents; - }, - currentActiveStage() { - return this.state.stages.find(stage => stage.active); - }, - }; -})(window.gl || (window.gl = {})); + return newEvents; + }, + currentActiveStage() { + return this.state.stages.find(stage => stage.active); + }, +}; diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js b/app/assets/javascripts/cycle_analytics/default_event_objects.js index cfaf9835bf8..57f9019d2f8 100644 --- a/app/assets/javascripts/cycle_analytics/default_event_objects.js +++ b/app/assets/javascripts/cycle_analytics/default_event_objects.js @@ -1,4 +1,4 @@ -module.exports = { +export default { issue: { created_at: '', url: '', diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue new file mode 100644 index 00000000000..3f993213dd0 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -0,0 +1,55 @@ +<script> + import eventHub from '../eventhub'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + + export default { + data() { + return { + isLoading: false, + }; + }, + props: { + deployKey: { + type: Object, + required: true, + }, + type: { + type: String, + required: true, + }, + btnCssClass: { + type: String, + required: false, + default: 'btn-default', + }, + }, + + components: { + loadingIcon, + }, + + methods: { + doAction() { + this.isLoading = true; + + eventHub.$emit(`${this.type}.key`, this.deployKey); + }, + }, + computed: { + text() { + return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`; + }, + }, + }; +</script> + +<template> + <button + class="btn btn-sm prepend-left-10" + :class="[{ disabled: isLoading }, btnCssClass]" + :disabled="isLoading" + @click="doAction"> + {{ text }} + <loading-icon v-if="isLoading" /> + </button> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue new file mode 100644 index 00000000000..5f6eed0c67c --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -0,0 +1,100 @@ +<script> + /* global Flash */ + import eventHub from '../eventhub'; + import DeployKeysService from '../service'; + import DeployKeysStore from '../store'; + import keysPanel from './keys_panel.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + + export default { + data() { + return { + isLoading: false, + store: new DeployKeysStore(), + }; + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + computed: { + hasKeys() { + return Object.keys(this.keys).length; + }, + keys() { + return this.store.keys; + }, + }, + components: { + keysPanel, + loadingIcon, + }, + methods: { + fetchKeys() { + this.isLoading = true; + + this.service.getKeys() + .then((data) => { + this.isLoading = false; + this.store.keys = data; + }) + .catch(() => new Flash('Error getting deploy keys')); + }, + enableKey(deployKey) { + this.service.enableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error enabling deploy key')); + }, + disableKey(deployKey) { + // eslint-disable-next-line no-alert + if (confirm('You are going to remove this deploy key. Are you sure?')) { + this.service.disableKey(deployKey.id) + .then(() => this.fetchKeys()) + .catch(() => new Flash('Error removing deploy key')); + } + }, + }, + created() { + this.service = new DeployKeysService(this.endpoint); + + eventHub.$on('enable.key', this.enableKey); + eventHub.$on('remove.key', this.disableKey); + eventHub.$on('disable.key', this.disableKey); + }, + mounted() { + this.fetchKeys(); + }, + beforeDestroy() { + eventHub.$off('enable.key', this.enableKey); + eventHub.$off('remove.key', this.disableKey); + eventHub.$off('disable.key', this.disableKey); + }, + }; +</script> + +<template> + <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys"> + <loading-icon + v-if="isLoading && !hasKeys" + size="2" + label="Loading deploy keys" + /> + <div v-else-if="hasKeys"> + <keys-panel + title="Enabled deploy keys for this project" + :keys="keys.enabled_keys" + :store="store" /> + <keys-panel + title="Deploy keys from projects you have access to" + :keys="keys.available_project_keys" + :store="store" /> + <keys-panel + v-if="keys.public_keys.length" + title="Public deploy keys available to any project" + :keys="keys.public_keys" + :store="store" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue new file mode 100644 index 00000000000..0a06a481b96 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -0,0 +1,80 @@ +<script> + import actionBtn from './action_btn.vue'; + + export default { + props: { + deployKey: { + type: Object, + required: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + actionBtn, + }, + computed: { + timeagoDate() { + return gl.utils.getTimeago().format(this.deployKey.created_at); + }, + }, + methods: { + isEnabled(id) { + return this.store.findEnabledKey(id) !== undefined; + }, + }, + }; +</script> + +<template> + <div> + <div class="pull-left append-right-10 hidden-xs"> + <i + aria-hidden="true" + class="fa fa-key key-icon"> + </i> + </div> + <div class="deploy-key-content key-list-item-info"> + <strong class="title"> + {{ deployKey.title }} + </strong> + <div class="description"> + {{ deployKey.fingerprint }} + </div> + <div + v-if="deployKey.can_push" + class="write-access-allowed"> + Write access allowed + </div> + </div> + <div class="deploy-key-content prepend-left-default deploy-key-projects"> + <a + v-for="project in deployKey.projects" + class="label deploy-project-label" + :href="project.full_path"> + {{ project.full_name }} + </a> + </div> + <div class="deploy-key-content"> + <span class="key-created-at"> + created {{ timeagoDate }} + </span> + <action-btn + v-if="!isEnabled(deployKey.id)" + :deploy-key="deployKey" + type="enable"/> + <action-btn + v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="remove" /> + <action-btn + v-else + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="disable" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue new file mode 100644 index 00000000000..eccc470578b --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -0,0 +1,52 @@ +<script> + import key from './key.vue'; + + export default { + props: { + title: { + type: String, + required: true, + }, + keys: { + type: Array, + required: true, + }, + showHelpBox: { + type: Boolean, + required: false, + default: true, + }, + store: { + type: Object, + required: true, + }, + }, + components: { + key, + }, + }; +</script> + +<template> + <div class="deploy-keys-panel"> + <h5> + {{ title }} + ({{ keys.length }}) + </h5> + <ul class="well-list" + v-if="keys.length"> + <li + v-for="deployKey in keys" + :key="deployKey.id"> + <key + :deploy-key="deployKey" + :store="store" /> + </li> + </ul> + <div + class="settings-message text-center" + v-else-if="showHelpBox"> + No deploy keys found. Create one with the form above. + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_pipelines_index/event_hub.js b/app/assets/javascripts/deploy_keys/eventhub.js index 0948c2e5352..0948c2e5352 100644 --- a/app/assets/javascripts/vue_pipelines_index/event_hub.js +++ b/app/assets/javascripts/deploy_keys/eventhub.js diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js new file mode 100644 index 00000000000..a5f232f950a --- /dev/null +++ b/app/assets/javascripts/deploy_keys/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import deployKeysApp from './components/app.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: document.getElementById('js-deploy-keys'), + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + }; + }, + components: { + deployKeysApp, + }, + render(createElement) { + return createElement('deploy-keys-app', { + props: { + endpoint: this.endpoint, + }, + }); + }, +})); diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js new file mode 100644 index 00000000000..fe6dbaa9498 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/service/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class DeployKeysService { + constructor(endpoint) { + this.endpoint = endpoint; + + this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, { + enable: { + method: 'PUT', + url: `${this.endpoint}{/id}/enable`, + }, + disable: { + method: 'PUT', + url: `${this.endpoint}{/id}/disable`, + }, + }); + } + + getKeys() { + return this.resource.get() + .then(response => response.json()); + } + + enableKey(id) { + return this.resource.enable({ id }, {}); + } + + disableKey(id) { + return this.resource.disable({ id }, {}); + } +} diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js new file mode 100644 index 00000000000..6210361af26 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/store/index.js @@ -0,0 +1,9 @@ +export default class DeployKeysStore { + constructor() { + this.keys = {}; + } + + findEnabledKey(id) { + return this.keys.enabled_keys.find(key => key.id === id); + } +} diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 88180149715..725ec7b9c70 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -1,6 +1,6 @@ /* eslint-disable class-methods-use-this */ -require('./lib/utils/url_utility'); +import './lib/utils/url_utility'; const UNFOLD_COUNT = 20; let isBound = false; @@ -13,10 +13,6 @@ class Diff { $diffFile.each((index, file) => new gl.ImageFile(file)); - if (this.diffViewType() === 'parallel') { - $('.content-wrapper .container-fluid').removeClass('container-limited'); - } - if (!isBound) { $(document) .on('click', '.js-unfold', this.handleClickUnfold.bind(this)) diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js index fc2f20e3bcb..aed7cac4e62 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -3,59 +3,63 @@ import Vue from 'vue'; -(() => { - const CommentAndResolveBtn = Vue.extend({ - props: { - discussionId: String, +const CommentAndResolveBtn = Vue.extend({ + props: { + discussionId: String, + }, + data() { + return { + textareaIsEmpty: true, + discussion: {}, + }; + }, + computed: { + showButton: function () { + if (this.discussion) { + return this.discussion.isResolvable(); + } else { + return false; + } }, - data() { - return { - textareaIsEmpty: true, - discussion: {}, - }; + isDiscussionResolved: function () { + return this.discussion.isResolved(); }, - computed: { - showButton: function () { - if (this.discussion) { - return this.discussion.isResolvable(); + buttonText: function () { + if (this.isDiscussionResolved) { + if (this.textareaIsEmpty) { + return "Unresolve discussion"; } else { - return false; + return "Comment & unresolve discussion"; } - }, - isDiscussionResolved: function () { - return this.discussion.isResolved(); - }, - buttonText: function () { - if (this.isDiscussionResolved) { - if (this.textareaIsEmpty) { - return "Unresolve discussion"; - } else { - return "Comment & unresolve discussion"; - } + } else { + if (this.textareaIsEmpty) { + return "Resolve discussion"; } else { - if (this.textareaIsEmpty) { - return "Resolve discussion"; - } else { - return "Comment & resolve discussion"; - } + return "Comment & resolve discussion"; } } - }, - created() { + } + }, + created() { + if (this.discussionId) { this.discussion = CommentsStore.state[this.discussionId]; - }, - mounted: function () { - const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); + } + }, + mounted: function () { + if (!this.discussionId) return; + + const $textarea = $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`); + this.textareaIsEmpty = $textarea.val() === ''; + + $textarea.on('input.comment-and-resolve-btn', () => { this.textareaIsEmpty = $textarea.val() === ''; + }); + }, + destroyed: function () { + if (!this.discussionId) return; - $textarea.on('input.comment-and-resolve-btn', () => { - this.textareaIsEmpty = $textarea.val() === ''; - }); - }, - destroyed: function () { - $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn'); - } - }); + $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off('input.comment-and-resolve-btn'); + } +}); - Vue.component('comment-and-resolve-btn', CommentAndResolveBtn); -})(window); +Vue.component('comment-and-resolve-btn', CommentAndResolveBtn); diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js index 0297add94d5..517bdb6be09 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -3,156 +3,160 @@ import Vue from 'vue'; import collapseIcon from '../icons/collapse_icon.svg'; - -(() => { - const DiffNoteAvatars = Vue.extend({ - props: ['discussionId'], - data() { - return { - isVisible: false, - lineType: '', - storeState: CommentsStore.state, - shownAvatars: 3, - collapseIcon, - }; - }, - template: ` - <div class="diff-comment-avatar-holders" - v-show="notesCount !== 0"> - <div v-if="!isVisible"> - <img v-for="note in notesSubset" - class="avatar diff-comment-avatar has-tooltip js-diff-comment-avatar" - width="19" - height="19" - role="button" - data-container="body" - data-placement="top" - data-html="true" - :data-line-type="lineType" - :title="note.authorName + ': ' + note.noteTruncated" - :src="note.authorAvatar" - @click="clickedAvatar($event)" /> - <span v-if="notesCount > shownAvatars" - class="diff-comments-more-count has-tooltip js-diff-comment-avatar" - data-container="body" - data-placement="top" - ref="extraComments" - role="button" - :data-line-type="lineType" - :title="extraNotesTitle" - @click="clickedAvatar($event)">{{ moreText }}</span> - </div> - <button class="diff-notes-collapse js-diff-comment-avatar" - type="button" - aria-label="Show comments" +import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; + +const DiffNoteAvatars = Vue.extend({ + props: ['discussionId'], + data() { + return { + isVisible: false, + lineType: '', + storeState: CommentsStore.state, + shownAvatars: 3, + collapseIcon, + }; + }, + components: { + userAvatarImage, + }, + template: ` + <div class="diff-comment-avatar-holders" + v-show="notesCount !== 0"> + <div v-if="!isVisible"> + <!-- FIXME: Pass an alt attribute here for accessibility --> + <user-avatar-image + v-for="note in notesSubset" + class="diff-comment-avatar js-diff-comment-avatar" + @click.native="clickedAvatar($event)" + :img-src="note.authorAvatar" + :tooltip-text="getTooltipText(note)" + :data-line-type="lineType" + :size="19" + data-html="true" + /> + <span v-if="notesCount > shownAvatars" + class="diff-comments-more-count has-tooltip js-diff-comment-avatar" + data-container="body" + data-placement="top" + ref="extraComments" + role="button" :data-line-type="lineType" - @click="clickedAvatar($event)" - v-if="isVisible" - v-html="collapseIcon"> - </button> + :title="extraNotesTitle" + @click="clickedAvatar($event)">{{ moreText }}</span> </div> - `, - mounted() { + <button class="diff-notes-collapse js-diff-comment-avatar" + type="button" + aria-label="Show comments" + :data-line-type="lineType" + @click="clickedAvatar($event)" + v-if="isVisible" + v-html="collapseIcon"> + </button> + </div> + `, + mounted() { + this.$nextTick(() => { + this.addNoCommentClass(); + this.setDiscussionVisible(); + + this.lineType = $(this.$el).closest('.diff-line-num').hasClass('old_line') ? 'old' : 'new'; + }); + + $(document).on('toggle.comments', () => { this.$nextTick(() => { - this.addNoCommentClass(); this.setDiscussionVisible(); - - this.lineType = $(this.$el).closest('.diff-line-num').hasClass('old_line') ? 'old' : 'new'; }); - - $(document).on('toggle.comments', () => { + }); + }, + destroyed() { + $(document).off('toggle.comments'); + }, + watch: { + storeState: { + handler() { this.$nextTick(() => { - this.setDiscussionVisible(); + $('.has-tooltip', this.$el).tooltip('fixTitle'); + + // We need to add/remove a class to an element that is outside the Vue instance + this.addNoCommentClass(); }); - }); - }, - destroyed() { - $(document).off('toggle.comments'); - }, - watch: { - storeState: { - handler() { - this.$nextTick(() => { - $('.has-tooltip', this.$el).tooltip('fixTitle'); - - // We need to add/remove a class to an element that is outside the Vue instance - this.addNoCommentClass(); - }); - }, - deep: true, }, + deep: true, }, - computed: { - notesSubset() { - let notes = []; - - if (this.discussion) { - notes = Object.keys(this.discussion.notes) - .slice(0, this.shownAvatars) - .map(noteId => this.discussion.notes[noteId]); - } - - return notes; - }, - extraNotesTitle() { - if (this.discussion) { - const extra = this.discussion.notesCount() - this.shownAvatars; + }, + computed: { + notesSubset() { + let notes = []; + + if (this.discussion) { + notes = Object.keys(this.discussion.notes) + .slice(0, this.shownAvatars) + .map(noteId => this.discussion.notes[noteId]); + } + + return notes; + }, + extraNotesTitle() { + if (this.discussion) { + const extra = this.discussion.notesCount() - this.shownAvatars; - return `${extra} more comment${extra > 1 ? 's' : ''}`; - } + return `${extra} more comment${extra > 1 ? 's' : ''}`; + } - return ''; - }, - discussion() { - return this.storeState[this.discussionId]; - }, - notesCount() { - if (this.discussion) { - return this.discussion.notesCount(); - } + return ''; + }, + discussion() { + return this.storeState[this.discussionId]; + }, + notesCount() { + if (this.discussion) { + return this.discussion.notesCount(); + } - return 0; - }, - moreText() { - const plusSign = this.notesCount < 100 ? '+' : ''; + return 0; + }, + moreText() { + const plusSign = this.notesCount < 100 ? '+' : ''; - return `${plusSign}${this.notesCount - this.shownAvatars}`; - }, + return `${plusSign}${this.notesCount - this.shownAvatars}`; }, - methods: { - clickedAvatar(e) { - notes.addDiffNote(e); + }, + methods: { + clickedAvatar(e) { + notes.onAddDiffNote(e); - // Toggle the active state of the toggle all button - this.toggleDiscussionsToggleState(); + // Toggle the active state of the toggle all button + this.toggleDiscussionsToggleState(); - this.$nextTick(() => { - this.setDiscussionVisible(); + this.$nextTick(() => { + this.setDiscussionVisible(); - $('.has-tooltip', this.$el).tooltip('fixTitle'); - $('.has-tooltip', this.$el).tooltip('hide'); - }); - }, - addNoCommentClass() { - const notesCount = this.notesCount; + $('.has-tooltip', this.$el).tooltip('fixTitle'); + $('.has-tooltip', this.$el).tooltip('hide'); + }); + }, + addNoCommentClass() { + const notesCount = this.notesCount; - $(this.$el).closest('.js-avatar-container') - .toggleClass('js-no-comment-btn', notesCount > 0) - .nextUntil('.js-avatar-container') - .toggleClass('js-no-comment-btn', notesCount > 0); - }, - toggleDiscussionsToggleState() { - const $notesHolders = $(this.$el).closest('.code').find('.notes_holder'); - const $visibleNotesHolders = $notesHolders.filter(':visible'); - const $toggleDiffCommentsBtn = $(this.$el).closest('.diff-file').find('.js-toggle-diff-comments'); + $(this.$el).closest('.js-avatar-container') + .toggleClass('js-no-comment-btn', notesCount > 0) + .nextUntil('.js-avatar-container') + .toggleClass('js-no-comment-btn', notesCount > 0); + }, + toggleDiscussionsToggleState() { + const $notesHolders = $(this.$el).closest('.code').find('.notes_holder'); + const $visibleNotesHolders = $notesHolders.filter(':visible'); + const $toggleDiffCommentsBtn = $(this.$el).closest('.diff-file').find('.js-toggle-diff-comments'); - $toggleDiffCommentsBtn.toggleClass('active', $notesHolders.length === $visibleNotesHolders.length); - }, - setDiscussionVisible() { - this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible'); - }, + $toggleDiffCommentsBtn.toggleClass('active', $notesHolders.length === $visibleNotesHolders.length); + }, + setDiscussionVisible() { + this.isVisible = $(`.diffs .notes[data-discussion-id="${this.discussion.id}"]`).is(':visible'); + }, + getTooltipText(note) { + return `${note.authorName}: ${note.noteTruncated}`; }, - }); + }, +}); - Vue.component('diff-note-avatars', DiffNoteAvatars); -})(); +Vue.component('diff-note-avatars', DiffNoteAvatars); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index 8edc45130fc..8a0fd3bb4a7 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -4,192 +4,190 @@ import Vue from 'vue'; -(() => { - const JumpToDiscussion = Vue.extend({ - mixins: [DiscussionMixins], - props: { - discussionId: String +const JumpToDiscussion = Vue.extend({ + mixins: [DiscussionMixins], + props: { + discussionId: String + }, + data: function () { + return { + discussions: CommentsStore.state, + discussion: {}, + }; + }, + computed: { + allResolved: function () { + return this.unresolvedDiscussionCount === 0; }, - data: function () { - return { - discussions: CommentsStore.state, - discussion: {}, - }; - }, - computed: { - allResolved: function () { - return this.unresolvedDiscussionCount === 0; - }, - showButton: function () { - if (this.discussionId) { - if (this.unresolvedDiscussionCount > 1) { - return true; - } else { - return this.discussionId !== this.lastResolvedId; - } + showButton: function () { + if (this.discussionId) { + if (this.unresolvedDiscussionCount > 1) { + return true; } else { - return this.unresolvedDiscussionCount >= 1; + return this.discussionId !== this.lastResolvedId; } - }, - lastResolvedId: function () { - let lastId; - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; - - if (!discussion.isResolved()) { - lastId = discussion.id; - } - } - return lastId; + } else { + return this.unresolvedDiscussionCount >= 1; } }, - methods: { - jumpToNextUnresolvedDiscussion: function () { - let discussionsSelector; - let discussionIdsInScope; - let firstUnresolvedDiscussionId; - let nextUnresolvedDiscussionId; - let activeTab = window.mrTabs.currentAction; - let hasDiscussionsToJumpTo = true; - let jumpToFirstDiscussion = !this.discussionId; - - const discussionIdsForElements = function(elements) { - return elements.map(function() { - return $(this).attr('data-discussion-id'); - }).toArray(); - }; - - const discussions = this.discussions; - - if (activeTab === 'diffs') { - discussionsSelector = '.diffs .notes[data-discussion-id]'; - discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); - - let unresolvedDiscussionCount = 0; - - for (let i = 0; i < discussionIdsInScope.length; i += 1) { - const discussionId = discussionIdsInScope[i]; - const discussion = discussions[discussionId]; - if (discussion && !discussion.isResolved()) { - unresolvedDiscussionCount += 1; - } - } + lastResolvedId: function () { + let lastId; + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; - if (this.discussionId && !this.discussion.isResolved()) { - // If this is the last unresolved discussion on the diffs tab, - // there are no discussions to jump to. - if (unresolvedDiscussionCount === 1) { - hasDiscussionsToJumpTo = false; - } - } else { - // If there are no unresolved discussions on the diffs tab at all, - // there are no discussions to jump to. - if (unresolvedDiscussionCount === 0) { - hasDiscussionsToJumpTo = false; - } - } - } else if (activeTab !== 'notes') { - // If we are on the commits or builds tabs, - // there are no discussions to jump to. - hasDiscussionsToJumpTo = false; + if (!discussion.isResolved()) { + lastId = discussion.id; } + } + return lastId; + } + }, + methods: { + jumpToNextUnresolvedDiscussion: function () { + let discussionsSelector; + let discussionIdsInScope; + let firstUnresolvedDiscussionId; + let nextUnresolvedDiscussionId; + let activeTab = window.mrTabs.currentAction; + let hasDiscussionsToJumpTo = true; + let jumpToFirstDiscussion = !this.discussionId; + + const discussionIdsForElements = function(elements) { + return elements.map(function() { + return $(this).attr('data-discussion-id'); + }).toArray(); + }; - if (!hasDiscussionsToJumpTo) { - // If there are no discussions to jump to on the current page, - // switch to the notes tab and jump to the first disucssion there. - window.mrTabs.activateTab('notes'); - activeTab = 'notes'; - jumpToFirstDiscussion = true; - } + const discussions = this.discussions; - if (activeTab === 'notes') { - discussionsSelector = '.discussion[data-discussion-id]'; - discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); - } + if (activeTab === 'diffs') { + discussionsSelector = '.diffs .notes[data-discussion-id]'; + discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); + + let unresolvedDiscussionCount = 0; - let currentDiscussionFound = false; for (let i = 0; i < discussionIdsInScope.length; i += 1) { const discussionId = discussionIdsInScope[i]; const discussion = discussions[discussionId]; + if (discussion && !discussion.isResolved()) { + unresolvedDiscussionCount += 1; + } + } - if (!discussion) { - // Discussions for comments on commits in this MR don't have a resolved status. - continue; + if (this.discussionId && !this.discussion.isResolved()) { + // If this is the last unresolved discussion on the diffs tab, + // there are no discussions to jump to. + if (unresolvedDiscussionCount === 1) { + hasDiscussionsToJumpTo = false; + } + } else { + // If there are no unresolved discussions on the diffs tab at all, + // there are no discussions to jump to. + if (unresolvedDiscussionCount === 0) { + hasDiscussionsToJumpTo = false; } + } + } else if (activeTab !== 'notes') { + // If we are on the commits or builds tabs, + // there are no discussions to jump to. + hasDiscussionsToJumpTo = false; + } - if (!firstUnresolvedDiscussionId && !discussion.isResolved()) { - firstUnresolvedDiscussionId = discussionId; + if (!hasDiscussionsToJumpTo) { + // If there are no discussions to jump to on the current page, + // switch to the notes tab and jump to the first disucssion there. + window.mrTabs.activateTab('notes'); + activeTab = 'notes'; + jumpToFirstDiscussion = true; + } - if (jumpToFirstDiscussion) { - break; - } + if (activeTab === 'notes') { + discussionsSelector = '.discussion[data-discussion-id]'; + discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); + } + + let currentDiscussionFound = false; + for (let i = 0; i < discussionIdsInScope.length; i += 1) { + const discussionId = discussionIdsInScope[i]; + const discussion = discussions[discussionId]; + + if (!discussion) { + // Discussions for comments on commits in this MR don't have a resolved status. + continue; + } + + if (!firstUnresolvedDiscussionId && !discussion.isResolved()) { + firstUnresolvedDiscussionId = discussionId; + + if (jumpToFirstDiscussion) { + break; } + } - if (!jumpToFirstDiscussion) { - if (currentDiscussionFound) { - if (!discussion.isResolved()) { - nextUnresolvedDiscussionId = discussionId; - break; - } - else { - continue; - } + if (!jumpToFirstDiscussion) { + if (currentDiscussionFound) { + if (!discussion.isResolved()) { + nextUnresolvedDiscussionId = discussionId; + break; } - - if (discussionId === this.discussionId) { - currentDiscussionFound = true; + else { + continue; } } + + if (discussionId === this.discussionId) { + currentDiscussionFound = true; + } } + } - nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId; + nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId; - if (!nextUnresolvedDiscussionId) { - return; - } + if (!nextUnresolvedDiscussionId) { + return; + } - let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`); + let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`); - if (activeTab === 'notes') { - $target = $target.closest('.note-discussion'); + if (activeTab === 'notes') { + $target = $target.closest('.note-discussion'); - // If the next discussion is closed, toggle it open. - if ($target.find('.js-toggle-content').is(':hidden')) { - $target.find('.js-toggle-button i').trigger('click'); + // If the next discussion is closed, toggle it open. + if ($target.find('.js-toggle-content').is(':hidden')) { + $target.find('.js-toggle-button i').trigger('click'); + } + } else if (activeTab === 'diffs') { + // Resolved discussions are hidden in the diffs tab by default. + // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab. + // When jumping between unresolved discussions on the diffs tab, we show them. + $target.closest(".content").show(); + + $target = $target.closest("tr.notes_holder"); + $target.show(); + + // If we are on the diffs tab, we don't scroll to the discussion itself, but to + // 4 diff lines above it: the line the discussion was in response to + 3 context + let prevEl; + for (let i = 0; i < 4; i += 1) { + prevEl = $target.prev(); + + // If the discussion doesn't have 4 lines above it, we'll have to do with fewer. + if (!prevEl.hasClass("line_holder")) { + break; } - } else if (activeTab === 'diffs') { - // Resolved discussions are hidden in the diffs tab by default. - // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab. - // When jumping between unresolved discussions on the diffs tab, we show them. - $target.closest(".content").show(); - - $target = $target.closest("tr.notes_holder"); - $target.show(); - - // If we are on the diffs tab, we don't scroll to the discussion itself, but to - // 4 diff lines above it: the line the discussion was in response to + 3 context - let prevEl; - for (let i = 0; i < 4; i += 1) { - prevEl = $target.prev(); - - // If the discussion doesn't have 4 lines above it, we'll have to do with fewer. - if (!prevEl.hasClass("line_holder")) { - break; - } - $target = prevEl; - } + $target = prevEl; } - - $.scrollTo($target, { - offset: 0 - }); } - }, - created() { - this.discussion = this.discussions[this.discussionId]; - }, - }); - Vue.component('jump-to-discussion', JumpToDiscussion); -})(); + $.scrollTo($target, { + offset: 0 + }); + } + }, + created() { + this.discussion = this.discussions[this.discussionId]; + }, +}); + +Vue.component('jump-to-discussion', JumpToDiscussion); diff --git a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js index 8eb0e10b832..e0c09aa0eee 100644 --- a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js +++ b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js @@ -2,29 +2,27 @@ import Vue from 'vue'; -(() => { - const NewIssueForDiscussion = Vue.extend({ - props: { - discussionId: { - type: String, - required: true, - }, +const NewIssueForDiscussion = Vue.extend({ + props: { + discussionId: { + type: String, + required: true, }, - data() { - return { - discussions: CommentsStore.state, - }; + }, + data() { + return { + discussions: CommentsStore.state, + }; + }, + computed: { + discussion() { + return this.discussions[this.discussionId]; }, - computed: { - discussion() { - return this.discussions[this.discussionId]; - }, - showButton() { - if (this.discussion) return !this.discussion.isResolved(); - return false; - }, + showButton() { + if (this.discussion) return !this.discussion.isResolved(); + return false; }, - }); + }, +}); - Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion); -})(); +Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 312f38ce241..9d51fb53eb2 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -5,117 +5,120 @@ import Vue from 'vue'; -(() => { - const ResolveBtn = Vue.extend({ - props: { - noteId: Number, - discussionId: String, - resolved: Boolean, - canResolve: Boolean, - resolvedBy: String, - authorName: String, - authorAvatar: String, - noteTruncated: String, +const ResolveBtn = Vue.extend({ + props: { + noteId: Number, + discussionId: String, + resolved: Boolean, + canResolve: Boolean, + resolvedBy: String, + authorName: String, + authorAvatar: String, + noteTruncated: String, + }, + data: function () { + return { + discussions: CommentsStore.state, + loading: false + }; + }, + watch: { + 'discussions': { + handler: 'updateTooltip', + deep: true + } + }, + computed: { + discussion: function () { + return this.discussions[this.discussionId]; }, - data: function () { - return { - discussions: CommentsStore.state, - loading: false, - note: {}, - }; + note: function () { + return this.discussion ? this.discussion.getNote(this.noteId) : {}; }, - watch: { - 'discussions': { - handler: 'updateTooltip', - deep: true + buttonText: function () { + if (this.isResolved) { + return `Resolved by ${this.resolvedByName}`; + } else if (this.canResolve) { + return 'Mark as resolved'; + } else { + return 'Unable to resolve'; } }, - computed: { - discussion: function () { - return this.discussions[this.discussionId]; - }, - buttonText: function () { - if (this.isResolved) { - return `Resolved by ${this.resolvedByName}`; - } else if (this.canResolve) { - return 'Mark as resolved'; - } else { - return 'Unable to resolve'; - } - }, - isResolved: function () { - if (this.note) { - return this.note.resolved; - } else { - return false; - } - }, - resolvedByName: function () { - return this.note.resolved_by; - }, + isResolved: function () { + if (this.note) { + return this.note.resolved; + } else { + return false; + } }, - methods: { - updateTooltip: function () { - this.$nextTick(() => { - $(this.$refs.button) - .tooltip('hide') - .tooltip('fixTitle'); - }); - }, - resolve: function () { - if (!this.canResolve) return; + resolvedByName: function () { + return this.note.resolved_by; + }, + }, + methods: { + updateTooltip: function () { + this.$nextTick(() => { + $(this.$refs.button) + .tooltip('hide') + .tooltip('fixTitle'); + }); + }, + resolve: function () { + const errorFlashMsg = 'An error occurred when trying to resolve a comment. Please try again.'; - let promise; - this.loading = true; + if (!this.canResolve) return; - if (this.isResolved) { - promise = ResolveService - .unresolve(this.noteId); - } else { - promise = ResolveService - .resolve(this.noteId); - } + let promise; + this.loading = true; - promise.then((response) => { - this.loading = false; + if (this.isResolved) { + promise = ResolveService + .unresolve(this.noteId); + } else { + promise = ResolveService + .resolve(this.noteId); + } - if (response.status === 200) { - const data = response.json(); - const resolved_by = data ? data.resolved_by : null; + promise.then((response) => { + this.loading = false; - CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); - this.discussion.updateHeadline(data); - } else { - new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert'); - } + if (response.status === 200) { + const data = response.json(); + const resolved_by = data ? data.resolved_by : null; - this.updateTooltip(); - }); - } - }, - mounted: function () { - $(this.$refs.button).tooltip({ - container: 'body' - }); - }, - beforeDestroy: function () { - CommentsStore.delete(this.discussionId, this.noteId); - }, - created: function () { - CommentsStore.create({ - discussionId: this.discussionId, - noteId: this.noteId, - canResolve: this.canResolve, - resolved: this.resolved, - resolvedBy: this.resolvedBy, - authorName: this.authorName, - authorAvatar: this.authorAvatar, - noteTruncated: this.noteTruncated, - }); + CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); + this.discussion.updateHeadline(data); + gl.mrWidget.checkStatus(); + } else { + new Flash(errorFlashMsg); + } - this.note = this.discussion.getNote(this.noteId); + this.updateTooltip(); + }).catch(() => { + new Flash(errorFlashMsg); + }); } - }); + }, + mounted: function () { + $(this.$refs.button).tooltip({ + container: 'body' + }); + }, + beforeDestroy: function () { + CommentsStore.delete(this.discussionId, this.noteId); + }, + created: function () { + CommentsStore.create({ + discussionId: this.discussionId, + noteId: this.noteId, + canResolve: this.canResolve, + resolved: this.resolved, + resolvedBy: this.resolvedBy, + authorName: this.authorName, + authorAvatar: this.authorAvatar, + noteTruncated: this.noteTruncated, + }); + } +}); - Vue.component('resolve-btn', ResolveBtn); -})(); +Vue.component('resolve-btn', ResolveBtn); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js index 27147ac6b5c..96e5a440357 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_count.js +++ b/app/assets/javascripts/diff_notes/components/resolve_count.js @@ -4,24 +4,22 @@ import Vue from 'vue'; -((w) => { - w.ResolveCount = Vue.extend({ - mixins: [DiscussionMixins], - props: { - loggedOut: Boolean +window.ResolveCount = Vue.extend({ + mixins: [DiscussionMixins], + props: { + loggedOut: Boolean + }, + data: function () { + return { + discussions: CommentsStore.state + }; + }, + computed: { + allResolved: function () { + return this.resolvedDiscussionCount === this.discussionCount; }, - data: function () { - return { - discussions: CommentsStore.state - }; - }, - computed: { - allResolved: function () { - return this.resolvedDiscussionCount === this.discussionCount; - }, - resolvedCountText() { - return this.discussionCount === 1 ? 'discussion' : 'discussions'; - } + resolvedCountText() { + return this.discussionCount === 1 ? 'discussion' : 'discussions'; } - }); -})(window); + } +}); diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js index a964b7d0c6b..6a036e96171 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js @@ -4,59 +4,57 @@ import Vue from 'vue'; -(() => { - const ResolveDiscussionBtn = Vue.extend({ - props: { - discussionId: String, - mergeRequestId: Number, - canResolve: Boolean, - }, - data: function() { - return { - discussion: {}, - }; +const ResolveDiscussionBtn = Vue.extend({ + props: { + discussionId: String, + mergeRequestId: Number, + canResolve: Boolean, + }, + data: function() { + return { + discussion: {}, + }; + }, + computed: { + showButton: function () { + if (this.discussion) { + return this.discussion.isResolvable(); + } else { + return false; + } }, - computed: { - showButton: function () { - if (this.discussion) { - return this.discussion.isResolvable(); - } else { - return false; - } - }, - isDiscussionResolved: function () { - if (this.discussion) { - return this.discussion.isResolved(); - } else { - return false; - } - }, - buttonText: function () { - if (this.isDiscussionResolved) { - return "Unresolve discussion"; - } else { - return "Resolve discussion"; - } - }, - loading: function () { - if (this.discussion) { - return this.discussion.loading; - } else { - return false; - } + isDiscussionResolved: function () { + if (this.discussion) { + return this.discussion.isResolved(); + } else { + return false; } }, - methods: { - resolve: function () { - ResolveService.toggleResolveForDiscussion(this.mergeRequestId, this.discussionId); + buttonText: function () { + if (this.isDiscussionResolved) { + return "Unresolve discussion"; + } else { + return "Resolve discussion"; } }, - created: function () { - CommentsStore.createDiscussion(this.discussionId, this.canResolve); - - this.discussion = CommentsStore.state[this.discussionId]; + loading: function () { + if (this.discussion) { + return this.discussion.loading; + } else { + return false; + } + } + }, + methods: { + resolve: function () { + ResolveService.toggleResolveForDiscussion(this.mergeRequestId, this.discussionId); } - }); + }, + created: function () { + CommentsStore.createDiscussion(this.discussionId, this.canResolve); + + this.discussion = CommentsStore.state[this.discussionId]; + } +}); - Vue.component('resolve-discussion-btn', ResolveDiscussionBtn); -})(); +Vue.component('resolve-discussion-btn', ResolveDiscussionBtn); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index b6b47e2da6f..a2d33b0936e 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -2,19 +2,18 @@ /* global ResolveCount */ import Vue from 'vue'; - -require('./models/discussion'); -require('./models/note'); -require('./stores/comments'); -require('./services/resolve'); -require('./mixins/discussion'); -require('./components/comment_resolve_btn'); -require('./components/jump_to_discussion'); -require('./components/resolve_btn'); -require('./components/resolve_count'); -require('./components/resolve_discussion_btn'); -require('./components/diff_note_avatars'); -require('./components/new_issue_for_discussion'); +import './models/discussion'; +import './models/note'; +import './stores/comments'; +import './services/resolve'; +import './mixins/discussion'; +import './components/comment_resolve_btn'; +import './components/jump_to_discussion'; +import './components/resolve_btn'; +import './components/resolve_count'; +import './components/resolve_discussion_btn'; +import './components/diff_note_avatars'; +import './components/new_issue_for_discussion'; $(() => { const projectPath = document.querySelector('.merge-request').dataset.projectPath; @@ -65,4 +64,6 @@ $(() => { 'resolve-count': ResolveCount } }); + + $(window).trigger('resize.nav'); }); diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js b/app/assets/javascripts/diff_notes/mixins/discussion.js index 3c08c222f46..36c4abf02cf 100644 --- a/app/assets/javascripts/diff_notes/mixins/discussion.js +++ b/app/assets/javascripts/diff_notes/mixins/discussion.js @@ -1,37 +1,35 @@ /* eslint-disable object-shorthand, func-names, guard-for-in, no-restricted-syntax, comma-dangle, no-param-reassign, max-len */ -((w) => { - w.DiscussionMixins = { - computed: { - discussionCount: function () { - return Object.keys(this.discussions).length; - }, - resolvedDiscussionCount: function () { - let resolvedCount = 0; +window.DiscussionMixins = { + computed: { + discussionCount: function () { + return Object.keys(this.discussions).length; + }, + resolvedDiscussionCount: function () { + let resolvedCount = 0; - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; - if (discussion.isResolved()) { - resolvedCount += 1; - } + if (discussion.isResolved()) { + resolvedCount += 1; } + } - return resolvedCount; - }, - unresolvedDiscussionCount: function () { - let unresolvedCount = 0; + return resolvedCount; + }, + unresolvedDiscussionCount: function () { + let unresolvedCount = 0; - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; - if (!discussion.isResolved()) { - unresolvedCount += 1; - } + if (!discussion.isResolved()) { + unresolvedCount += 1; } - - return unresolvedCount; } + + return unresolvedCount; } - }; -})(window); + } +}; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index bfa4fc9037a..807ab11d292 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -3,82 +3,79 @@ /* global CommentsStore */ import Vue from 'vue'; -import VueResource from 'vue-resource'; +import '../../vue_shared/vue_resource_interceptor'; -require('../../vue_shared/vue_resource_interceptor'); +window.gl = window.gl || {}; -Vue.use(VueResource); +class ResolveServiceClass { + constructor(root) { + this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); + this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); + } -(() => { - window.gl = window.gl || {}; + resolve(noteId) { + return this.noteResource.save({ noteId }, {}); + } - class ResolveServiceClass { - constructor(root) { - this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); - this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); - } + unresolve(noteId) { + return this.noteResource.delete({ noteId }, {}); + } - resolve(noteId) { - return this.noteResource.save({ noteId }, {}); - } + toggleResolveForDiscussion(mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; + const isResolved = discussion.isResolved(); + let promise; - unresolve(noteId) { - return this.noteResource.delete({ noteId }, {}); + if (isResolved) { + promise = this.unResolveAll(mergeRequestId, discussionId); + } else { + promise = this.resolveAll(mergeRequestId, discussionId); } - toggleResolveForDiscussion(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - const isResolved = discussion.isResolved(); - let promise; - - if (isResolved) { - promise = this.unResolveAll(mergeRequestId, discussionId); - } else { - promise = this.resolveAll(mergeRequestId, discussionId); - } - - promise.then((response) => { - discussion.loading = false; + promise.then((response) => { + discussion.loading = false; - if (response.status === 200) { - const data = response.json(); - const resolved_by = data ? data.resolved_by : null; + if (response.status === 200) { + const data = response.json(); + const resolved_by = data ? data.resolved_by : null; - if (isResolved) { - discussion.unResolveAllNotes(); - } else { - discussion.resolveAllNotes(resolved_by); - } - - discussion.updateHeadline(data); + if (isResolved) { + discussion.unResolveAllNotes(); } else { - new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert'); + discussion.resolveAllNotes(resolved_by); } - }); - } - resolveAll(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; + gl.mrWidget.checkStatus(); + discussion.updateHeadline(data); + } else { + throw new Error('An error occurred when trying to resolve discussion.'); + } + }).catch(() => { + new Flash('An error occurred when trying to resolve a discussion. Please try again.'); + }); + } - discussion.loading = true; + resolveAll(mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; - return this.discussionResource.save({ - mergeRequestId, - discussionId - }, {}); - } + discussion.loading = true; + + return this.discussionResource.save({ + mergeRequestId, + discussionId + }, {}); + } - unResolveAll(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; + unResolveAll(mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; - discussion.loading = true; + discussion.loading = true; - return this.discussionResource.delete({ - mergeRequestId, - discussionId - }, {}); - } + return this.discussionResource.delete({ + mergeRequestId, + discussionId + }, {}); } +} - gl.DiffNotesResolveServiceClass = ResolveServiceClass; -})(); +gl.DiffNotesResolveServiceClass = ResolveServiceClass; diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js index e6cbda56c91..d802db7d3af 100644 --- a/app/assets/javascripts/diff_notes/stores/comments.js +++ b/app/assets/javascripts/diff_notes/stores/comments.js @@ -3,56 +3,54 @@ import Vue from 'vue'; -((w) => { - w.CommentsStore = { - state: {}, - get: function (discussionId, noteId) { - return this.state[discussionId].getNote(noteId); - }, - createDiscussion: function (discussionId, canResolve) { - let discussion = this.state[discussionId]; - if (!this.state[discussionId]) { - discussion = new DiscussionModel(discussionId); - Vue.set(this.state, discussionId, discussion); - } +window.CommentsStore = { + state: {}, + get: function (discussionId, noteId) { + return this.state[discussionId].getNote(noteId); + }, + createDiscussion: function (discussionId, canResolve) { + let discussion = this.state[discussionId]; + if (!this.state[discussionId]) { + discussion = new DiscussionModel(discussionId); + Vue.set(this.state, discussionId, discussion); + } - if (canResolve !== undefined) { - discussion.canResolve = canResolve; - } + if (canResolve !== undefined) { + discussion.canResolve = canResolve; + } - return discussion; - }, - create: function (noteObj) { - const discussion = this.createDiscussion(noteObj.discussionId); + return discussion; + }, + create: function (noteObj) { + const discussion = this.createDiscussion(noteObj.discussionId); + + discussion.createNote(noteObj); + }, + update: function (discussionId, noteId, resolved, resolved_by) { + const discussion = this.state[discussionId]; + const note = discussion.getNote(noteId); + note.resolved = resolved; + note.resolved_by = resolved_by; + }, + delete: function (discussionId, noteId) { + const discussion = this.state[discussionId]; + discussion.deleteNote(noteId); + + if (discussion.notesCount() === 0) { + Vue.delete(this.state, discussionId); + } + }, + unresolvedDiscussionIds: function () { + const ids = []; - discussion.createNote(noteObj); - }, - update: function (discussionId, noteId, resolved, resolved_by) { - const discussion = this.state[discussionId]; - const note = discussion.getNote(noteId); - note.resolved = resolved; - note.resolved_by = resolved_by; - }, - delete: function (discussionId, noteId) { + for (const discussionId in this.state) { const discussion = this.state[discussionId]; - discussion.deleteNote(noteId); - if (discussion.notesCount() === 0) { - Vue.delete(this.state, discussionId); + if (!discussion.isResolved()) { + ids.push(discussion.id); } - }, - unresolvedDiscussionIds: function () { - const ids = []; - - for (const discussionId in this.state) { - const discussion = this.state[discussionId]; - - if (!discussion.isResolved()) { - ids.push(discussion.id); - } - } - - return ids; } - }; -})(window); + + return ids; + } +}; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 80490052389..a27abee5431 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -10,12 +10,10 @@ /* global IssuableForm */ /* global LabelsSelect */ /* global MilestoneSelect */ -/* global MergedButtons */ /* global Commit */ /* global NotificationsForm */ /* global TreeView */ /* global NotificationsDropdown */ -/* global UsersSelect */ /* global GroupAvatar */ /* global LineHighlighter */ /* global ProjectFork */ @@ -24,7 +22,6 @@ /* global Search */ /* global Admin */ /* global NamespaceSelects */ -/* global ShortcutsDashboardNavigation */ /* global Project */ /* global ProjectAvatar */ /* global CompareAutocomplete */ @@ -34,18 +31,29 @@ /* global Labels */ /* global Shortcuts */ /* global Sidebar */ +/* global ShortcutsWiki */ import Issue from './issue'; - import BindInOut from './behaviors/bind_in_out'; +import DeleteModal from './branches/branches_delete_modal'; +import Group from './group'; import GroupName from './group_name'; import GroupsList from './groups_list'; import ProjectsList from './projects_list'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; +import Landing from './landing'; +import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; - -const ShortcutsBlob = require('./shortcuts_blob'); +import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; +import ShortcutsWiki from './shortcuts_wiki'; +import Pipelines from './pipelines'; +import BlobViewer from './blob/viewer/index'; +import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; +import UsersSelect from './users_select'; +import RefSelectDropdown from './ref_select_dropdown'; +import GfmAutoComplete from './gfm_auto_complete'; +import ShortcutsBlob from './shortcuts_blob'; (function() { var Dispatcher; @@ -70,6 +78,8 @@ const ShortcutsBlob = require('./shortcuts_blob'); path = page.split(':'); shortcut_handler = null; + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(); + function initBlob() { new LineHighlighter(); @@ -86,6 +96,15 @@ const ShortcutsBlob = require('./shortcuts_blob'); skipResetBindings: true, fileBlobPermalinkUrl, }); + + new BlobForkSuggestion({ + openButtons: document.querySelectorAll('.js-edit-blob-link-fork-toggler'), + forkButtons: document.querySelectorAll('.js-fork-suggestion-button'), + cancelButtons: document.querySelectorAll('.js-cancel-fork-suggestion-button'), + suggestionSections: document.querySelectorAll('.js-file-fork-suggestion-section'), + actionTextPieces: document.querySelectorAll('.js-file-fork-suggestion-section-action'), + }) + .init(); } switch (page) { @@ -96,6 +115,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:boards:show': case 'projects:boards:index': shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:builds:show': new Build(); @@ -110,6 +130,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', }); shortcut_handler = new ShortcutsNavigation(); + new UsersSelect(); break; case 'projects:issues:show': new Issue(); @@ -122,6 +143,10 @@ const ShortcutsBlob = require('./shortcuts_blob'); new Milestone(); new Sidebar(); break; + case 'groups:issues': + case 'groups:merge_requests': + new UsersSelect(); + break; case 'dashboard:todos:index': new gl.Todos(); break; @@ -134,24 +159,36 @@ const ShortcutsBlob = require('./shortcuts_blob'); new ProjectsList(); break; case 'dashboard:groups:index': + new GroupsList(); + break; case 'explore:groups:index': new GroupsList(); + + const landingElement = document.querySelector('.js-explore-groups-landing'); + if (!landingElement) break; + const exploreGroupsLanding = new Landing( + landingElement, + landingElement.querySelector('.dismiss-button'), + 'explore_groups_landing_dismissed', + ); + exploreGroupsLanding.toggle(); break; case 'projects:milestones:new': case 'projects:milestones:edit': case 'projects:milestones:update': + case 'groups:milestones:new': + case 'groups:milestones:edit': + case 'groups:milestones:update': new ZenMode(); new gl.DueDateSelectors(); new gl.GLForm($('.milestone-form')); break; - case 'groups:milestones:new': - new ZenMode(); - break; case 'projects:compare:show': new gl.Diff(); break; case 'projects:branches:index': gl.AjaxLoadingSpinner.init(); + new DeleteModal(); break; case 'projects:issues:new': case 'projects:issues:edit': @@ -172,10 +209,12 @@ const ShortcutsBlob = require('./shortcuts_blob'); new LabelsSelect(); new MilestoneSelect(); new gl.IssuableTemplateSelectors(); + new AutoWidthDropdownSelect($('.js-target-branch-select')).init(); break; case 'projects:tags:new': new ZenMode(); new gl.GLForm($('.tag-form')); + new RefSelectDropdown($('.js-branch-select'), window.gl.availableRefs); break; case 'projects:releases:edit': new ZenMode(); @@ -185,19 +224,18 @@ const ShortcutsBlob = require('./shortcuts_blob'); new gl.Diff(); shortcut_handler = new ShortcutsIssuable(true); new ZenMode(); - new MergedButtons(); - break; - case 'projects:merge_requests:commits': - new MergedButtons(); break; case "projects:merge_requests:diffs": new gl.Diff(); new ZenMode(); - new MergedButtons(); break; case 'dashboard:activity': new gl.Activities(); break; + case 'dashboard:issues': + case 'dashboard:merge_requests': + new UsersSelect(); + break; case 'projects:commit:show': new Commit(); new gl.Diff(); @@ -222,13 +260,19 @@ const ShortcutsBlob = require('./shortcuts_blob'); if ($('#tree-slider').length) { new TreeView(); } + if ($('.blob-viewer').length) { + new BlobViewer(); + } break; case 'projects:pipelines:builds': + case 'projects:pipelines:failures': case 'projects:pipelines:show': const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; + const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`; - new gl.Pipelines({ + new Pipelines({ initTabs: true, + pipelineStatusUrl, tabsOptions: { action: controllerAction, defaultAction: 'pipelines', @@ -262,8 +306,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'groups:create': case 'admin:groups:create': BindInOut.initAll(); - case 'groups:new': - case 'admin:groups:new': + new Group(); + new GroupAvatar(); + break; case 'groups:edit': case 'admin:groups:edit': new GroupAvatar(); @@ -271,6 +316,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:tree:show': shortcut_handler = new ShortcutsNavigation(); new TreeView(); + new BlobViewer(); gl.TargetBranchDropDown.bootstrap(); break; case 'projects:find_file:show': @@ -283,6 +329,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); gl.TargetBranchDropDown.bootstrap(); break; case 'projects:blob:show': + new BlobViewer(); gl.TargetBranchDropDown.bootstrap(); initBlob(); break; @@ -314,6 +361,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:artifacts:browse': new BuildArtifacts(); break; + case 'projects:artifacts:file': + new BlobViewer(); + break; case 'help:index': gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); break; @@ -321,8 +371,12 @@ const ShortcutsBlob = require('./shortcuts_blob'); new Search(); break; case 'projects:repository:show': + // Initialize Protected Branch Settings new gl.ProtectedBranchCreate(); new gl.ProtectedBranchEditList(); + // Initialize Protected Tag Settings + new ProtectedTagCreate(); + new ProtectedTagEditList(); break; case 'projects:ci_cd:show': new gl.ProjectVariables(); @@ -334,6 +388,13 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'users:show': new UserCallout(); break; + case 'snippets:show': + new LineHighlighter(); + new BlobViewer(); + break; + case 'import:fogbugz:new_user_map': + new UsersSelect(); + break; } switch (path.first()) { case 'sessions': @@ -350,6 +411,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'admin': new Admin(); switch (path[1]) { + case 'cohorts': + new gl.UsagePing(); + break; case 'groups': new UsersSelect(); break; @@ -369,7 +433,6 @@ const ShortcutsBlob = require('./shortcuts_blob'); break; case 'dashboard': case 'root': - shortcut_handler = new ShortcutsDashboardNavigation(); new UserCallout(); break; case 'groups': @@ -402,7 +465,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); break; case 'wikis': new gl.Wikis(); - shortcut_handler = new ShortcutsNavigation(); + shortcut_handler = new ShortcutsWiki(); new ZenMode(); new gl.GLForm($('.wiki-form')); break; @@ -410,6 +473,8 @@ const ShortcutsBlob = require('./shortcuts_blob'); shortcut_handler = new ShortcutsNavigation(); if (path[2] === 'show') { new ZenMode(); + new LineHighlighter(); + new BlobViewer(); } break; case 'labels': diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js new file mode 100644 index 00000000000..868d47e91b3 --- /dev/null +++ b/app/assets/javascripts/droplab/constants.js @@ -0,0 +1,16 @@ +const DATA_TRIGGER = 'data-dropdown-trigger'; +const DATA_DROPDOWN = 'data-dropdown'; +const SELECTED_CLASS = 'droplab-item-selected'; +const ACTIVE_CLASS = 'droplab-item-active'; +const IGNORE_CLASS = 'droplab-item-ignore'; +// Matches `{{anything}}` and `{{ everything }}`. +const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; + +export { + DATA_TRIGGER, + DATA_DROPDOWN, + SELECTED_CLASS, + ACTIVE_CLASS, + TEMPLATE_REGEX, + IGNORE_CLASS, +}; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js new file mode 100644 index 00000000000..70cd337fb8a --- /dev/null +++ b/app/assets/javascripts/droplab/drop_down.js @@ -0,0 +1,138 @@ +import utils from './utils'; +import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; + +class DropDown { + constructor(list) { + this.currentIndex = 0; + this.hidden = true; + this.list = typeof list === 'string' ? document.querySelector(list) : list; + this.items = []; + + this.eventWrapper = {}; + + this.getItems(); + this.initTemplateString(); + this.addEvents(); + + this.initialState = list.innerHTML; + } + + getItems() { + this.items = [].slice.call(this.list.querySelectorAll('li')); + return this.items; + } + + initTemplateString() { + const items = this.items || this.getItems(); + + let templateString = ''; + if (items.length > 0) templateString = items[items.length - 1].outerHTML; + this.templateString = templateString; + + return this.templateString; + } + + clickEvent(e) { + if (e.target.tagName === 'UL') return; + if (e.target.classList.contains(IGNORE_CLASS)) return; + + const selected = utils.closest(e.target, 'LI'); + if (!selected) return; + + this.addSelectedClass(selected); + + e.preventDefault(); + this.hide(); + + const listEvent = new CustomEvent('click.dl', { + detail: { + list: this, + selected, + data: e.target.dataset, + }, + }); + this.list.dispatchEvent(listEvent); + } + + addSelectedClass(selected) { + this.removeSelectedClasses(); + selected.classList.add(SELECTED_CLASS); + } + + removeSelectedClasses() { + const items = this.items || this.getItems(); + + items.forEach(item => item.classList.remove(SELECTED_CLASS)); + } + + addEvents() { + this.eventWrapper.clickEvent = this.clickEvent.bind(this); + this.list.addEventListener('click', this.eventWrapper.clickEvent); + } + + setData(data) { + this.data = data; + this.render(data); + } + + addData(data) { + this.data = (this.data || []).concat(data); + this.render(this.data); + } + + render(data) { + const children = data ? data.map(this.renderChildren.bind(this)) : []; + const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; + + renderableList.innerHTML = children.join(''); + } + + renderChildren(data) { + const html = utils.template(this.templateString, data); + const template = document.createElement('div'); + + template.innerHTML = html; + DropDown.setImagesSrc(template); + template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block'; + + return template.firstChild.outerHTML; + } + + show() { + if (!this.hidden) return; + this.list.style.display = 'block'; + this.currentIndex = 0; + this.hidden = false; + } + + hide() { + if (this.hidden) return; + this.list.style.display = 'none'; + this.currentIndex = 0; + this.hidden = true; + } + + toggle() { + if (this.hidden) return this.show(); + + return this.hide(); + } + + destroy() { + this.hide(); + this.list.removeEventListener('click', this.eventWrapper.clickEvent); + } + + static setImagesSrc(template) { + const images = [...template.querySelectorAll('img[data-src]')]; + + images.forEach((image) => { + const img = image; + + img.src = img.getAttribute('data-src'); + img.removeAttribute('data-src'); + }); + } +} + +export default DropDown; diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js new file mode 100644 index 00000000000..2a02ede72bf --- /dev/null +++ b/app/assets/javascripts/droplab/drop_lab.js @@ -0,0 +1,156 @@ +import HookButton from './hook_button'; +import HookInput from './hook_input'; +import utils from './utils'; +import Keyboard from './keyboard'; +import { DATA_TRIGGER } from './constants'; + +class DropLab { + constructor() { + this.ready = false; + this.hooks = []; + this.queuedData = []; + this.config = {}; + + this.eventWrapper = {}; + } + + loadStatic() { + const dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`)); + this.addHooks(dropdownTriggers); + } + + addData(...args) { + this.applyArgs(args, 'processAddData'); + } + + setData(...args) { + this.applyArgs(args, 'processSetData'); + } + + destroy() { + this.hooks.forEach(hook => hook.destroy()); + this.hooks = []; + this.removeEvents(); + } + + applyArgs(args, methodName) { + if (this.ready) return this[methodName](...args); + + this.queuedData = this.queuedData || []; + this.queuedData.push(args); + + return this.ready; + } + + processAddData(trigger, data) { + this.processData(trigger, data, 'addData'); + } + + processSetData(trigger, data) { + this.processData(trigger, data, 'setData'); + } + + processData(trigger, data, methodName) { + this.hooks.forEach((hook) => { + if (Array.isArray(trigger)) hook.list[methodName](trigger); + + if (hook.trigger.id === trigger) hook.list[methodName](data); + }); + } + + addEvents() { + this.eventWrapper.documentClicked = this.documentClicked.bind(this); + document.addEventListener('click', this.eventWrapper.documentClicked); + } + + documentClicked(e) { + let thisTag = e.target; + + if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL'); + if (utils.isDropDownParts(thisTag, this.hooks)) return; + if (utils.isDropDownParts(e.target, this.hooks)) return; + + this.hooks.forEach(hook => hook.list.hide()); + } + + removeEvents() { + document.removeEventListener('click', this.eventWrapper.documentClicked); + } + + changeHookList(trigger, list, plugins, config) { + const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger; + + this.hooks.forEach((hook, i) => { + const aHook = hook; + + aHook.list.list.dataset.dropdownActive = false; + + if (aHook.trigger !== availableTrigger) return; + + aHook.destroy(); + this.hooks.splice(i, 1); + this.addHook(availableTrigger, list, plugins, config); + }); + } + + addHook(hook, list, plugins, config) { + const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook; + let availableList; + + if (typeof list === 'string') { + availableList = document.querySelector(list); + } else if (list instanceof Element) { + availableList = list; + } else { + availableList = document.querySelector(hook.dataset[utils.toCamelCase(DATA_TRIGGER)]); + } + + availableList.dataset.dropdownActive = true; + + const HookObject = availableHook.tagName === 'INPUT' ? HookInput : HookButton; + this.hooks.push(new HookObject(availableHook, availableList, plugins, config)); + + return this; + } + + addHooks(hooks, plugins, config) { + hooks.forEach(hook => this.addHook(hook, null, plugins, config)); + return this; + } + + setConfig(obj) { + this.config = obj; + } + + fireReady() { + const readyEvent = new CustomEvent('ready.dl', { + detail: { + dropdown: this, + }, + }); + document.dispatchEvent(readyEvent); + + this.ready = true; + } + + init(hook, list, plugins, config) { + if (hook) { + this.addHook(hook, list, plugins, config); + } else { + this.loadStatic(); + } + + this.addEvents(); + + Keyboard(); + + this.fireReady(); + + this.queuedData.forEach(data => this.addData(data)); + this.queuedData = []; + + return this; + } +} + +export default DropLab; diff --git a/app/assets/javascripts/droplab/droplab.js b/app/assets/javascripts/droplab/droplab.js deleted file mode 100644 index 8b14191395b..00000000000 --- a/app/assets/javascripts/droplab/droplab.js +++ /dev/null @@ -1,741 +0,0 @@ -/* eslint-disable */ -// Determine where to place this -if (typeof Object.assign != 'function') { - Object.assign = function (target, varArgs) { // .length of function is 2 - 'use strict'; - if (target == null) { // TypeError if undefined or null - throw new TypeError('Cannot convert undefined or null to object'); - } - - var to = Object(target); - - for (var index = 1; index < arguments.length; index++) { - var nextSource = arguments[index]; - - if (nextSource != null) { // Skip over if undefined or null - for (var nextKey in nextSource) { - // Avoid bugs when hasOwnProperty is shadowed - if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { - to[nextKey] = nextSource[nextKey]; - } - } - } - } - return to; - }; -} - -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.droplab = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -var DATA_TRIGGER = 'data-dropdown-trigger'; -var DATA_DROPDOWN = 'data-dropdown'; - -module.exports = { - DATA_TRIGGER: DATA_TRIGGER, - DATA_DROPDOWN: DATA_DROPDOWN, -} - -},{}],2:[function(require,module,exports){ -// Custom event support for IE -if ( typeof CustomEvent === "function" ) { - module.exports = CustomEvent; -} else { - require('./window')(function(w){ - var CustomEvent = function ( event, params ) { - params = params || { bubbles: false, cancelable: false, detail: undefined }; - var evt = document.createEvent( 'CustomEvent' ); - evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail ); - return evt; - } - CustomEvent.prototype = w.Event.prototype; - - w.CustomEvent = CustomEvent; - }); - module.exports = CustomEvent; -} - -},{"./window":11}],3:[function(require,module,exports){ -var CustomEvent = require('./custom_event_polyfill'); -var utils = require('./utils'); - -var DropDown = function(list) { - this.currentIndex = 0; - this.hidden = true; - this.list = list; - this.items = []; - this.getItems(); - this.initTemplateString(); - this.addEvents(); - this.initialState = list.innerHTML; -}; - -Object.assign(DropDown.prototype, { - getItems: function() { - this.items = [].slice.call(this.list.querySelectorAll('li')); - return this.items; - }, - - initTemplateString: function() { - var items = this.items || this.getItems(); - - var templateString = ''; - if(items.length > 0) { - templateString = items[items.length - 1].outerHTML; - } - this.templateString = templateString; - return this.templateString; - }, - - clickEvent: function(e) { - // climb up the tree to find the LI - var selected = utils.closest(e.target, 'LI'); - - if(selected) { - e.preventDefault(); - this.hide(); - var listEvent = new CustomEvent('click.dl', { - detail: { - list: this, - selected: selected, - data: e.target.dataset, - }, - }); - this.list.dispatchEvent(listEvent); - } - }, - - addEvents: function() { - this.clickWrapper = this.clickEvent.bind(this); - // event delegation. - this.list.addEventListener('click', this.clickWrapper); - }, - - toggle: function() { - if(this.hidden) { - this.show(); - } else { - this.hide(); - } - }, - - setData: function(data) { - this.data = data; - this.render(data); - }, - - addData: function(data) { - this.data = (this.data || []).concat(data); - this.render(this.data); - }, - - // call render manually on data; - render: function(data){ - // debugger - // empty the list first - var templateString = this.templateString; - var newChildren = []; - var toAppend; - - newChildren = (data ||[]).map(function(dat){ - var html = utils.t(templateString, dat); - var template = document.createElement('div'); - template.innerHTML = html; - - // Help set the image src template - var imageTags = template.querySelectorAll('img[data-src]'); - // debugger - for(var i = 0; i < imageTags.length; i++) { - var imageTag = imageTags[i]; - imageTag.src = imageTag.getAttribute('data-src'); - imageTag.removeAttribute('data-src'); - } - - if(dat.hasOwnProperty('droplab_hidden') && dat.droplab_hidden){ - template.firstChild.style.display = 'none' - }else{ - template.firstChild.style.display = 'block'; - } - return template.firstChild.outerHTML; - }); - toAppend = this.list.querySelector('ul[data-dynamic]'); - if(toAppend) { - toAppend.innerHTML = newChildren.join(''); - } else { - this.list.innerHTML = newChildren.join(''); - } - }, - - show: function() { - if (this.hidden) { - // debugger - this.list.style.display = 'block'; - this.currentIndex = 0; - this.hidden = false; - } - }, - - hide: function() { - if (!this.hidden) { - // debugger - this.list.style.display = 'none'; - this.currentIndex = 0; - this.hidden = true; - } - }, - - destroy: function() { - this.hide(); - this.list.removeEventListener('click', this.clickWrapper); - } -}); - -module.exports = DropDown; - -},{"./custom_event_polyfill":2,"./utils":10}],4:[function(require,module,exports){ -require('./window')(function(w){ - module.exports = function(deps) { - deps = deps || {}; - var window = deps.window || w; - var document = deps.document || window.document; - var CustomEvent = deps.CustomEvent || require('./custom_event_polyfill'); - var HookButton = deps.HookButton || require('./hook_button'); - var HookInput = deps.HookInput || require('./hook_input'); - var utils = deps.utils || require('./utils'); - var DATA_TRIGGER = require('./constants').DATA_TRIGGER; - - var DropLab = function(hook){ - if (!(this instanceof DropLab)) return new DropLab(hook); - this.ready = false; - this.hooks = []; - this.queuedData = []; - this.config = {}; - this.loadWrapper; - if(typeof hook !== 'undefined'){ - this.addHook(hook); - } - }; - - - Object.assign(DropLab.prototype, { - load: function() { - this.loadWrapper(); - }, - - loadWrapper: function(){ - var dropdownTriggers = [].slice.apply(document.querySelectorAll('['+DATA_TRIGGER+']')); - this.addHooks(dropdownTriggers).init(); - }, - - addData: function () { - var args = [].slice.apply(arguments); - this.applyArgs(args, '_addData'); - }, - - setData: function() { - var args = [].slice.apply(arguments); - this.applyArgs(args, '_setData'); - }, - - destroy: function() { - for(var i = 0; i < this.hooks.length; i++) { - this.hooks[i].destroy(); - } - this.hooks = []; - this.removeEvents(); - }, - - applyArgs: function(args, methodName) { - if(this.ready) { - this[methodName].apply(this, args); - } else { - this.queuedData = this.queuedData || []; - this.queuedData.push(args); - } - }, - - _addData: function(trigger, data) { - this._processData(trigger, data, 'addData'); - }, - - _setData: function(trigger, data) { - this._processData(trigger, data, 'setData'); - }, - - _processData: function(trigger, data, methodName) { - for(var i = 0; i < this.hooks.length; i++) { - var hook = this.hooks[i]; - if(hook.trigger.dataset.hasOwnProperty('id')) { - if(hook.trigger.dataset.id === trigger) { - hook.list[methodName](data); - } - } - } - }, - - addEvents: function() { - var self = this; - this.windowClickedWrapper = function(e){ - var thisTag = e.target; - if(thisTag.tagName !== 'UL'){ - // climb up the tree to find the UL - thisTag = utils.closest(thisTag, 'UL'); - } - if(utils.isDropDownParts(thisTag)){ return } - if(utils.isDropDownParts(e.target)){ return } - for(var i = 0; i < self.hooks.length; i++) { - self.hooks[i].list.hide(); - } - }.bind(this); - document.addEventListener('click', this.windowClickedWrapper); - }, - - removeEvents: function(){ - w.removeEventListener('click', this.windowClickedWrapper); - w.removeEventListener('load', this.loadWrapper); - }, - - changeHookList: function(trigger, list, plugins, config) { - trigger = document.querySelector('[data-id="'+trigger+'"]'); - // list = document.querySelector(list); - this.hooks.every(function(hook, i) { - if(hook.trigger === trigger) { - hook.destroy(); - this.hooks.splice(i, 1); - this.addHook(trigger, list, plugins, config); - return false; - } - return true - }.bind(this)); - }, - - addHook: function(hook, list, plugins, config) { - if(!(hook instanceof HTMLElement) && typeof hook === 'string'){ - hook = document.querySelector(hook); - } - if(!list){ - list = document.querySelector(hook.dataset[utils.toDataCamelCase(DATA_TRIGGER)]); - } - - if(hook) { - if(hook.tagName === 'A' || hook.tagName === 'BUTTON') { - this.hooks.push(new HookButton(hook, list, plugins, config)); - } else if(hook.tagName === 'INPUT') { - this.hooks.push(new HookInput(hook, list, plugins, config)); - } - } - return this; - }, - - addHooks: function(hooks, plugins, config) { - for(var i = 0; i < hooks.length; i++) { - var hook = hooks[i]; - this.addHook(hook, null, plugins, config); - } - return this; - }, - - setConfig: function(obj){ - this.config = obj; - }, - - init: function () { - this.addEvents(); - var readyEvent = new CustomEvent('ready.dl', { - detail: { - dropdown: this, - }, - }); - window.dispatchEvent(readyEvent); - this.ready = true; - for(var i = 0; i < this.queuedData.length; i++) { - this.addData.apply(this, this.queuedData[i]); - } - this.queuedData = []; - return this; - }, - }); - - return DropLab; - }; -}); - -},{"./constants":1,"./custom_event_polyfill":2,"./hook_button":6,"./hook_input":7,"./utils":10,"./window":11}],5:[function(require,module,exports){ -var DropDown = require('./dropdown'); - -var Hook = function(trigger, list, plugins, config){ - this.trigger = trigger; - this.list = new DropDown(list); - this.type = 'Hook'; - this.event = 'click'; - this.plugins = plugins || []; - this.config = config || {}; - this.id = trigger.dataset.id; -}; - -Object.assign(Hook.prototype, { - - addEvents: function(){}, - - constructor: Hook, -}); - -module.exports = Hook; - -},{"./dropdown":3}],6:[function(require,module,exports){ -var CustomEvent = require('./custom_event_polyfill'); -var Hook = require('./hook'); - -var HookButton = function(trigger, list, plugins, config) { - Hook.call(this, trigger, list, plugins, config); - this.type = 'button'; - this.event = 'click'; - this.addEvents(); - this.addPlugins(); -}; - -HookButton.prototype = Object.create(Hook.prototype); - -Object.assign(HookButton.prototype, { - addPlugins: function() { - for(var i = 0; i < this.plugins.length; i++) { - this.plugins[i].init(this); - } - }, - - clicked: function(e){ - var buttonEvent = new CustomEvent('click.dl', { - detail: { - hook: this, - }, - bubbles: true, - cancelable: true - }); - this.list.show(); - e.target.dispatchEvent(buttonEvent); - }, - - addEvents: function(){ - this.clickedWrapper = this.clicked.bind(this); - this.trigger.addEventListener('click', this.clickedWrapper); - }, - - removeEvents: function(){ - this.trigger.removeEventListener('click', this.clickedWrapper); - }, - - restoreInitialState: function() { - this.list.list.innerHTML = this.list.initialState; - }, - - removePlugins: function() { - for(var i = 0; i < this.plugins.length; i++) { - this.plugins[i].destroy(); - } - }, - - destroy: function() { - this.restoreInitialState(); - this.removeEvents(); - this.removePlugins(); - }, - - - constructor: HookButton, -}); - - -module.exports = HookButton; - -},{"./custom_event_polyfill":2,"./hook":5}],7:[function(require,module,exports){ -var CustomEvent = require('./custom_event_polyfill'); -var Hook = require('./hook'); - -var HookInput = function(trigger, list, plugins, config) { - Hook.call(this, trigger, list, plugins, config); - this.type = 'input'; - this.event = 'input'; - this.addPlugins(); - this.addEvents(); -}; - -Object.assign(HookInput.prototype, { - addPlugins: function() { - var self = this; - for(var i = 0; i < this.plugins.length; i++) { - this.plugins[i].init(self); - } - }, - - addEvents: function(){ - var self = this; - - this.mousedown = function mousedown(e) { - if(self.hasRemovedEvents) return; - - var mouseEvent = new CustomEvent('mousedown.dl', { - detail: { - hook: self, - text: e.target.value, - }, - bubbles: true, - cancelable: true - }); - e.target.dispatchEvent(mouseEvent); - } - - this.input = function input(e) { - if(self.hasRemovedEvents) return; - - self.list.show(); - - var inputEvent = new CustomEvent('input.dl', { - detail: { - hook: self, - text: e.target.value, - }, - bubbles: true, - cancelable: true - }); - e.target.dispatchEvent(inputEvent); - } - - this.keyup = function keyup(e) { - if(self.hasRemovedEvents) return; - - keyEvent(e, 'keyup.dl'); - } - - this.keydown = function keydown(e) { - if(self.hasRemovedEvents) return; - - keyEvent(e, 'keydown.dl'); - } - - function keyEvent(e, keyEventName){ - self.list.show(); - - var keyEvent = new CustomEvent(keyEventName, { - detail: { - hook: self, - text: e.target.value, - which: e.which, - key: e.key, - }, - bubbles: true, - cancelable: true - }); - e.target.dispatchEvent(keyEvent); - } - - this.events = this.events || {}; - this.events.mousedown = this.mousedown; - this.events.input = this.input; - this.events.keyup = this.keyup; - this.events.keydown = this.keydown; - this.trigger.addEventListener('mousedown', this.mousedown); - this.trigger.addEventListener('input', this.input); - this.trigger.addEventListener('keyup', this.keyup); - this.trigger.addEventListener('keydown', this.keydown); - }, - - removeEvents: function() { - this.hasRemovedEvents = true; - this.trigger.removeEventListener('mousedown', this.mousedown); - this.trigger.removeEventListener('input', this.input); - this.trigger.removeEventListener('keyup', this.keyup); - this.trigger.removeEventListener('keydown', this.keydown); - }, - - restoreInitialState: function() { - this.list.list.innerHTML = this.list.initialState; - }, - - removePlugins: function() { - for(var i = 0; i < this.plugins.length; i++) { - this.plugins[i].destroy(); - } - }, - - destroy: function() { - this.restoreInitialState(); - this.removeEvents(); - this.removePlugins(); - this.list.destroy(); - } -}); - -module.exports = HookInput; - -},{"./custom_event_polyfill":2,"./hook":5}],8:[function(require,module,exports){ -var DropLab = require('./droplab')(); -var DATA_TRIGGER = require('./constants').DATA_TRIGGER; -var keyboard = require('./keyboard')(); -var setup = function() { - window.DropLab = DropLab; -}; - - -module.exports = setup(); - -},{"./constants":1,"./droplab":4,"./keyboard":9}],9:[function(require,module,exports){ -require('./window')(function(w){ - module.exports = function(){ - var currentKey; - var currentFocus; - var isUpArrow = false; - var isDownArrow = false; - var removeHighlight = function removeHighlight(list) { - var listItems = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0); - var listItemsTmp = []; - for(var i = 0; i < listItems.length; i++) { - var listItem = listItems[i]; - listItem.classList.remove('dropdown-active'); - - if (listItem.style.display !== 'none') { - listItemsTmp.push(listItem); - } - } - return listItemsTmp; - }; - - var setMenuForArrows = function setMenuForArrows(list) { - var listItems = removeHighlight(list); - if(list.currentIndex>0){ - if(!listItems[list.currentIndex-1]){ - list.currentIndex = list.currentIndex-1; - } - - if (listItems[list.currentIndex-1]) { - var el = listItems[list.currentIndex-1]; - var filterDropdownEl = el.closest('.filter-dropdown'); - el.classList.add('dropdown-active'); - - if (filterDropdownEl) { - var filterDropdownBottom = filterDropdownEl.offsetHeight; - var elOffsetTop = el.offsetTop - 30; - - if (elOffsetTop > filterDropdownBottom) { - filterDropdownEl.scrollTop = elOffsetTop - filterDropdownBottom; - } - } - } - } - }; - - var mousedown = function mousedown(e) { - var list = e.detail.hook.list; - removeHighlight(list); - list.show(); - list.currentIndex = 0; - isUpArrow = false; - isDownArrow = false; - }; - var selectItem = function selectItem(list) { - var listItems = removeHighlight(list); - var currentItem = listItems[list.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; - var list = e.detail.hook.list; - var currentIndex = list.currentIndex; - 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; } - list.currentIndex = currentIndex; - 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 && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML'){ - thisTag = thisTag.parentNode; - } - return thisTag; -}; - -var isDropDownParts = function(target) { - if(!target || 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 deleted file mode 100644 index 020f8b4ac65..00000000000 --- a/app/assets/javascripts/droplab/droplab_ajax.js +++ /dev/null @@ -1,103 +0,0 @@ -/* eslint-disable */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -/* global droplab */ - -require('../window')(function(w){ - function droplabAjaxException(message) { - this.message = message; - } - - w.droplabAjax = { - _loadUrlData: function _loadUrlData(url) { - var self = this; - 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); - self.cache[url] = data; - return resolve(data); - } else { - return reject([xhr.responseText, xhr.status]); - } - } - }; - xhr.send(); - }); - }, - - _loadData: function _loadData(data, config, self) { - if (config.loadingTemplate) { - var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]'); - - if (dataLoadingTemplate) { - dataLoadingTemplate.outerHTML = self.listTemplate; - } - } - - if (!self.destroyed) { - self.hook.list[config.method].call(self.hook.list, data); - } - }, - - init: function init(hook) { - var self = this; - self.destroyed = false; - self.cache = self.cache || {}; - var config = hook.config.droplabAjax; - this.hook = hook; - - if (!config || !config.endpoint || !config.method) { - return; - } - - if (config.method !== 'setData' && config.method !== 'addData') { - return; - } - - if (config.loadingTemplate) { - var dynamicList = hook.list.list.querySelector('[data-dynamic]'); - - var loadingTemplate = document.createElement('div'); - loadingTemplate.innerHTML = config.loadingTemplate; - loadingTemplate.setAttribute('data-loading-template', ''); - - this.listTemplate = dynamicList.outerHTML; - dynamicList.outerHTML = loadingTemplate.outerHTML; - } - - if (self.cache[config.endpoint]) { - self._loadData(self.cache[config.endpoint], config, self); - } else { - this._loadUrlData(config.endpoint) - .then(function(d) { - self._loadData(d, config, self); - }, function(xhrError) { - // TODO: properly handle errors due to XHR cancellation - return; - }).catch(function(e) { - throw new droplabAjaxException(e.message || e); - }); - } - }, - - destroy: function() { - var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); - this.destroyed = true; - if (this.listTemplate && dynamicList) { - dynamicList.outerHTML = this.listTemplate; - } - } - }; -}); -},{"../window":2}],2:[function(require,module,exports){ -module.exports = function(callback) { - return (function() { - callback(this); - }).call(null); -}; - -},{}]},{},[1])(1) -}); diff --git a/app/assets/javascripts/droplab/droplab_ajax_filter.js b/app/assets/javascripts/droplab/droplab_ajax_filter.js deleted file mode 100644 index 05eba7aef56..00000000000 --- a/app/assets/javascripts/droplab/droplab_ajax_filter.js +++ /dev/null @@ -1,164 +0,0 @@ -/* eslint-disable */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -/* global droplab */ - -require('../window')(function(w){ - w.droplabAjaxFilter = { - init: function(hook) { - this.destroyed = false; - this.hook = hook; - this.notLoading(); - - this.debounceTriggerWrapper = this.debounceTrigger.bind(this); - this.hook.trigger.addEventListener('keydown.dl', this.debounceTriggerWrapper); - this.hook.trigger.addEventListener('focus', this.debounceTriggerWrapper); - this.trigger(true); - }, - - notLoading: function notLoading() { - this.loading = false; - }, - - debounceTrigger: function debounceTrigger(e) { - var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93]; - var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1; - var focusEvent = e.type === 'focus'; - - if (invalidKeyPressed || this.loading) { - return; - } - - if (this.timeout) { - clearTimeout(this.timeout); - } - - this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200); - }, - - trigger: function trigger(getEntireList) { - var config = this.hook.config.droplabAjaxFilter; - var searchValue = this.trigger.value; - - if (!config || !config.endpoint || !config.searchKey) { - return; - } - - if (config.searchValueFunction) { - searchValue = config.searchValueFunction(); - } - - if (config.loadingTemplate && this.hook.list.data === undefined || - this.hook.list.data.length === 0) { - var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); - - var loadingTemplate = document.createElement('div'); - loadingTemplate.innerHTML = config.loadingTemplate; - loadingTemplate.setAttribute('data-loading-template', true); - - this.listTemplate = dynamicList.outerHTML; - dynamicList.outerHTML = loadingTemplate.outerHTML; - } - - if (getEntireList) { - searchValue = ''; - } - - if (config.searchKey === searchValue) { - return this.list.show(); - } - - this.loading = true; - - var params = config.params || {}; - params[config.searchKey] = searchValue; - var self = this; - self.cache = self.cache || {}; - var url = config.endpoint + this.buildParams(params); - var urlCachedData = self.cache[url]; - - if (urlCachedData) { - self._loadData(urlCachedData, config, self); - } else { - this._loadUrlData(url) - .then(function(data) { - self._loadData(data, config, self); - }, function(xhrError) { - // TODO: properly handle errors due to XHR cancellation - return; - }); - } - }, - - _loadUrlData: function _loadUrlData(url) { - var self = this; - 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); - self.cache[url] = data; - return resolve(data); - } else { - return reject([xhr.responseText, xhr.status]); - } - } - }; - xhr.send(); - }); - }, - - _loadData: function _loadData(data, config, self) { - 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(); - self.hook.list.currentIndex = 0; - }, - - 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) -}); diff --git a/app/assets/javascripts/droplab/droplab_filter.js b/app/assets/javascripts/droplab/droplab_filter.js deleted file mode 100644 index 7f7d93f3e27..00000000000 --- a/app/assets/javascripts/droplab/droplab_filter.js +++ /dev/null @@ -1,76 +0,0 @@ -/* eslint-disable */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.filter||(g.filter = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -/* global droplab */ - -require('../window')(function(w){ - w.droplabFilter = { - - keydownWrapper: function(e){ - var hiddenCount = 0; - var dataHiddenCount = 0; - var list = e.detail.hook.list; - var data = list.data; - var value = e.detail.hook.trigger.value.toLowerCase(); - var config = e.detail.hook.config.droplabFilter; - var matches = []; - var filterFunction; - // will only work on dynamically set data - if(!data){ - return; - } - - if (config && config.filterFunction && typeof config.filterFunction === 'function') { - filterFunction = config.filterFunction; - } else { - filterFunction = function(o){ - // cheap string search - o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1; - return o; - }; - } - - dataHiddenCount = data.filter(function(o) { - return !o.droplab_hidden; - }).length; - - matches = data.map(function(o) { - return filterFunction(o, value); - }); - - hiddenCount = matches.filter(function(o) { - return !o.droplab_hidden; - }).length; - - if (dataHiddenCount !== hiddenCount) { - list.render(matches); - list.currentIndex = 0; - } - }, - - init: function init(hookInput) { - var config = hookInput.config.droplabFilter; - - if (!config || (!config.template && !config.filterFunction)) { - return; - } - - this.hookInput = hookInput; - this.hookInput.trigger.addEventListener('keyup.dl', this.keydownWrapper); - this.hookInput.trigger.addEventListener('mousedown.dl', this.keydownWrapper); - }, - - destroy: function destroy(){ - this.hookInput.trigger.removeEventListener('keyup.dl', this.keydownWrapper); - this.hookInput.trigger.removeEventListener('mousedown.dl', this.keydownWrapper); - } - }; -}); -},{"../window":2}],2:[function(require,module,exports){ -module.exports = function(callback) { - return (function() { - callback(this); - }).call(null); -}; - -},{}]},{},[1])(1) -}); diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js new file mode 100644 index 00000000000..cf78165b0d8 --- /dev/null +++ b/app/assets/javascripts/droplab/hook.js @@ -0,0 +1,15 @@ +import DropDown from './drop_down'; + +class Hook { + constructor(trigger, list, plugins, config) { + this.trigger = trigger; + this.list = new DropDown(list); + this.type = 'Hook'; + this.event = 'click'; + this.plugins = plugins || []; + this.config = config || {}; + this.id = trigger.id; + } +} + +export default Hook; diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js new file mode 100644 index 00000000000..af45eba74e7 --- /dev/null +++ b/app/assets/javascripts/droplab/hook_button.js @@ -0,0 +1,58 @@ +import Hook from './hook'; + +class HookButton extends Hook { + constructor(trigger, list, plugins, config) { + super(trigger, list, plugins, config); + + this.type = 'button'; + this.event = 'click'; + + this.eventWrapper = {}; + + this.addEvents(); + this.addPlugins(); + } + + addPlugins() { + this.plugins.forEach(plugin => plugin.init(this)); + } + + clicked(e) { + const buttonEvent = new CustomEvent('click.dl', { + detail: { + hook: this, + }, + bubbles: true, + cancelable: true, + }); + e.target.dispatchEvent(buttonEvent); + + this.list.toggle(); + } + + addEvents() { + this.eventWrapper.clicked = this.clicked.bind(this); + this.trigger.addEventListener('click', this.eventWrapper.clicked); + } + + removeEvents() { + this.trigger.removeEventListener('click', this.eventWrapper.clicked); + } + + restoreInitialState() { + this.list.list.innerHTML = this.list.initialState; + } + + removePlugins() { + this.plugins.forEach(plugin => plugin.destroy()); + } + + destroy() { + this.restoreInitialState(); + + this.removeEvents(); + this.removePlugins(); + } +} + +export default HookButton; diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/droplab/hook_input.js new file mode 100644 index 00000000000..19131a64f2c --- /dev/null +++ b/app/assets/javascripts/droplab/hook_input.js @@ -0,0 +1,117 @@ +import Hook from './hook'; + +class HookInput extends Hook { + constructor(trigger, list, plugins, config) { + super(trigger, list, plugins, config); + + this.type = 'input'; + this.event = 'input'; + + this.eventWrapper = {}; + + this.addEvents(); + this.addPlugins(); + } + + addPlugins() { + this.plugins.forEach(plugin => plugin.init(this)); + } + + addEvents() { + this.eventWrapper.mousedown = this.mousedown.bind(this); + this.eventWrapper.input = this.input.bind(this); + this.eventWrapper.keyup = this.keyup.bind(this); + this.eventWrapper.keydown = this.keydown.bind(this); + + this.trigger.addEventListener('mousedown', this.eventWrapper.mousedown); + this.trigger.addEventListener('input', this.eventWrapper.input); + this.trigger.addEventListener('keyup', this.eventWrapper.keyup); + this.trigger.addEventListener('keydown', this.eventWrapper.keydown); + } + + removeEvents() { + this.hasRemovedEvents = true; + + this.trigger.removeEventListener('mousedown', this.eventWrapper.mousedown); + this.trigger.removeEventListener('input', this.eventWrapper.input); + this.trigger.removeEventListener('keyup', this.eventWrapper.keyup); + this.trigger.removeEventListener('keydown', this.eventWrapper.keydown); + } + + input(e) { + if (this.hasRemovedEvents) return; + + this.list.show(); + + const inputEvent = new CustomEvent('input.dl', { + detail: { + hook: this, + text: e.target.value, + }, + bubbles: true, + cancelable: true, + }); + e.target.dispatchEvent(inputEvent); + } + + mousedown(e) { + if (this.hasRemovedEvents) return; + + const mouseEvent = new CustomEvent('mousedown.dl', { + detail: { + hook: this, + text: e.target.value, + }, + bubbles: true, + cancelable: true, + }); + e.target.dispatchEvent(mouseEvent); + } + + keyup(e) { + if (this.hasRemovedEvents) return; + + this.keyEvent(e, 'keyup.dl'); + } + + keydown(e) { + if (this.hasRemovedEvents) return; + + this.keyEvent(e, 'keydown.dl'); + } + + keyEvent(e, eventName) { + this.list.show(); + + const keyEvent = new CustomEvent(eventName, { + detail: { + hook: this, + text: e.target.value, + which: e.which, + key: e.key, + }, + bubbles: true, + cancelable: true, + }); + e.target.dispatchEvent(keyEvent); + } + + restoreInitialState() { + this.list.list.innerHTML = this.list.initialState; + } + + removePlugins() { + this.plugins.forEach(plugin => plugin.destroy()); + } + + destroy() { + this.restoreInitialState(); + + this.removeEvents(); + this.removePlugins(); + + this.list.destroy(); + } +} + +export default HookInput; diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js new file mode 100644 index 00000000000..36740a430e1 --- /dev/null +++ b/app/assets/javascripts/droplab/keyboard.js @@ -0,0 +1,113 @@ +/* eslint-disable */ + +import { ACTIVE_CLASS } from './constants'; + +const Keyboard = function () { + var currentKey; + var currentFocus; + var isUpArrow = false; + var isDownArrow = false; + var removeHighlight = function removeHighlight(list) { + var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0); + var listItems = []; + for(var i = 0; i < itemElements.length; i++) { + var listItem = itemElements[i]; + listItem.classList.remove(ACTIVE_CLASS); + + if (listItem.style.display !== 'none') { + listItems.push(listItem); + } + } + return listItems; + }; + + var setMenuForArrows = function setMenuForArrows(list) { + var listItems = removeHighlight(list); + if(list.currentIndex>0){ + if(!listItems[list.currentIndex-1]){ + list.currentIndex = list.currentIndex-1; + } + + if (listItems[list.currentIndex-1]) { + var el = listItems[list.currentIndex-1]; + var filterDropdownEl = el.closest('.filter-dropdown'); + el.classList.add(ACTIVE_CLASS); + + if (filterDropdownEl) { + var filterDropdownBottom = filterDropdownEl.offsetHeight; + var elOffsetTop = el.offsetTop - 30; + + if (elOffsetTop > filterDropdownBottom) { + filterDropdownEl.scrollTop = elOffsetTop - filterDropdownBottom; + } + } + } + } + }; + + var mousedown = function mousedown(e) { + var list = e.detail.hook.list; + removeHighlight(list); + list.show(); + list.currentIndex = 0; + isUpArrow = false; + isDownArrow = false; + }; + var selectItem = function selectItem(list) { + var listItems = removeHighlight(list); + var currentItem = listItems[list.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; + var list = e.detail.hook.list; + var currentIndex = list.currentIndex; + 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; } + list.currentIndex = currentIndex; + setMenuForArrows(e.detail.hook.list); + }; + + document.addEventListener('mousedown.dl', mousedown); + document.addEventListener('keydown.dl', keydown); +}; + +export default Keyboard; diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js new file mode 100644 index 00000000000..c0da5866139 --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/ajax.js @@ -0,0 +1,43 @@ +/* eslint-disable */ + +import AjaxCache from '~/lib/utils/ajax_cache'; + +const Ajax = { + _loadData: function _loadData(data, config, self) { + if (config.loadingTemplate) { + var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]'); + if (dataLoadingTemplate) dataLoadingTemplate.outerHTML = self.listTemplate; + } + + if (!self.destroyed) self.hook.list[config.method].call(self.hook.list, data); + }, + init: function init(hook) { + var self = this; + self.destroyed = false; + var config = hook.config.Ajax; + this.hook = hook; + if (!config || !config.endpoint || !config.method) { + return; + } + if (config.method !== 'setData' && config.method !== 'addData') { + return; + } + if (config.loadingTemplate) { + var dynamicList = hook.list.list.querySelector('[data-dynamic]'); + var loadingTemplate = document.createElement('div'); + loadingTemplate.innerHTML = config.loadingTemplate; + loadingTemplate.setAttribute('data-loading-template', ''); + this.listTemplate = dynamicList.outerHTML; + dynamicList.outerHTML = loadingTemplate.outerHTML; + } + + AjaxCache.retrieve(config.endpoint) + .then((data) => self._loadData(data, config, self)) + .catch(config.onError); + }, + destroy: function() { + this.destroyed = true; + } +}; + +export default Ajax; diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js new file mode 100644 index 00000000000..cfd7e2ca189 --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js @@ -0,0 +1,133 @@ +/* eslint-disable */ + +const AjaxFilter = { + init: function(hook) { + this.destroyed = false; + this.hook = hook; + this.notLoading(); + + this.eventWrapper = {}; + this.eventWrapper.debounceTrigger = this.debounceTrigger.bind(this); + this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceTrigger); + this.hook.trigger.addEventListener('focus', this.eventWrapper.debounceTrigger); + + this.trigger(true); + }, + + notLoading: function notLoading() { + this.loading = false; + }, + + debounceTrigger: function debounceTrigger(e) { + var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93]; + var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1; + var focusEvent = e.type === 'focus'; + if (invalidKeyPressed || this.loading) { + return; + } + if (this.timeout) { + clearTimeout(this.timeout); + } + this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200); + }, + + trigger: function trigger(getEntireList) { + var config = this.hook.config.AjaxFilter; + var searchValue = this.trigger.value; + if (!config || !config.endpoint || !config.searchKey) { + return; + } + if (config.searchValueFunction) { + searchValue = config.searchValueFunction(); + } + if (config.loadingTemplate && this.hook.list.data === undefined || + this.hook.list.data.length === 0) { + var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); + var loadingTemplate = document.createElement('div'); + loadingTemplate.innerHTML = config.loadingTemplate; + loadingTemplate.setAttribute('data-loading-template', true); + this.listTemplate = dynamicList.outerHTML; + dynamicList.outerHTML = loadingTemplate.outerHTML; + } + if (getEntireList) { + searchValue = ''; + } + if (config.searchKey === searchValue) { + return this.list.show(); + } + this.loading = true; + var params = config.params || {}; + params[config.searchKey] = searchValue; + var self = this; + self.cache = self.cache || {}; + var url = config.endpoint + this.buildParams(params); + var urlCachedData = self.cache[url]; + if (urlCachedData) { + self._loadData(urlCachedData, config, self); + } else { + this._loadUrlData(url) + .then(function(data) { + self._loadData(data, config, self); + }, config.onError).catch(config.onError); + } + }, + + _loadUrlData: function _loadUrlData(url) { + var self = this; + 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); + self.cache[url] = data; + return resolve(data); + } else { + return reject([xhr.responseText, xhr.status]); + } + } + }; + xhr.send(); + }); + }, + + _loadData: function _loadData(data, config, self) { + const list = self.hook.list; + if (config.loadingTemplate && list.data === undefined || + list.data.length === 0) { + const dataLoadingTemplate = list.list.querySelector('[data-loading-template]'); + if (dataLoadingTemplate) { + dataLoadingTemplate.outerHTML = self.listTemplate; + } + } + if (!self.destroyed) { + var hookListChildren = list.list.children; + var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic'); + if (onlyDynamicList && data.length === 0) { + list.hide(); + } + list.setData.call(list, data); + } + self.notLoading(); + list.currentIndex = 0; + }, + + 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.eventWrapper.debounceTrigger); + this.hook.trigger.removeEventListener('focus', this.eventWrapper.debounceTrigger); + } +}; + +export default AjaxFilter; diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/droplab/plugins/filter.js new file mode 100644 index 00000000000..d6a1aadd49c --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/filter.js @@ -0,0 +1,95 @@ +/* eslint-disable */ + +const Filter = { + keydown: function(e){ + if (this.destroyed) return; + + var hiddenCount = 0; + var dataHiddenCount = 0; + + var list = e.detail.hook.list; + var data = list.data; + var value = e.detail.hook.trigger.value.toLowerCase(); + var config = e.detail.hook.config.Filter; + var matches = []; + var filterFunction; + // will only work on dynamically set data + if(!data){ + return; + } + + if (config && config.filterFunction && typeof config.filterFunction === 'function') { + filterFunction = config.filterFunction; + } else { + filterFunction = function(o){ + // cheap string search + o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1; + return o; + }; + } + + dataHiddenCount = data.filter(function(o) { + return !o.droplab_hidden; + }).length; + + matches = data.map(function(o) { + return filterFunction(o, value); + }); + + hiddenCount = matches.filter(function(o) { + return !o.droplab_hidden; + }).length; + + if (dataHiddenCount !== hiddenCount) { + list.setData(matches); + list.currentIndex = 0; + } + }, + + debounceKeydown: function debounceKeydown(e) { + if ([ + 13, // enter + 16, // shift + 17, // ctrl + 18, // alt + 20, // caps lock + 37, // left arrow + 38, // up arrow + 39, // right arrow + 40, // down arrow + 91, // left window + 92, // right window + 93, // select + ].indexOf(e.detail.which || e.detail.keyCode) > -1) return; + + if (this.timeout) clearTimeout(this.timeout); + this.timeout = setTimeout(this.keydown.bind(this, e), 200); + }, + + init: function init(hook) { + var config = hook.config.Filter; + + if (!config || !config.template) return; + + this.hook = hook; + this.destroyed = false; + + this.eventWrapper = {}; + this.eventWrapper.debounceKeydown = this.debounceKeydown.bind(this); + + this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceKeydown); + this.hook.trigger.addEventListener('mousedown.dl', this.eventWrapper.debounceKeydown); + + this.debounceKeydown({ detail: { hook: this.hook } }); + }, + + destroy: function destroy() { + if (this.timeout) clearTimeout(this.timeout); + this.destroyed = true; + + this.hook.trigger.removeEventListener('keydown.dl', this.eventWrapper.debounceKeydown); + this.hook.trigger.removeEventListener('mousedown.dl', this.eventWrapper.debounceKeydown); + } +}; + +export default Filter; diff --git a/app/assets/javascripts/droplab/plugins/input_setter.js b/app/assets/javascripts/droplab/plugins/input_setter.js new file mode 100644 index 00000000000..d01fbc5830d --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/input_setter.js @@ -0,0 +1,50 @@ +/* eslint-disable */ + +const InputSetter = { + init(hook) { + this.hook = hook; + this.destroyed = false; + this.config = hook.config.InputSetter || (this.hook.config.InputSetter = {}); + + this.eventWrapper = {}; + + this.addEvents(); + }, + + addEvents() { + this.eventWrapper.setInputs = this.setInputs.bind(this); + this.hook.list.list.addEventListener('click.dl', this.eventWrapper.setInputs); + }, + + removeEvents() { + this.hook.list.list.removeEventListener('click.dl', this.eventWrapper.setInputs); + }, + + setInputs(e) { + if (this.destroyed) return; + + const selectedItem = e.detail.selected; + + if (!Array.isArray(this.config)) this.config = [this.config]; + + this.config.forEach(config => this.setInput(config, selectedItem)); + }, + + setInput(config, selectedItem) { + const input = config.input || this.hook.trigger; + const newValue = selectedItem.getAttribute(config.valueAttribute); + const inputAttribute = config.inputAttribute; + + if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue); + if (input.tagName === 'INPUT') return input.value = newValue; + return input.textContent = newValue; + }, + + destroy() { + this.destroyed = true; + + this.removeEvents(); + }, +}; + +export default InputSetter; diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js new file mode 100644 index 00000000000..4da7344604e --- /dev/null +++ b/app/assets/javascripts/droplab/utils.js @@ -0,0 +1,38 @@ +/* eslint-disable */ + +import { template as _template } from 'underscore'; +import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants'; + +const utils = { + toCamelCase(attr) { + return this.camelize(attr.split('-').slice(1).join(' ')); + }, + + template(templateString, data) { + const template = _template(templateString, { + escape: TEMPLATE_REGEX, + }); + + return template(data); + }, + + camelize(str) { + return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => { + return index === 0 ? letter.toLowerCase() : letter.toUpperCase(); + }).replace(/\s+/g, ''); + }, + + closest(thisTag, stopTag) { + while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') { + thisTag = thisTag.parentNode; + } + return thisTag; + }, + + isDropDownParts(target) { + if (!target || target.tagName === 'HTML') return false; + return target.hasAttribute(DATA_TRIGGER) || target.hasAttribute(DATA_DROPDOWN); + }, +}; + +export default utils; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f2963a5eb19..266cd3966c6 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -1,102 +1,158 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, max-len, one-var, no-var, one-var-declaration-per-line, no-unused-vars, camelcase, quotes, no-useless-concat, prefer-template, quote-props, comma-dangle, object-shorthand, consistent-return, prefer-arrow-callback */ /* global Dropzone */ -require('./preview_markdown'); +import './preview_markdown'; window.DropzoneInput = (function() { function DropzoneInput(form) { - var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, project_uploads_path, showError, showSpinner, uploadFile, uploadProgress; + var updateAttachingMessage, $attachingFileMessage, $mdArea, $attachButton, $cancelButton, $retryLink, $uploadingErrorContainer, $uploadingErrorMessage, $uploadProgress, $uploadingProgressContainer, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divHover, divSpinner, dropzone, $formDropzone, formTextarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, maxFileSize, pasteText, uploadsPath, showError, showSpinner, uploadFile; Dropzone.autoDiscover = false; - alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; - alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; - divHover = "<div class=\"div-dropzone-hover\"></div>"; - divSpinner = "<div class=\"div-dropzone-spinner\"></div>"; - divAlert = "<div class=\"" + alertClass + "\"></div>"; - iconPaperclip = "<i class=\"fa fa-paperclip div-dropzone-icon\"></i>"; - iconSpinner = "<i class=\"fa fa-spinner fa-spin div-dropzone-icon\"></i>"; - uploadProgress = $("<div class=\"div-dropzone-progress\"></div>"); - btnAlert = "<button type=\"button\"" + alertAttr + ">×</button>"; - project_uploads_path = window.project_uploads_path || null; - max_file_size = gon.max_file_size || 10; - form_textarea = $(form).find(".js-gfm-input"); - form_textarea.wrap("<div class=\"div-dropzone\"></div>"); - form_textarea.on('paste', (function(_this) { + divHover = '<div class="div-dropzone-hover"></div>'; + iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>'; + $attachButton = form.find('.button-attach-file'); + $attachingFileMessage = form.find('.attaching-file-message'); + $cancelButton = form.find('.button-cancel-uploading-files'); + $retryLink = form.find('.retry-uploading-link'); + $uploadProgress = form.find('.uploading-progress'); + $uploadingErrorContainer = form.find('.uploading-error-container'); + $uploadingErrorMessage = form.find('.uploading-error-message'); + $uploadingProgressContainer = form.find('.uploading-progress-container'); + uploadsPath = window.uploads_path || null; + maxFileSize = gon.max_file_size || 10; + formTextarea = form.find('.js-gfm-input'); + formTextarea.wrap('<div class="div-dropzone"></div>'); + formTextarea.on('paste', (function(_this) { return function(event) { return handlePaste(event); }; })(this)); - $mdArea = $(form_textarea).closest('.md-area'); - $(form).setupMarkdownPreview(); - form_dropzone = $(form).find('.div-dropzone'); - form_dropzone.parent().addClass("div-dropzone-wrapper"); - form_dropzone.append(divHover); - form_dropzone.find(".div-dropzone-hover").append(iconPaperclip); - form_dropzone.append(divSpinner); - form_dropzone.find(".div-dropzone-spinner").append(iconSpinner); - form_dropzone.find(".div-dropzone-spinner").append(uploadProgress); - form_dropzone.find(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); - dropzone = form_dropzone.dropzone({ - url: project_uploads_path, - dictDefaultMessage: "", + + // Add dropzone area to the form. + $mdArea = formTextarea.closest('.md-area'); + form.setupMarkdownPreview(); + $formDropzone = form.find('.div-dropzone'); + $formDropzone.parent().addClass('div-dropzone-wrapper'); + $formDropzone.append(divHover); + $formDropzone.find('.div-dropzone-hover').append(iconPaperclip); + + if (!uploadsPath) return; + + dropzone = $formDropzone.dropzone({ + url: uploadsPath, + dictDefaultMessage: '', clickable: true, - paramName: "file", - maxFilesize: max_file_size, + paramName: 'file', + maxFilesize: maxFileSize, uploadMultiple: false, headers: { - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') }, previewContainer: false, processing: function() { - return $(".div-dropzone-alert").alert("close"); + return $('.div-dropzone-alert').alert('close'); }, dragover: function() { $mdArea.addClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0.7); + form.find('.div-dropzone-hover').css('opacity', 0.7); }, dragleave: function() { $mdArea.removeClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0); + form.find('.div-dropzone-hover').css('opacity', 0); }, drop: function() { $mdArea.removeClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0); - form_textarea.focus(); + form.find('.div-dropzone-hover').css('opacity', 0); + formTextarea.focus(); }, success: function(header, response) { - pasteText(response.link.markdown); + const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length; + const shouldPad = processingFileCount >= 1; + + pasteText(response.link.markdown, shouldPad); + // Show 'Attach a file' link only when all files have been uploaded. + if (!processingFileCount) $attachButton.removeClass('hide'); }, - error: function(temp) { - var checkIfMsgExists, errorAlert; - errorAlert = $(form).find('.error-alert'); - checkIfMsgExists = errorAlert.children().length; - if (checkIfMsgExists === 0) { - errorAlert.append(divAlert); - $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed."); - } + error: function(file, errorMessage = 'Attaching the file failed.', xhr) { + // If 'error' event is fired by dropzone, the second parameter is error message. + // If the 'errorMessage' parameter is empty, the default error message is set. + // If the 'error' event is fired by backend (xhr) error response, the third parameter is + // xhr object (xhr.responseText is error message). + // On error we hide the 'Attach' and 'Cancel' buttons + // and show an error. + + // If there's xhr error message, let's show it instead of dropzone's one. + const message = xhr ? xhr.responseText : errorMessage; + + $uploadingErrorContainer.removeClass('hide'); + $uploadingErrorMessage.html(message); + $attachButton.addClass('hide'); + $cancelButton.addClass('hide'); }, totaluploadprogress: function(totalUploadProgress) { - uploadProgress.text(Math.round(totalUploadProgress) + "%"); + updateAttachingMessage(this.files, $attachingFileMessage); + $uploadProgress.text(Math.round(totalUploadProgress) + '%'); + }, + sending: function(file) { + // DOM elements already exist. + // Instead of dynamically generating them, + // we just either hide or show them. + $attachButton.addClass('hide'); + $uploadingErrorContainer.addClass('hide'); + $uploadingProgressContainer.removeClass('hide'); + $cancelButton.removeClass('hide'); }, - sending: function() { - form_dropzone.find(".div-dropzone-spinner").css({ - "opacity": 0.7, - "display": "inherit" - }); + removedfile: function() { + $attachButton.removeClass('hide'); + $cancelButton.addClass('hide'); + $uploadingProgressContainer.addClass('hide'); + $uploadingErrorContainer.addClass('hide'); }, queuecomplete: function() { - uploadProgress.text(""); - $(".dz-preview").remove(); - $(".markdown-area").trigger("input"); - $(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); + $('.dz-preview').remove(); + $('.markdown-area').trigger('input'); + + $uploadingProgressContainer.addClass('hide'); + $cancelButton.addClass('hide'); } }); - child = $(dropzone[0]).children("textarea"); + + child = $(dropzone[0]).children('textarea'); + + // removeAllFiles(true) stops uploading files (if any) + // and remove them from dropzone files queue. + $cancelButton.on('click', (e) => { + const target = e.target.closest('form').querySelector('.div-dropzone'); + + e.preventDefault(); + e.stopPropagation(); + Dropzone.forElement(target).removeAllFiles(true); + }); + + // If 'error' event is fired, we store a failed files, + // clear dropzone files queue, change status of failed files to undefined, + // and add that files to the dropzone files queue again. + // addFile() adds file to dropzone files queue and upload it. + $retryLink.on('click', (e) => { + const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone')); + const failedFiles = dropzoneInstance.files; + + e.preventDefault(); + + // 'true' parameter of removeAllFiles() cancels uploading of files that are being uploaded at the moment. + dropzoneInstance.removeAllFiles(true); + + failedFiles.map((failedFile, i) => { + const file = failedFile; + + if (file.status === Dropzone.ERROR) { + file.status = undefined; + file.accepted = undefined; + } + + return dropzoneInstance.addFile(file); + }); + }); + handlePaste = function(event) { var filename, image, pasteEvent, text; pasteEvent = event.originalEvent; @@ -104,60 +160,67 @@ window.DropzoneInput = (function() { image = isImage(pasteEvent); if (image) { event.preventDefault(); - filename = getFilename(pasteEvent) || "image.png"; - text = "{{" + filename + "}}"; + filename = getFilename(pasteEvent) || 'image.png'; + text = `{{${filename}}}`; pasteText(text); return uploadFile(image.getAsFile(), filename); } } }; + isImage = function(data) { var i, item; i = 0; while (i < data.clipboardData.items.length) { item = data.clipboardData.items[i]; - if (item.type.indexOf("image") !== -1) { + if (item.type.indexOf('image') !== -1) { return item; } i += 1; } return false; }; - pasteText = function(text) { + + pasteText = function(text, shouldPad) { var afterSelection, beforeSelection, caretEnd, caretStart, textEnd; - var formattedText = text + "\n\n"; - caretStart = $(child)[0].selectionStart; - caretEnd = $(child)[0].selectionEnd; + var formattedText = text; + if (shouldPad) formattedText += "\n\n"; + const textarea = child.get(0); + caretStart = textarea.selectionStart; + caretEnd = textarea.selectionEnd; textEnd = $(child).val().length; beforeSelection = $(child).val().substring(0, caretStart); afterSelection = $(child).val().substring(caretEnd, textEnd); $(child).val(beforeSelection + formattedText + afterSelection); - child.get(0).setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); - return form_textarea.trigger("input"); + textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); + textarea.style.height = `${textarea.scrollHeight}px`; + return formTextarea.trigger('input'); }; + getFilename = function(e) { var value; if (window.clipboardData && window.clipboardData.getData) { - value = window.clipboardData.getData("Text"); + value = window.clipboardData.getData('Text'); } else if (e.clipboardData && e.clipboardData.getData) { - value = e.clipboardData.getData("text/plain"); + value = e.clipboardData.getData('text/plain'); } value = value.split("\r"); return value.first(); }; + uploadFile = function(item, filename) { var formData; formData = new FormData(); - formData.append("file", item, filename); + formData.append('file', item, filename); return $.ajax({ - url: project_uploads_path, - type: "POST", + url: uploadsPath, + type: 'POST', data: formData, - dataType: "json", + dataType: 'json', processData: false, contentType: false, headers: { - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') }, beforeSend: function() { showSpinner(); @@ -174,43 +237,54 @@ window.DropzoneInput = (function() { } }); }; + + updateAttachingMessage = (files, messageContainer) => { + let attachingMessage; + const filesCount = files.filter(function(file) { + return file.status === 'uploading' || + file.status === 'queued'; + }).length; + + // Dinamycally change uploading files text depending on files number in + // dropzone files queue. + if (filesCount > 1) { + attachingMessage = 'Attaching ' + filesCount + ' files -'; + } else { + attachingMessage = 'Attaching a file -'; + } + + messageContainer.text(attachingMessage); + }; + insertToTextArea = function(filename, url) { return $(child).val(function(index, val) { - return val.replace("{{" + filename + "}}", url + "\n"); + return val.replace(`{{${filename}}}`, url); }); }; + appendToTextArea = function(url) { return $(child).val(function(index, val) { return val + url + "\n"; }); }; + showSpinner = function(e) { - return form.find(".div-dropzone-spinner").css({ - "opacity": 0.7, - "display": "inherit" - }); + return $uploadingProgressContainer.removeClass('hide'); }; + closeSpinner = function() { - return form.find(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); + return $uploadingProgressContainer.addClass('hide'); }; + showError = function(message) { - var checkIfMsgExists, errorAlert; - errorAlert = $(form).find('.error-alert'); - checkIfMsgExists = errorAlert.children().length; - if (checkIfMsgExists === 0) { - errorAlert.append(divAlert); - return $(".div-dropzone-alert").append(btnAlert + message); - } + $uploadingErrorContainer.removeClass('hide'); + $uploadingErrorMessage.html(message); }; - closeAlertMessage = function() { - return form.find(".div-dropzone-alert").alert("close"); - }; - form.find(".markdown-selector").click(function(e) { + + form.find('.markdown-selector').click(function(e) { e.preventDefault(); $(this).closest('.gfm-form').find('.div-dropzone').click(); + formTextarea.focus(); }); } diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index db10b383913..a8fc5b41fb4 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -115,11 +115,13 @@ class DueDateSelect { this.$dropdown.trigger('loading.gl.dropdown'); this.$selectbox.hide(); this.$value.css('display', ''); + const fadeOutLoader = () => { + this.$loading.fadeOut(); + }; gl.issueBoards.BoardsStore.detail.issue.update(this.$dropdown.attr('data-issue-update')) - .then(() => { - this.$loading.fadeOut(); - }); + .then(fadeOutLoader) + .catch(fadeOutLoader); } submitSelectedDate(isDropdown) { @@ -168,8 +170,9 @@ class DueDateSelectors { const $datePicker = $(this); const calendar = new Pikaday({ field: $datePicker.get(0), - theme: 'gitlab-theme', + theme: 'gitlab-theme animate-picker', format: 'yyyy-mm-dd', + container: $datePicker.parent().get(0), onSelect(dateText) { $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); } diff --git a/app/assets/javascripts/environments/components/environment.js b/app/assets/javascripts/environments/components/environment.js deleted file mode 100644 index 51aab8460f6..00000000000 --- a/app/assets/javascripts/environments/components/environment.js +++ /dev/null @@ -1,192 +0,0 @@ -/* eslint-disable no-new */ -/* global Flash */ -import Vue from 'vue'; -import EnvironmentsService from '../services/environments_service'; -import EnvironmentTable from './environments_table'; -import EnvironmentsStore from '../stores/environments_store'; -import TablePaginationComponent from '../../vue_shared/components/table_pagination'; -import '../../lib/utils/common_utils'; -import eventHub from '../event_hub'; - -export default Vue.component('environment-component', { - - components: { - 'environment-table': EnvironmentTable, - 'table-pagination': TablePaginationComponent, - }, - - data() { - const environmentsData = document.querySelector('#environments-list-view').dataset; - const store = new EnvironmentsStore(); - - return { - store, - state: store.state, - visibility: 'available', - isLoading: false, - cssContainerClass: environmentsData.cssClass, - endpoint: environmentsData.environmentsDataEndpoint, - canCreateDeployment: environmentsData.canCreateDeployment, - canReadEnvironment: environmentsData.canReadEnvironment, - canCreateEnvironment: environmentsData.canCreateEnvironment, - projectEnvironmentsPath: environmentsData.projectEnvironmentsPath, - projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, - newEnvironmentPath: environmentsData.newEnvironmentPath, - helpPagePath: environmentsData.helpPagePath, - - // Pagination Properties, - paginationInformation: {}, - pageNumber: 1, - }; - }, - - computed: { - scope() { - return gl.utils.getParameterByName('scope'); - }, - - canReadEnvironmentParsed() { - return gl.utils.convertPermissionToBoolean(this.canReadEnvironment); - }, - - canCreateDeploymentParsed() { - return gl.utils.convertPermissionToBoolean(this.canCreateDeployment); - }, - - canCreateEnvironmentParsed() { - return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment); - }, - }, - - /** - * Fetches all the environments and stores them. - * Toggles loading property. - */ - created() { - this.service = new EnvironmentsService(this.endpoint); - - this.fetchEnvironments(); - - eventHub.$on('refreshEnvironments', this.fetchEnvironments); - }, - - beforeDestroyed() { - eventHub.$off('refreshEnvironments'); - }, - - methods: { - toggleRow(model) { - return this.store.toggleFolder(model.name); - }, - - /** - * Will change the page number and update the URL. - * - * @param {Number} pageNumber desired page to go to. - * @return {String} - */ - changePage(pageNumber) { - const param = gl.utils.setParamInURL('page', pageNumber); - - gl.utils.visitUrl(param); - return param; - }, - - fetchEnvironments() { - const scope = gl.utils.getParameterByName('scope') || this.visibility; - const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; - - this.isLoading = true; - - return this.service.get(scope, pageNumber) - .then(resp => ({ - headers: resp.headers, - body: resp.json(), - })) - .then((response) => { - this.store.storeAvailableCount(response.body.available_count); - this.store.storeStoppedCount(response.body.stopped_count); - this.store.storeEnvironments(response.body.environments); - this.store.setPagination(response.headers); - }) - .then(() => { - this.isLoading = false; - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occurred while fetching the environments.'); - }); - }, - }, - - template: ` - <div :class="cssContainerClass"> - <div class="top-area"> - <ul v-if="!isLoading" class="nav-links"> - <li v-bind:class="{ 'active': scope === null || scope === 'available' }"> - <a :href="projectEnvironmentsPath"> - Available - <span class="badge js-available-environments-count"> - {{state.availableCounter}} - </span> - </a> - </li> - <li v-bind:class="{ 'active' : scope === 'stopped' }"> - <a :href="projectStoppedEnvironmentsPath"> - Stopped - <span class="badge js-stopped-environments-count"> - {{state.stoppedCounter}} - </span> - </a> - </li> - </ul> - <div v-if="canCreateEnvironmentParsed && !isLoading" class="nav-controls"> - <a :href="newEnvironmentPath" class="btn btn-create"> - New environment - </a> - </div> - </div> - - <div class="content-list environments-container"> - <div class="environments-list-loading text-center" v-if="isLoading"> - <i class="fa fa-spinner fa-spin" aria-hidden="true"></i> - </div> - - <div class="blank-state blank-state-no-icon" - v-if="!isLoading && state.environments.length === 0"> - <h2 class="blank-state-title js-blank-state-title"> - You don't have any environments right now. - </h2> - <p class="blank-state-text"> - Environments are places where code gets deployed, such as staging or production. - <br /> - <a :href="helpPagePath"> - Read more about environments - </a> - </p> - - <a v-if="canCreateEnvironmentParsed" - :href="newEnvironmentPath" - class="btn btn-create js-new-environment-button"> - New Environment - </a> - </div> - - <div class="table-holder" - v-if="!isLoading && state.environments.length > 0"> - - <environment-table - :environments="state.environments" - :can-create-deployment="canCreateDeploymentParsed" - :can-read-environment="canReadEnvironmentParsed" - :service="service"/> - </div> - - <table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" - :change="changePage" - :pageInfo="state.paginationInformation"> - </table-pagination> - </div> - </div> - `, -}); diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue new file mode 100644 index 00000000000..d4e13f3c84a --- /dev/null +++ b/app/assets/javascripts/environments/components/environment.vue @@ -0,0 +1,236 @@ +<script> +/* global Flash */ +import EnvironmentsService from '../services/environments_service'; +import environmentTable from './environments_table.vue'; +import EnvironmentsStore from '../stores/environments_store'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import tablePagination from '../../vue_shared/components/table_pagination.vue'; +import '../../lib/utils/common_utils'; +import eventHub from '../event_hub'; + +export default { + + components: { + environmentTable, + tablePagination, + loadingIcon, + }, + + data() { + const environmentsData = document.querySelector('#environments-list-view').dataset; + const store = new EnvironmentsStore(); + + return { + store, + state: store.state, + visibility: 'available', + isLoading: false, + isLoadingFolderContent: false, + cssContainerClass: environmentsData.cssClass, + endpoint: environmentsData.environmentsDataEndpoint, + canCreateDeployment: environmentsData.canCreateDeployment, + canReadEnvironment: environmentsData.canReadEnvironment, + canCreateEnvironment: environmentsData.canCreateEnvironment, + projectEnvironmentsPath: environmentsData.projectEnvironmentsPath, + projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, + newEnvironmentPath: environmentsData.newEnvironmentPath, + helpPagePath: environmentsData.helpPagePath, + + // Pagination Properties, + paginationInformation: {}, + pageNumber: 1, + }; + }, + + computed: { + scope() { + return gl.utils.getParameterByName('scope'); + }, + + canReadEnvironmentParsed() { + return gl.utils.convertPermissionToBoolean(this.canReadEnvironment); + }, + + canCreateDeploymentParsed() { + return gl.utils.convertPermissionToBoolean(this.canCreateDeployment); + }, + + canCreateEnvironmentParsed() { + return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment); + }, + }, + + /** + * Fetches all the environments and stores them. + * Toggles loading property. + */ + created() { + this.service = new EnvironmentsService(this.endpoint); + + this.fetchEnvironments(); + + eventHub.$on('refreshEnvironments', this.fetchEnvironments); + eventHub.$on('toggleFolder', this.toggleFolder); + eventHub.$on('postAction', this.postAction); + }, + + beforeDestroyed() { + eventHub.$off('refreshEnvironments'); + eventHub.$off('toggleFolder'); + eventHub.$off('postAction'); + }, + + methods: { + toggleFolder(folder, folderUrl) { + this.store.toggleFolder(folder); + + if (!folder.isOpen) { + this.fetchChildEnvironments(folder, folderUrl); + } + }, + + /** + * Will change the page number and update the URL. + * + * @param {Number} pageNumber desired page to go to. + * @return {String} + */ + changePage(pageNumber) { + const param = gl.utils.setParamInURL('page', pageNumber); + + gl.utils.visitUrl(param); + return param; + }, + + fetchEnvironments() { + const scope = gl.utils.getParameterByName('scope') || this.visibility; + const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; + + this.isLoading = true; + + return this.service.get(scope, pageNumber) + .then(resp => ({ + headers: resp.headers, + body: resp.json(), + })) + .then((response) => { + this.store.storeAvailableCount(response.body.available_count); + this.store.storeStoppedCount(response.body.stopped_count); + this.store.storeEnvironments(response.body.environments); + this.store.setPagination(response.headers); + }) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + // eslint-disable-next-line no-new + new Flash('An error occurred while fetching the environments.'); + }); + }, + + fetchChildEnvironments(folder, folderUrl) { + this.isLoadingFolderContent = true; + + this.service.getFolderContent(folderUrl) + .then(resp => resp.json()) + .then((response) => { + this.store.setfolderContent(folder, response.environments); + this.isLoadingFolderContent = false; + }) + .catch(() => { + this.isLoadingFolderContent = false; + // eslint-disable-next-line no-new + new Flash('An error occurred while fetching the environments.'); + }); + }, + + postAction(endpoint) { + this.service.postAction(endpoint) + .then(() => this.fetchEnvironments()) + .catch(() => new Flash('An error occured while making the request.')); + }, + }, +}; +</script> +<template> + <div :class="cssContainerClass"> + <div class="top-area"> + <ul + v-if="!isLoading" + class="nav-links"> + <li :class="{ active: scope === null || scope === 'available' }"> + <a :href="projectEnvironmentsPath"> + Available + <span class="badge js-available-environments-count"> + {{state.availableCounter}} + </span> + </a> + </li> + <li :class="{ active : scope === 'stopped' }"> + <a :href="projectStoppedEnvironmentsPath"> + Stopped + <span class="badge js-stopped-environments-count"> + {{state.stoppedCounter}} + </span> + </a> + </li> + </ul> + <div + v-if="canCreateEnvironmentParsed && !isLoading" + class="nav-controls"> + <a + :href="newEnvironmentPath" + class="btn btn-create"> + New environment + </a> + </div> + </div> + + <div class="content-list environments-container"> + <loading-icon + label="Loading environments" + size="3" + v-if="isLoading" + /> + + <div + class="blank-state blank-state-no-icon" + v-if="!isLoading && state.environments.length === 0"> + <h2 class="blank-state-title js-blank-state-title"> + You don't have any environments right now. + </h2> + <p class="blank-state-text"> + Environments are places where code gets deployed, such as staging or production. + <br /> + <a :href="helpPagePath"> + Read more about environments + </a> + </p> + + <a + v-if="canCreateEnvironmentParsed" + :href="newEnvironmentPath" + class="btn btn-create js-new-environment-button"> + New Environment + </a> + </div> + + <div + class="table-holder" + v-if="!isLoading && state.environments.length > 0"> + + <environment-table + :environments="state.environments" + :can-create-deployment="canCreateDeploymentParsed" + :can-read-environment="canReadEnvironmentParsed" + :is-loading-folder-content="isLoadingFolderContent" /> + </div> + + <table-pagination + v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" + :change="changePage" + :pageInfo="state.paginationInformation" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/environment_actions.js b/app/assets/javascripts/environments/components/environment_actions.js deleted file mode 100644 index 385085c03e2..00000000000 --- a/app/assets/javascripts/environments/components/environment_actions.js +++ /dev/null @@ -1,80 +0,0 @@ -/* global Flash */ -/* eslint-disable no-new */ - -import playIconSvg from 'icons/_icon_play.svg'; -import eventHub from '../event_hub'; - -export default { - props: { - actions: { - type: Array, - required: false, - default: () => [], - }, - - service: { - type: Object, - required: true, - }, - }, - - data() { - return { - playIconSvg, - isLoading: false, - }; - }, - - computed: { - title() { - return 'Deploy to...'; - }, - }, - - methods: { - onClickAction(endpoint) { - this.isLoading = true; - - this.service.postAction(endpoint) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.'); - }); - }, - }, - - template: ` - <div class="btn-group" role="group"> - <button - class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container has-tooltip" - data-container="body" - data-toggle="dropdown" - :title="title" - :aria-label="title" - :disabled="isLoading"> - <span> - <span v-html="playIconSvg"></span> - <i class="fa fa-caret-down" aria-hidden="true"></i> - <i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - </span> - - <ul class="dropdown-menu dropdown-menu-align-right"> - <li v-for="action in actions"> - <button - @click="onClickAction(action.play_path)" - class="js-manual-action-link no-btn"> - ${playIconSvg} - <span> - {{action.name}} - </span> - </button> - </li> - </ul> - </button> - </div> - `, -}; diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue new file mode 100644 index 00000000000..a2448520a5f --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -0,0 +1,89 @@ +<script> +import playIconSvg from 'icons/_icon_play.svg'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + actions: { + type: Array, + required: false, + default: () => [], + }, + }, + + components: { + loadingIcon, + }, + + data() { + return { + playIconSvg, + isLoading: false, + }; + }, + + computed: { + title() { + return 'Deploy to...'; + }, + }, + + methods: { + onClickAction(endpoint) { + this.isLoading = true; + + $(this.$refs.tooltip).tooltip('destroy'); + + eventHub.$emit('postAction', endpoint); + }, + + isActionDisabled(action) { + if (action.playable === undefined) { + return false; + } + + return !action.playable; + }, + }, +}; +</script> +<template> + <div + class="btn-group" + role="group"> + <button + type="button" + class="dropdown btn btn-default dropdown-new js-dropdown-play-icon-container has-tooltip" + data-container="body" + data-toggle="dropdown" + ref="tooltip" + :title="title" + :aria-label="title" + :disabled="isLoading"> + <span> + <span v-html="playIconSvg"></span> + <i + class="fa fa-caret-down" + aria-hidden="true"/> + <loading-icon v-if="isLoading" /> + </span> + </button> + + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for="action in actions"> + <button + type="button" + class="js-manual-action-link no-btn btn" + @click="onClickAction(action.play_path)" + :class="{ disabled: isActionDisabled(action) }" + :disabled="isActionDisabled(action)"> + <span v-html="playIconSvg"></span> + <span> + {{action.name}} + </span> + </button> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/environments/components/environment_external_url.js b/app/assets/javascripts/environments/components/environment_external_url.js deleted file mode 100644 index d79b916c360..00000000000 --- a/app/assets/javascripts/environments/components/environment_external_url.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Renders the external url link in environments table. - */ -export default { - props: { - externalUrl: { - type: String, - default: '', - }, - }, - - computed: { - title() { - return 'Open'; - }, - }, - - template: ` - <a - class="btn external-url has-tooltip" - data-container="body" - :href="externalUrl" - target="_blank" - rel="noopener noreferrer nofollow" - :title="title" - :aria-label="title"> - <i class="fa fa-external-link" aria-hidden="true"></i> - </a> - `, -}; diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue new file mode 100644 index 00000000000..eaeec2bc53c --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_external_url.vue @@ -0,0 +1,33 @@ +<script> +/** + * Renders the external url link in environments table. + */ +export default { + props: { + externalUrl: { + type: String, + required: true, + }, + }, + + computed: { + title() { + return 'Open'; + }, + }, +}; +</script> +<template> + <a + class="btn external-url has-tooltip" + data-container="body" + target="_blank" + rel="noopener noreferrer nofollow" + :title="title" + :aria-label="title" + :href="externalUrl"> + <i + class="fa fa-external-link" + aria-hidden="true" /> + </a> +</template> diff --git a/app/assets/javascripts/environments/components/environment_item.js b/app/assets/javascripts/environments/components/environment_item.vue index 9c196562c6c..012ff1f975b 100644 --- a/app/assets/javascripts/environments/components/environment_item.js +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,12 +1,16 @@ +<script> import Timeago from 'timeago.js'; +import _ from 'underscore'; +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import '../../lib/utils/text_utility'; -import ActionsComponent from './environment_actions'; -import ExternalUrlComponent from './environment_external_url'; -import StopComponent from './environment_stop'; -import RollbackComponent from './environment_rollback'; -import TerminalButtonComponent from './environment_terminal_button'; -import MonitoringButtonComponent from './environment_monitoring'; +import ActionsComponent from './environment_actions.vue'; +import ExternalUrlComponent from './environment_external_url.vue'; +import StopComponent from './environment_stop.vue'; +import RollbackComponent from './environment_rollback.vue'; +import TerminalButtonComponent from './environment_terminal_button.vue'; +import MonitoringButtonComponent from './environment_monitoring.vue'; import CommitComponent from '../../vue_shared/components/commit'; +import eventHub from '../event_hub'; /** * Envrionment Item Component @@ -17,6 +21,7 @@ const timeagoInstance = new Timeago(); export default { components: { + userAvatarLink, 'commit-component': CommitComponent, 'actions-component': ActionsComponent, 'external-url-component': ExternalUrlComponent, @@ -44,11 +49,6 @@ export default { required: false, default: false, }, - - service: { - type: Object, - required: true, - }, }, computed: { @@ -62,7 +62,7 @@ export default { hasLastDeploymentKey() { if (this.model && this.model.last_deployment && - !this.$options.isObjectEmpty(this.model.last_deployment)) { + !_.isEmpty(this.model.last_deployment)) { return true; } return false; @@ -141,6 +141,7 @@ export default { const parsedAction = { name: gl.text.humanize(action.name), play_path: action.play_path, + playable: action.playable, }; return parsedAction; }); @@ -312,8 +313,8 @@ export default { */ deploymentHasUser() { return this.model && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.user); + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.user); }, /** @@ -324,8 +325,8 @@ export default { */ deploymentUser() { if (this.model && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.user)) { + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.user)) { return this.model.last_deployment.user; } return {}; @@ -340,8 +341,8 @@ export default { */ shouldRenderBuildName() { return !this.model.isFolder && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.deployable); + !_.isEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment.deployable); }, /** @@ -382,7 +383,7 @@ export default { */ shouldRenderDeploymentID() { return !this.model.isFolder && - !this.$options.isObjectEmpty(this.model.last_deployment) && + !_.isEmpty(this.model.last_deployment) && this.model.last_deployment.iid !== undefined; }, @@ -410,118 +411,148 @@ export default { folderUrl() { return `${window.location.pathname}/folders/${this.model.folderName}`; }, - }, - /** - * Helper to verify if certain given object are empty. - * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty - * @param {Object} object - * @returns {Bollean} - */ - isObjectEmpty(object) { - for (const key in object) { // eslint-disable-line - if (hasOwnProperty.call(object, key)) { - return false; - } - } - return true; + methods: { + onClickFolder() { + eventHub.$emit('toggleFolder', this.model, this.folderUrl); + }, }, +}; +</script> +<template> + <tr :class="{ 'js-child-row': model.isChildren }"> + <td> + <a + v-if="!model.isFolder" + class="environment-name" + :class="{ 'prepend-left-default': model.isChildren }" + :href="environmentPath"> + {{model.name}} + </a> + <span + v-else + class="folder-name" + @click="onClickFolder" + role="button"> + + <span class="folder-icon"> + <i + v-show="model.isOpen" + class="fa fa-caret-down" + aria-hidden="true" /> + <i + v-show="!model.isOpen" + class="fa fa-caret-right" + aria-hidden="true"/> + </span> - template: ` - <tr> - <td> - <a v-if="!model.isFolder" - class="environment-name" - :href="environmentPath"> - {{model.name}} - </a> - <a v-else class="folder-name" :href="folderUrl"> - <span class="folder-icon"> - <i class="fa fa-folder" aria-hidden="true"></i> - </span> - - <span> - {{model.folderName}} - </span> - - <span class="badge"> - {{model.size}} - </span> - </a> - </td> - - <td class="deployment-column"> - <span v-if="shouldRenderDeploymentID"> - {{deploymentInternalId}} + <span class="folder-icon"> + <i + class="fa fa-folder" + aria-hidden="true" /> </span> - <span v-if="!model.isFolder && deploymentHasUser"> - by - <a :href="deploymentUser.web_url" class="js-deploy-user-container"> - <img class="avatar has-tooltip s20" - :src="deploymentUser.avatar_url" - :alt="userImageAltDescription" - :title="deploymentUser.username" /> - </a> + <span> + {{model.folderName}} </span> - </td> - - <td class="environments-build-cell"> - <a v-if="shouldRenderBuildName" - class="build-link" - :href="buildPath"> - {{buildName}} - </a> - </td> - - <td> - <div v-if="!model.isFolder && hasLastDeploymentKey" class="js-commit-component"> - <commit-component - :tag="commitTag" - :commit-ref="commitRef" - :commit-url="commitUrl" - :short-sha="commitShortSha" - :title="commitTitle" - :author="commitAuthor"/> - </div> - <p v-if="!model.isFolder && !hasLastDeploymentKey" class="commit-title"> - No deployments yet - </p> - </td> - - <td> - <span v-if="!model.isFolder && canShowDate" - class="environment-created-date-timeago"> - {{createdDate}} + + <span class="badge"> + {{model.size}} </span> - </td> - - <td class="environments-actions"> - <div v-if="!model.isFolder" class="btn-group pull-right" role="group"> - <actions-component v-if="hasManualActions && canCreateDeployment" - :service="service" - :actions="manualActions"/> - - <external-url-component v-if="externalURL && canReadEnvironment" - :external-url="externalURL"/> - - <monitoring-button-component v-if="monitoringUrl && canReadEnvironment" - :monitoring-url="monitoringUrl"/> - - <terminal-button-component v-if="model && model.terminal_path" - :terminal-path="model.terminal_path"/> - - <stop-component v-if="hasStopAction && canCreateDeployment" - :stop-url="model.stop_path" - :service="service"/> - - <rollback-component v-if="canRetry && canCreateDeployment" - :is-last-deployment="isLastDeployment" - :retry-url="retryUrl" - :service="service"/> - </div> - </td> - </tr> - `, -}; + </span> + </td> + + <td class="deployment-column"> + <span v-if="shouldRenderDeploymentID"> + {{deploymentInternalId}} + </span> + + <span v-if="!model.isFolder && deploymentHasUser"> + by + <user-avatar-link + class="js-deploy-user-container" + :link-href="deploymentUser.web_url" + :img-src="deploymentUser.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="deploymentUser.username" + /> + </span> + </td> + + <td class="environments-build-cell"> + <a + v-if="shouldRenderBuildName" + class="build-link" + :href="buildPath"> + {{buildName}} + </a> + </td> + + <td> + <div + v-if="!model.isFolder && hasLastDeploymentKey" + class="js-commit-component"> + <commit-component + :tag="commitTag" + :commit-ref="commitRef" + :commit-url="commitUrl" + :short-sha="commitShortSha" + :title="commitTitle" + :author="commitAuthor"/> + </div> + <p + v-if="!model.isFolder && !hasLastDeploymentKey" + class="commit-title"> + No deployments yet + </p> + </td> + + <td> + <span + v-if="!model.isFolder && canShowDate" + class="environment-created-date-timeago"> + {{createdDate}} + </span> + </td> + + <td class="environments-actions"> + <div + v-if="!model.isFolder" + class="btn-group pull-right" + role="group"> + + <actions-component + v-if="hasManualActions && canCreateDeployment" + :actions="manualActions" + /> + + <external-url-component + v-if="externalURL && canReadEnvironment" + :external-url="externalURL" + /> + + <monitoring-button-component + v-if="monitoringUrl && canReadEnvironment" + :monitoring-url="monitoringUrl" + /> + + <terminal-button-component + v-if="model && model.terminal_path" + :terminal-path="model.terminal_path" + /> + + <stop-component + v-if="hasStopAction && canCreateDeployment" + :stop-url="model.stop_path" + /> + + <rollback-component + v-if="canRetry && canCreateDeployment" + :is-last-deployment="isLastDeployment" + :retry-url="retryUrl" + /> + </div> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/environments/components/environment_monitoring.js b/app/assets/javascripts/environments/components/environment_monitoring.js deleted file mode 100644 index 064e2fc7434..00000000000 --- a/app/assets/javascripts/environments/components/environment_monitoring.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Renders the Monitoring (Metrics) link in environments table. - */ -export default { - props: { - monitoringUrl: { - type: String, - default: '', - required: true, - }, - }, - - computed: { - title() { - return 'Monitoring'; - }, - }, - - template: ` - <a - class="btn monitoring-url has-tooltip" - data-container="body" - :href="monitoringUrl" - target="_blank" - rel="noopener noreferrer nofollow" - :title="title" - :aria-label="title"> - <i class="fa fa-area-chart" aria-hidden="true"></i> - </a> - `, -}; diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue new file mode 100644 index 00000000000..79c019b3491 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_monitoring.vue @@ -0,0 +1,32 @@ +<script> +/** + * Renders the Monitoring (Metrics) link in environments table. + */ +export default { + props: { + monitoringUrl: { + type: String, + required: true, + }, + }, + + computed: { + title() { + return 'Monitoring'; + }, + }, +}; +</script> +<template> + <a + class="btn monitoring-url has-tooltip" + data-container="body" + rel="noopener noreferrer nofollow" + :href="monitoringUrl" + :title="title" + :aria-label="title"> + <i + class="fa fa-area-chart" + aria-hidden="true" /> + </a> +</template> diff --git a/app/assets/javascripts/environments/components/environment_rollback.js b/app/assets/javascripts/environments/components/environment_rollback.js deleted file mode 100644 index baa15d9e5b5..00000000000 --- a/app/assets/javascripts/environments/components/environment_rollback.js +++ /dev/null @@ -1,67 +0,0 @@ -/* global Flash */ -/* eslint-disable no-new */ -/** - * Renders Rollback or Re deploy button in environments table depending - * of the provided property `isLastDeployment`. - * - * Makes a post request when the button is clicked. - */ -import eventHub from '../event_hub'; - -export default { - props: { - retryUrl: { - type: String, - default: '', - }, - - isLastDeployment: { - type: Boolean, - default: true, - }, - - service: { - type: Object, - required: true, - }, - }, - - data() { - return { - isLoading: false, - }; - }, - - methods: { - onClick() { - this.isLoading = true; - - this.service.postAction(this.retryUrl) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.'); - }); - }, - }, - - template: ` - <button type="button" - class="btn" - @click="onClick" - :disabled="isLoading"> - - <span v-if="isLastDeployment"> - Re-deploy - </span> - <span v-else> - Rollback - </span> - - <i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - </button> - `, -}; diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue new file mode 100644 index 00000000000..2ba985bfe3e --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -0,0 +1,59 @@ +<script> +/** + * Renders Rollback or Re deploy button in environments table depending + * of the provided property `isLastDeployment`. + * + * Makes a post request when the button is clicked. + */ +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + retryUrl: { + type: String, + default: '', + }, + + isLastDeployment: { + type: Boolean, + default: true, + }, + }, + + components: { + loadingIcon, + }, + + data() { + return { + isLoading: false, + }; + }, + + methods: { + onClick() { + this.isLoading = true; + + eventHub.$emit('postAction', this.retryUrl); + }, + }, +}; +</script> +<template> + <button + type="button" + class="btn" + @click="onClick" + :disabled="isLoading"> + + <span v-if="isLastDeployment"> + Re-deploy + </span> + <span v-else> + Rollback + </span> + + <loading-icon v-if="isLoading" /> + </button> +</template> diff --git a/app/assets/javascripts/environments/components/environment_stop.js b/app/assets/javascripts/environments/components/environment_stop.js deleted file mode 100644 index 47102692024..00000000000 --- a/app/assets/javascripts/environments/components/environment_stop.js +++ /dev/null @@ -1,64 +0,0 @@ -/* global Flash */ -/* eslint-disable no-new, no-alert */ -/** - * Renders the stop "button" that allows stop an environment. - * Used in environments table. - */ -import eventHub from '../event_hub'; - -export default { - props: { - stopUrl: { - type: String, - default: '', - }, - - service: { - type: Object, - required: true, - }, - }, - - data() { - return { - isLoading: false, - }; - }, - - computed: { - title() { - return 'Stop'; - }, - }, - - methods: { - onClick() { - if (confirm('Are you sure you want to stop this environment?')) { - this.isLoading = true; - - this.service.postAction(this.retryUrl) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshEnvironments'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.', 'alert'); - }); - } - }, - }, - - template: ` - <button type="button" - class="btn stop-env-link has-tooltip" - data-container="body" - @click="onClick" - :disabled="isLoading" - :title="title" - :aria-label="title"> - <i class="fa fa-stop stop-env-icon" aria-hidden="true"></i> - <i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> - </button> - `, -}; diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue new file mode 100644 index 00000000000..a904453ffa9 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -0,0 +1,61 @@ +<script> +/** + * Renders the stop "button" that allows stop an environment. + * Used in environments table. + */ +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + stopUrl: { + type: String, + default: '', + }, + }, + + data() { + return { + isLoading: false, + }; + }, + + components: { + loadingIcon, + }, + + computed: { + title() { + return 'Stop'; + }, + }, + + methods: { + onClick() { + // eslint-disable-next-line no-alert + if (confirm('Are you sure you want to stop this environment?')) { + this.isLoading = true; + + $(this.$el).tooltip('destroy'); + + eventHub.$emit('postAction', this.stopUrl); + } + }, + }, +}; +</script> +<template> + <button + type="button" + class="btn stop-env-link has-tooltip" + data-container="body" + @click="onClick" + :disabled="isLoading" + :title="title" + :aria-label="title"> + <i + class="fa fa-stop stop-env-icon" + aria-hidden="true" /> + <loading-icon v-if="isLoading" /> + </button> +</template> diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.js b/app/assets/javascripts/environments/components/environment_terminal_button.vue index 092a50a0d6f..c8c1f17d4d8 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.js +++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue @@ -1,3 +1,4 @@ +<script> /** * Renders a terminal button to open a web terminal. * Used in environments table. @@ -24,14 +25,15 @@ export default { return 'Terminal'; }, }, - - template: ` - <a class="btn terminal-button has-tooltip" - data-container="body" - :title="title" - :aria-label="title" - :href="terminalPath"> - ${terminalIconSvg} - </a> - `, }; +</script> +<template> + <a + class="btn terminal-button has-tooltip" + data-container="body" + :title="title" + :aria-label="title" + :href="terminalPath" + v-html="terminalIconSvg"> + </a> +</template> diff --git a/app/assets/javascripts/environments/components/environments_table.js b/app/assets/javascripts/environments/components/environments_table.js deleted file mode 100644 index 338dff40bc9..00000000000 --- a/app/assets/javascripts/environments/components/environments_table.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Render environments table. - */ -import EnvironmentTableRowComponent from './environment_item'; - -export default { - components: { - 'environment-item': EnvironmentTableRowComponent, - }, - - props: { - environments: { - type: Array, - required: true, - default: () => ([]), - }, - - canReadEnvironment: { - type: Boolean, - required: false, - default: false, - }, - - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, - - service: { - type: Object, - required: true, - }, - }, - - template: ` - <table class="table ci-table"> - <thead> - <tr> - <th class="environments-name">Environment</th> - <th class="environments-deploy">Last deployment</th> - <th class="environments-build">Job</th> - <th class="environments-commit">Commit</th> - <th class="environments-date">Updated</th> - <th class="environments-actions"></th> - </tr> - </thead> - <tbody> - <template v-for="model in environments" - v-bind:model="model"> - <tr is="environment-item" - :model="model" - :can-create-deployment="canCreateDeployment" - :can-read-environment="canReadEnvironment" - :service="service"></tr> - </template> - </tbody> - </table> - `, -}; diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue new file mode 100644 index 00000000000..5148a2ae79b --- /dev/null +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -0,0 +1,112 @@ +<script> +/** + * Render environments table. + */ +import EnvironmentTableRowComponent from './environment_item.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + components: { + 'environment-item': EnvironmentTableRowComponent, + loadingIcon, + }, + + props: { + environments: { + type: Array, + required: true, + default: () => ([]), + }, + + canReadEnvironment: { + type: Boolean, + required: false, + default: false, + }, + + canCreateDeployment: { + type: Boolean, + required: false, + default: false, + }, + + isLoadingFolderContent: { + type: Boolean, + required: false, + default: false, + }, + }, + + methods: { + folderUrl(model) { + return `${window.location.pathname}/folders/${model.folderName}`; + }, + }, +}; +</script> +<template> + <table class="table ci-table"> + <thead> + <tr> + <th class="environments-name"> + Environment + </th> + <th class="environments-deploy"> + Last deployment + </th> + <th class="environments-build"> + Job + </th> + <th class="environments-commit"> + Commit + </th> + <th class="environments-date"> + Updated + </th> + <th class="environments-actions"></th> + </tr> + </thead> + <tbody> + <template + v-for="model in environments" + v-bind:model="model"> + <tr + is="environment-item" + :model="model" + :can-create-deployment="canCreateDeployment" + :can-read-environment="canReadEnvironment" + /> + + <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0"> + <tr v-if="isLoadingFolderContent"> + <td colspan="6"> + <loading-icon size="2" /> + </td> + </tr> + + <template v-else> + <tr + is="environment-item" + v-for="children in model.children" + :model="children" + :can-create-deployment="canCreateDeployment" + :can-read-environment="canReadEnvironment" + /> + + <tr> + <td + colspan="6" + class="text-center"> + <a + :href="folderUrl(model)" + class="btn btn-default"> + Show all + </a> + </td> + </tr> + </template> + </template> + </template> + </tbody> + </table> +</template> diff --git a/app/assets/javascripts/environments/environments_bundle.js b/app/assets/javascripts/environments/environments_bundle.js index 8d963b335cf..c0662125f28 100644 --- a/app/assets/javascripts/environments/environments_bundle.js +++ b/app/assets/javascripts/environments/environments_bundle.js @@ -1,13 +1,10 @@ -import EnvironmentsComponent from './components/environment'; +import Vue from 'vue'; +import EnvironmentsComponent from './components/environment.vue'; -$(() => { - window.gl = window.gl || {}; - - if (gl.EnvironmentsListApp) { - gl.EnvironmentsListApp.$destroy(true); - } - - gl.EnvironmentsListApp = new EnvironmentsComponent({ - el: document.querySelector('#environments-list-view'), - }); -}); +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#environments-list-view', + components: { + 'environments-table-app': EnvironmentsComponent, + }, + render: createElement => createElement('environments-table-app'), +})); diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index f939eccf246..9add8c3d721 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -1,13 +1,10 @@ -import EnvironmentsFolderComponent from './environments_folder_view'; +import Vue from 'vue'; +import EnvironmentsFolderComponent from './environments_folder_view.vue'; -$(() => { - window.gl = window.gl || {}; - - if (gl.EnvironmentsListFolderApp) { - gl.EnvironmentsListFolderApp.$destroy(true); - } - - gl.EnvironmentsListFolderApp = new EnvironmentsFolderComponent({ - el: document.querySelector('#environments-folder-list-view'), - }); -}); +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#environments-folder-list-view', + components: { + 'environments-folder-app': EnvironmentsFolderComponent, + }, + render: createElement => createElement('environments-folder-app'), +})); diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.js b/app/assets/javascripts/environments/folder/environments_folder_view.vue index 8abbcf0c227..bd161c8a379 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.js +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,17 +1,18 @@ -/* eslint-disable no-new */ +<script> /* global Flash */ -import Vue from 'vue'; import EnvironmentsService from '../services/environments_service'; -import EnvironmentTable from '../components/environments_table'; +import environmentTable from '../components/environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; -import TablePaginationComponent from '../../vue_shared/components/table_pagination'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import tablePagination from '../../vue_shared/components/table_pagination.vue'; import '../../lib/utils/common_utils'; import '../../vue_shared/vue_resource_interceptor'; -export default Vue.component('environment-folder-view', { +export default { components: { - 'environment-table': EnvironmentTable, - 'table-pagination': TablePaginationComponent, + environmentTable, + tablePagination, + loadingIcon, }, data() { @@ -31,12 +32,6 @@ export default Vue.component('environment-folder-view', { cssContainerClass: environmentsData.cssClass, canCreateDeployment: environmentsData.canCreateDeployment, canReadEnvironment: environmentsData.canReadEnvironment, - - // svgs - commitIconSvg: environmentsData.commitIconSvg, - playIconSvg: environmentsData.playIconSvg, - terminalIconSvg: environmentsData.terminalIconSvg, - // Pagination Properties, paginationInformation: {}, pageNumber: 1, @@ -105,6 +100,7 @@ export default Vue.component('environment-folder-view', { }) .catch(() => { this.isLoading = false; + // eslint-disable-next-line no-new new Flash('An error occurred while fetching the environments.', 'alert'); }); }, @@ -122,57 +118,65 @@ export default Vue.component('environment-folder-view', { return param; }, }, +}; +</script> +<template> + <div :class="cssContainerClass"> + <div + class="top-area" + v-if="!isLoading"> + + <h4 class="js-folder-name environments-folder-name"> + Environments / <b>{{folderName}}</b> + </h4> + + <ul class="nav-links"> + <li :class="{ active: scope === null || scope === 'available' }"> + <a + :href="availablePath" + class="js-available-environments-folder-tab"> + Available + <span class="badge js-available-environments-count"> + {{state.availableCounter}} + </span> + </a> + </li> + <li :class="{ active : scope === 'stopped' }"> + <a + :href="stoppedPath" + class="js-stopped-environments-folder-tab"> + Stopped + <span class="badge js-stopped-environments-count"> + {{state.stoppedCounter}} + </span> + </a> + </li> + </ul> + </div> - template: ` - <div :class="cssContainerClass"> - <div class="top-area" v-if="!isLoading"> - - <h4 class="js-folder-name environments-folder-name"> - Environments / <b>{{folderName}}</b> - </h4> - - <ul class="nav-links"> - <li v-bind:class="{ 'active': scope === null || scope === 'available' }"> - <a :href="availablePath" class="js-available-environments-folder-tab"> - Available - <span class="badge js-available-environments-count"> - {{state.availableCounter}} - </span> - </a> - </li> - <li v-bind:class="{ 'active' : scope === 'stopped' }"> - <a :href="stoppedPath" class="js-stopped-environments-folder-tab"> - Stopped - <span class="badge js-stopped-environments-count"> - {{state.stoppedCounter}} - </span> - </a> - </li> - </ul> - </div> + <div class="environments-container"> + + <loading-icon + label="Loading environments" + v-if="isLoading" + size="3" + /> + + <div + class="table-holder" + v-if="!isLoading && state.environments.length > 0"> + + <environment-table + :environments="state.environments" + :can-create-deployment="canCreateDeploymentParsed" + :can-read-environment="canReadEnvironmentParsed" + /> - <div class="environments-container"> - <div class="environments-list-loading text-center" v-if="isLoading"> - <i class="fa fa-spinner fa-spin"></i> - </div> - - <div class="table-holder" - v-if="!isLoading && state.environments.length > 0"> - - <environment-table - :environments="state.environments" - :can-create-deployment="canCreateDeploymentParsed" - :can-read-environment="canReadEnvironmentParsed" - :play-icon-svg="playIconSvg" - :terminal-icon-svg="terminalIconSvg" - :commit-icon-svg="commitIconSvg" - :service="service"/> - - <table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" - :change="changePage" - :pageInfo="state.paginationInformation"/> - </div> + <table-pagination + v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" + :change="changePage" + :pageInfo="state.paginationInformation"/> </div> </div> - `, -}); + </div> +</template> diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 07040bf0d73..8adb53ea86d 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -7,6 +7,7 @@ Vue.use(VueResource); export default class EnvironmentsService { constructor(endpoint) { this.environments = Vue.resource(endpoint); + this.folderResults = 3; } get(scope, page) { @@ -16,4 +17,8 @@ export default class EnvironmentsService { postAction(endpoint) { return Vue.http.post(endpoint, {}, { emulateJSON: true }); } + + getFolderContent(folderUrl) { + return Vue.http.get(`${folderUrl}.json?per_page=${this.folderResults}`); + } } diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 3c3084f3b78..158e7922e3c 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -38,7 +38,12 @@ export default class EnvironmentsStore { let filtered = {}; if (env.size > 1) { - filtered = Object.assign({}, env, { isFolder: true, folderName: env.name }); + filtered = Object.assign({}, env, { + isFolder: true, + folderName: env.name, + isOpen: false, + children: [], + }); } if (env.latest) { @@ -85,4 +90,67 @@ export default class EnvironmentsStore { this.state.stoppedCounter = count; return count; } + + /** + * Toggles folder open property for the given folder. + * + * @param {Object} folder + * @return {Array} + */ + toggleFolder(folder) { + return this.updateFolder(folder, 'isOpen', !folder.isOpen); + } + + /** + * Updates the folder with the received environments. + * + * + * @param {Object} folder Folder to update + * @param {Array} environments Received environments + * @return {Object} + */ + setfolderContent(folder, environments) { + const updatedEnvironments = environments.map((env) => { + let updated = env; + + if (env.latest) { + updated = Object.assign({}, env, env.latest); + delete updated.latest; + } else { + updated = env; + } + + updated.isChildren = true; + + return updated; + }); + + return this.updateFolder(folder, 'children', updatedEnvironments); + } + + /** + * Given a folder a prop and a new value updates the correct folder. + * + * @param {Object} folder + * @param {String} prop + * @param {String|Boolean|Object|Array} newValue + * @return {Array} + */ + updateFolder(folder, prop, newValue) { + const environments = this.state.environments; + + const updatedEnvironments = environments.map((env) => { + const updateEnv = Object.assign({}, env); + if (env.isFolder && env.id === folder.id) { + updateEnv[prop] = newValue; + } + + return updateEnv; + }); + + this.state.environments = updatedEnvironments; + + return updatedEnvironments; + } + } diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 3f041172ff3..534e651b030 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -3,7 +3,6 @@ /* global notes */ let $commentButtonTemplate; -var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; window.FilesCommentButton = (function() { var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; @@ -27,8 +26,8 @@ window.FilesCommentButton = (function() { TEXT_FILE_SELECTOR = '.text-file'; function FilesCommentButton(filesContainerElement) { - this.render = bind(this.render, this); - this.hideButton = bind(this.hideButton, this); + this.render = this.render.bind(this); + this.hideButton = this.hideButton.bind(this); this.isParallelView = notes.isParallelView(); filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render) .on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton); @@ -55,14 +54,19 @@ window.FilesCommentButton = (function() { textFileElement = this.getTextFileElement($currentTarget); buttonParentElement.append(this.buildButton({ + discussionID: lineContentElement.attr('data-discussion-id'), + lineType: lineContentElement.attr('data-line-type'), + noteableType: textFileElement.attr('data-noteable-type'), noteableID: textFileElement.attr('data-noteable-id'), commitID: textFileElement.attr('data-commit-id'), noteType: lineContentElement.attr('data-note-type'), - position: lineContentElement.attr('data-position'), - lineType: lineContentElement.attr('data-line-type'), - discussionID: lineContentElement.attr('data-discussion-id'), - lineCode: lineContentElement.attr('data-line-code') + + // LegacyDiffNote + lineCode: lineContentElement.attr('data-line-code'), + + // DiffNote + position: lineContentElement.attr('data-position') })); }; @@ -76,14 +80,19 @@ window.FilesCommentButton = (function() { FilesCommentButton.prototype.buildButton = function(buttonAttributes) { return $commentButtonTemplate.clone().attr({ + 'data-discussion-id': buttonAttributes.discussionID, + 'data-line-type': buttonAttributes.lineType, + 'data-noteable-type': buttonAttributes.noteableType, 'data-noteable-id': buttonAttributes.noteableID, 'data-commit-id': buttonAttributes.commitID, 'data-note-type': buttonAttributes.noteType, + + // LegacyDiffNote 'data-line-code': buttonAttributes.lineCode, - 'data-position': buttonAttributes.position, - 'data-discussion-id': buttonAttributes.discussionID, - 'data-line-type': buttonAttributes.lineType + + // DiffNote + 'data-position': buttonAttributes.position }); }; @@ -121,7 +130,7 @@ window.FilesCommentButton = (function() { }; FilesCommentButton.prototype.validateLineContent = function(lineContentElement) { - return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== ''; + return lineContentElement.attr('data-note-type') && lineContentElement.attr('data-note-type') !== ''; }; return FilesCommentButton; diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js new file mode 100644 index 00000000000..15052dbd362 --- /dev/null +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -0,0 +1,97 @@ +import eventHub from '../event_hub'; + +export default { + name: 'RecentSearchesDropdownContent', + + props: { + items: { + type: Array, + required: true, + }, + isLocalStorageAvailable: { + type: Boolean, + required: false, + default: true, + }, + }, + + computed: { + processedItems() { + return this.items.map((item) => { + const { tokens, searchToken } + = gl.FilteredSearchTokenizer.processTokens(item); + + const resultantTokens = tokens.map(token => ({ + prefix: `${token.key}:`, + suffix: `${token.symbol}${token.value}`, + })); + + return { + text: item, + tokens: resultantTokens, + searchToken, + }; + }); + }, + hasItems() { + return this.items.length > 0; + }, + }, + + methods: { + onItemActivated(text) { + eventHub.$emit('recentSearchesItemSelected', text); + }, + onRequestClearRecentSearches(e) { + // Stop the dropdown from closing + e.stopPropagation(); + + eventHub.$emit('requestClearRecentSearches'); + }, + }, + + template: ` + <div> + <div + v-if="!isLocalStorageAvailable" + class="dropdown-info-note"> + This feature requires local storage to be enabled + </div> + <ul v-else-if="hasItems"> + <li + v-for="(item, index) in processedItems" + :key="index"> + <button + type="button" + class="filtered-search-history-dropdown-item" + @click="onItemActivated(item.text)"> + <span> + <span + v-for="(token, tokenIndex) in item.tokens" + class="filtered-search-history-dropdown-token"> + <span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span> + </span> + </span> + <span class="filtered-search-history-dropdown-search-token"> + {{ item.searchToken }} + </span> + </button> + </li> + <li class="divider"></li> + <li> + <button + type="button" + class="filtered-search-history-clear-button" + @click="onRequestClearRecentSearches($event)"> + Clear recent searches + </button> + </li> + </ul> + <div + v-else + class="dropdown-info-note"> + You don't have any recent searches + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 98dcb697af9..5d92d29c399 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -1,83 +1,80 @@ -require('./filtered_search_dropdown'); +import Filter from '~/droplab/plugins/filter'; +import './filtered_search_dropdown'; -/* global droplabFilter */ - -(() => { - class DropdownHint extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { - super(droplab, dropdown, input, filter); - this.config = { - droplabFilter: { - template: 'hint', - filterFunction: gl.DropdownUtils.filterHint.bind(null, input), - }, - }; - } - - itemClicked(e) { - const { selected } = e.detail; +class DropdownHint extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + super(droplab, dropdown, input, filter); + this.config = { + Filter: { + template: 'hint', + filterFunction: gl.DropdownUtils.filterHint.bind(null, input), + }, + }; + } - if (selected.tagName === 'LI') { - if (selected.hasAttribute('data-value')) { - this.dismissDropdown(); - } else if (selected.getAttribute('data-action') === 'submit') { - this.dismissDropdown(); - this.dispatchFormSubmitEvent(); - } else { - const token = selected.querySelector('.js-filter-hint').innerText.trim(); - const tag = selected.querySelector('.js-filter-tag').innerText.trim(); + itemClicked(e) { + const { selected } = e.detail; - if (tag.length) { - // Get previous input values in the input field and convert them into visual tokens - const previousInputValues = this.input.value.split(' '); - const searchTerms = []; + if (selected.tagName === 'LI') { + if (selected.hasAttribute('data-value')) { + this.dismissDropdown(); + } else if (selected.getAttribute('data-action') === 'submit') { + this.dismissDropdown(); + this.dispatchFormSubmitEvent(); + } else { + const token = selected.querySelector('.js-filter-hint').innerText.trim(); + const tag = selected.querySelector('.js-filter-tag').innerText.trim(); - previousInputValues.forEach((value, index) => { - searchTerms.push(value); + if (tag.length) { + // Get previous input values in the input field and convert them into visual tokens + const previousInputValues = this.input.value.split(' '); + const searchTerms = []; - if (index === previousInputValues.length - 1 - && token.indexOf(value.toLowerCase()) !== -1) { - searchTerms.pop(); - } - }); + previousInputValues.forEach((value, index) => { + searchTerms.push(value); - if (searchTerms.length > 0) { - gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' ')); + if (index === previousInputValues.length - 1 + && token.indexOf(value.toLowerCase()) !== -1) { + searchTerms.pop(); } + }); - gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container); + if (searchTerms.length > 0) { + gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' ')); } - this.dismissDropdown(); - this.dispatchInputEvent(); + + gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''), '', false, this.container); } + this.dismissDropdown(); + this.dispatchInputEvent(); } } + } - renderContent() { - const dropdownData = []; + renderContent() { + const dropdownData = []; - [].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { - const { icon, hint, tag, type } = dropdownMenu.dataset; - if (icon && hint && tag) { - dropdownData.push( - Object.assign({ - icon: `fa-${icon}`, - hint, - tag: `<${tag}>`, - }, type && { type }), - ); - } - }); + [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { + const { icon, hint, tag, type } = dropdownMenu.dataset; + if (icon && hint && tag) { + dropdownData.push( + Object.assign({ + icon: `fa-${icon}`, + hint, + tag: `<${tag}>`, + }, type && { type }), + ); + } + }); - this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config); - this.droplab.setData(this.hookId, dropdownData); - } + this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); + this.droplab.setData(this.hookId, dropdownData); + } - init() { - this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init(); - } + init() { + this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init(); } +} - window.gl = window.gl || {}; - gl.DropdownHint = DropdownHint; -})(); +window.gl = window.gl || {}; +gl.DropdownHint = DropdownHint; diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index b3dc3e502c5..f20193eecba 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,44 +1,49 @@ -require('./filtered_search_dropdown'); +/* global Flash */ -/* global droplabAjax */ -/* global droplabFilter */ +import Ajax from '~/droplab/plugins/ajax'; +import Filter from '~/droplab/plugins/filter'; +import './filtered_search_dropdown'; -(() => { - class DropdownNonUser extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter, endpoint, symbol) { - super(droplab, dropdown, input, filter); - this.symbol = symbol; - this.config = { - droplabAjax: { - endpoint, - method: 'setData', - loadingTemplate: this.loadingTemplate, +class DropdownNonUser extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter, endpoint, symbol) { + super(droplab, dropdown, input, filter); + this.symbol = symbol; + this.config = { + Ajax: { + endpoint, + method: 'setData', + loadingTemplate: this.loadingTemplate, + onError() { + /* eslint-disable no-new */ + new Flash('An error occured fetching the dropdown data.'); + /* eslint-enable no-new */ }, - droplabFilter: { - filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input), - }, - }; - } + }, + Filter: { + filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input), + template: 'title', + }, + }; + } - itemClicked(e) { - super.itemClicked(e, (selected) => { - const title = selected.querySelector('.js-data-value').innerText.trim(); - return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`; - }); - } + 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); - } + renderContent(forceShowList = false) { + this.droplab + .changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config); + super.renderContent(forceShowList); + } - init() { - this.droplab - .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init(); - } + init() { + this.droplab + .addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init(); } +} - window.gl = window.gl || {}; - gl.DropdownNonUser = DropdownNonUser; -})(); +window.gl = window.gl || {}; +gl.DropdownNonUser = DropdownNonUser; diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 04e2afad02f..42538780e50 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -1,65 +1,69 @@ -require('./filtered_search_dropdown'); +/* global Flash */ -/* global droplabAjaxFilter */ +import AjaxFilter from '~/droplab/plugins/ajax_filter'; +import './filtered_search_dropdown'; -(() => { - class DropdownUser extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { - super(droplab, dropdown, input, filter); - this.config = { - droplabAjaxFilter: { - endpoint: `${gon.relative_url_root || ''}/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, +class DropdownUser extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + super(droplab, dropdown, input, filter); + this.config = { + AjaxFilter: { + endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`, + searchKey: 'search', + params: { + per_page: 20, + active: true, + project_id: this.getProjectId(), + current_user: true, }, - }; - } - - 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); - } + searchValueFunction: this.getSearchInput.bind(this), + loadingTemplate: this.loadingTemplate, + onError() { + /* eslint-disable no-new */ + new Flash('An error occured fetching the dropdown data.'); + /* eslint-enable no-new */ + }, + }, + }; + } - getProjectId() { - return this.input.getAttribute('data-project-id'); - } + itemClicked(e) { + super.itemClicked(e, + selected => selected.querySelector('.dropdown-light-content').innerText.trim()); + } - getSearchInput() { - const query = gl.DropdownUtils.getSearchInput(this.input); - const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); + renderContent(forceShowList = false) { + this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config); + super.renderContent(forceShowList); + } - let value = lastToken || ''; + getProjectId() { + return this.input.getAttribute('data-project-id'); + } - if (value[0] === '@') { - value = value.slice(1); - } + getSearchInput() { + const query = gl.DropdownUtils.getSearchInput(this.input); + const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); - // Removes the first character if it is a quotation so that we can search - // with multiple words - if (value[0] === '"' || value[0] === '\'') { - value = value.slice(1); - } + let value = lastToken || ''; - return value; + if (value[0] === '@') { + value = value.slice(1); } - init() { - this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init(); + // Removes the first character if it is a quotation so that we can search + // with multiple words + if (value[0] === '"' || value[0] === '\'') { + value = value.slice(1); } + + return value; + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init(); } +} - window.gl = window.gl || {}; - gl.DropdownUser = DropdownUser; -})(); +window.gl = window.gl || {}; +gl.DropdownUser = DropdownUser; diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 432b0c0dfd2..bc7c1dffece 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -1,181 +1,181 @@ import FilteredSearchContainer from './container'; -(() => { - 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}"`; - } +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, input, item) { - const updatedItem = item; - const searchInput = gl.DropdownUtils.getSearchInput(input); + return escapedText; + } + + static filterWithSymbol(filterSymbol, input, item) { + const updatedItem = item; + const searchInput = gl.DropdownUtils.getSearchInput(input); - const title = updatedItem.title.toLowerCase(); - let value = searchInput.toLowerCase(); - let symbol = ''; + const title = updatedItem.title.toLowerCase(); + let value = searchInput.toLowerCase(); + let symbol = ''; - // Remove the symbol for filter - if (value[0] === filterSymbol) { - symbol = value[0]; - value = value.slice(1); - } + // Remove the symbol for filter + if (value[0] === filterSymbol) { + symbol = value[0]; + value = value.slice(1); + } - // Removes the first character if it is a quotation so that we can search - // with multiple words - if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) { - value = value.slice(1); - } + // Removes the first character if it is a quotation so that we can search + // with multiple words + if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) { + value = value.slice(1); + } + + // Eg. filterSymbol = ~ for labels + const matchWithoutSymbol = symbol === filterSymbol && title.indexOf(value) !== -1; + const match = title.indexOf(`${symbol}${value}`) !== -1; - // Eg. filterSymbol = ~ for labels - const matchWithoutSymbol = symbol === filterSymbol && title.indexOf(value) !== -1; - const match = title.indexOf(`${symbol}${value}`) !== -1; + updatedItem.droplab_hidden = !match && !matchWithoutSymbol; - updatedItem.droplab_hidden = !match && !matchWithoutSymbol; + return updatedItem; + } - return updatedItem; + static filterHint(input, item) { + const updatedItem = item; + const searchInput = gl.DropdownUtils.getSearchQuery(input); + const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput); + const lastKey = lastToken.key || lastToken || ''; + const allowMultiple = item.type === 'array'; + const itemInExistingTokens = tokens.some(t => t.key === item.hint); + + if (!allowMultiple && itemInExistingTokens) { + updatedItem.droplab_hidden = true; + } else if (!lastKey || searchInput.split('').last() === ' ') { + updatedItem.droplab_hidden = false; + } else if (lastKey) { + const split = lastKey.split(':'); + const tokenName = split[0].split(' ').last(); + + const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; + updatedItem.droplab_hidden = tokenName ? match : false; } - static filterHint(input, item) { - const updatedItem = item; - const searchInput = gl.DropdownUtils.getSearchQuery(input); - const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput); - const lastKey = lastToken.key || lastToken || ''; - const allowMultiple = item.type === 'array'; - const itemInExistingTokens = tokens.some(t => t.key === item.hint); - - if (!allowMultiple && itemInExistingTokens) { - updatedItem.droplab_hidden = true; - } else if (!lastKey || searchInput.split('').last() === ' ') { - updatedItem.droplab_hidden = false; - } else if (lastKey) { - const split = lastKey.split(':'); - const tokenName = split[0].split(' ').last(); - - const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; - updatedItem.droplab_hidden = tokenName ? match : false; - } + return updatedItem; + } + + static setDataValueIfSelected(filter, selected) { + const dataValue = selected.getAttribute('data-value'); - return updatedItem; + if (dataValue) { + gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true); } - static setDataValueIfSelected(filter, selected) { - const dataValue = selected.getAttribute('data-value'); + // Return boolean based on whether it was set + return dataValue !== null; + } - if (dataValue) { - gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true); - } + // Determines the full search query (visual tokens + input) + static getSearchQuery(untilInput = false) { + const container = FilteredSearchContainer.container; + const tokens = [].slice.call(container.querySelectorAll('.tokens-container li')); + const values = []; - // Return boolean based on whether it was set - return dataValue !== null; + if (untilInput) { + const inputIndex = _.findIndex(tokens, t => t.classList.contains('input-token')); + // Add one to include input-token to the tokens array + tokens.splice(inputIndex + 1); } - // Determines the full search query (visual tokens + input) - static getSearchQuery(untilInput = false) { - const container = FilteredSearchContainer.container; - const tokens = [].slice.call(container.querySelectorAll('.tokens-container li')); - const values = []; + tokens.forEach((token) => { + if (token.classList.contains('js-visual-token')) { + const name = token.querySelector('.name'); + const value = token.querySelector('.value'); + const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; + let valueText = ''; - if (untilInput) { - const inputIndex = _.findIndex(tokens, t => t.classList.contains('input-token')); - // Add one to include input-token to the tokens array - tokens.splice(inputIndex + 1); - } + if (value && value.innerText) { + valueText = value.innerText; + } - tokens.forEach((token) => { - if (token.classList.contains('js-visual-token')) { - const name = token.querySelector('.name'); - const value = token.querySelector('.value'); - const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; - let valueText = ''; - - if (value && value.innerText) { - valueText = value.innerText; - } - - if (token.className.indexOf('filtered-search-token') !== -1) { - values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`); - } else { - values.push(name.innerText); - } - } else if (token.classList.contains('input-token')) { - const { isLastVisualTokenValid } = - gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - - const input = FilteredSearchContainer.container.querySelector('.filtered-search'); - const inputValue = input && input.value; - - if (isLastVisualTokenValid) { - values.push(inputValue); - } else { - const previous = values.pop(); - values.push(`${previous}${inputValue}`); - } + if (token.className.indexOf('filtered-search-token') !== -1) { + values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`); + } else { + values.push(name.innerText); } - }); + } else if (token.classList.contains('input-token')) { + const { isLastVisualTokenValid } = + gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - return values.join(' '); - } + const input = FilteredSearchContainer.container.querySelector('.filtered-search'); + const inputValue = input && input.value; - static getSearchInput(filteredSearchInput) { - const inputValue = filteredSearchInput.value; - const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput); + if (isLastVisualTokenValid) { + values.push(inputValue); + } else { + const previous = values.pop(); + values.push(`${previous}${inputValue}`); + } + } + }); - return inputValue.slice(0, right); - } + return values + .map(value => value.trim()) + .join(' '); + } - static getInputSelectionPosition(input) { - const selectionStart = input.selectionStart; - let inputValue = input.value; - // Replace all spaces inside quote marks with underscores - // (will continue to match entire string until an end quote is found if any) - // This helps with matching the beginning & end of a token:key - inputValue = inputValue.replace(/(('[^']*'{0,1})|("[^"]*"{0,1})|:\s+)/g, str => str.replace(/\s/g, '_')); - - // Get the right position for the word selected - // Regex matches first space - let right = inputValue.slice(selectionStart).search(/\s/); - - if (right >= 0) { - right += selectionStart; - } else if (right < 0) { - right = inputValue.length; - } + static getSearchInput(filteredSearchInput) { + const inputValue = filteredSearchInput.value; + const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput); - // Get the left position for the word selected - // Regex matches last non-whitespace character - let left = inputValue.slice(0, right).search(/\S+$/); + return inputValue.slice(0, right); + } - if (selectionStart === 0) { - left = 0; - } else if (selectionStart === inputValue.length && left < 0) { - left = inputValue.length; - } else if (left < 0) { - left = selectionStart; - } + static getInputSelectionPosition(input) { + const selectionStart = input.selectionStart; + let inputValue = input.value; + // Replace all spaces inside quote marks with underscores + // (will continue to match entire string until an end quote is found if any) + // This helps with matching the beginning & end of a token:key + inputValue = inputValue.replace(/(('[^']*'{0,1})|("[^"]*"{0,1})|:\s+)/g, str => str.replace(/\s/g, '_')); + + // Get the right position for the word selected + // Regex matches first space + let right = inputValue.slice(selectionStart).search(/\s/); + + if (right >= 0) { + right += selectionStart; + } else if (right < 0) { + right = inputValue.length; + } + + // Get the left position for the word selected + // Regex matches last non-whitespace character + let left = inputValue.slice(0, right).search(/\S+$/); - return { - left, - right, - }; + if (selectionStart === 0) { + left = 0; + } else if (selectionStart === inputValue.length && left < 0) { + left = inputValue.length; + } else if (left < 0) { + left = selectionStart; } + + return { + left, + right, + }; } +} - window.gl = window.gl || {}; - gl.DropdownUtils = DropdownUtils; -})(); +window.gl = window.gl || {}; +gl.DropdownUtils = DropdownUtils; diff --git a/app/assets/javascripts/filtered_search/event_hub.js b/app/assets/javascripts/filtered_search/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/filtered_search/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js index 856eb6590ee..5d48b8aacb2 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -1,10 +1,10 @@ -require('./dropdown_hint'); -require('./dropdown_non_user'); -require('./dropdown_user'); -require('./dropdown_utils'); -require('./filtered_search_dropdown_manager'); -require('./filtered_search_dropdown'); -require('./filtered_search_manager'); -require('./filtered_search_token_keys'); -require('./filtered_search_tokenizer'); -require('./filtered_search_visual_tokens'); +import './dropdown_hint'; +import './dropdown_non_user'; +import './dropdown_user'; +import './dropdown_utils'; +import './filtered_search_dropdown_manager'; +import './filtered_search_dropdown'; +import './filtered_search_manager'; +import './filtered_search_token_keys'; +import './filtered_search_tokenizer'; +import './filtered_search_visual_tokens'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index e7bf530d343..4209ca0d6e2 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -1,124 +1,122 @@ -(() => { - const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; - - class FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { - this.droplab = droplab; - this.hookId = input && input.getAttribute('data-id'); - this.input = input; - this.filter = filter; - this.dropdown = dropdown; - this.loadingTemplate = `<div class="filter-dropdown-loading"> - <i class="fa fa-spinner fa-spin"></i> - </div>`; - this.bindEvents(); - } - - bindEvents() { - this.itemClickedWrapper = this.itemClicked.bind(this); - this.dropdown.addEventListener('click.dl', this.itemClickedWrapper); - } +const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; + +class FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + this.droplab = droplab; + this.hookId = input && input.id; + this.input = input; + this.filter = filter; + this.dropdown = dropdown; + this.loadingTemplate = `<div class="filter-dropdown-loading"> + <i class="fa fa-spinner fa-spin"></i> + </div>`; + this.bindEvents(); + } - unbindEvents() { - this.dropdown.removeEventListener('click.dl', this.itemClickedWrapper); - } + bindEvents() { + this.itemClickedWrapper = this.itemClicked.bind(this); + this.dropdown.addEventListener('click.dl', this.itemClickedWrapper); + } - getCurrentHook() { - return this.droplab.hooks.filter(h => h.id === this.hookId)[0] || null; - } + unbindEvents() { + this.dropdown.removeEventListener('click.dl', this.itemClickedWrapper); + } - itemClicked(e, getValueFunction) { - const { selected } = e.detail; + getCurrentHook() { + return this.droplab.hooks.filter(h => h.id === this.hookId)[0] || null; + } - if (selected.tagName === 'LI' && selected.innerHTML) { - const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected); + itemClicked(e, getValueFunction) { + const { selected } = e.detail; - if (!dataValueSet) { - const value = getValueFunction(selected); - gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true); - } + if (selected.tagName === 'LI' && selected.innerHTML) { + const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected); - this.resetFilters(); - this.dismissDropdown(); - this.dispatchInputEvent(); + if (!dataValueSet) { + const value = getValueFunction(selected); + gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true); } - } - setAsDropdown() { - this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`); + this.resetFilters(); + this.dismissDropdown(); + this.dispatchInputEvent(); } + } - setOffset(offset = 0) { - if (window.innerWidth > 480) { - this.dropdown.style.left = `${offset}px`; - } else { - this.dropdown.style.left = '0px'; - } + setAsDropdown() { + this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`); + } + + setOffset(offset = 0) { + if (window.innerWidth > 480) { + this.dropdown.style.left = `${offset}px`; + } else { + this.dropdown.style.left = '0px'; } + } - renderContent(forceShowList = false) { - const currentHook = this.getCurrentHook(); - if (forceShowList && currentHook && currentHook.list.hidden) { - currentHook.list.show(); - } + renderContent(forceShowList = false) { + const currentHook = this.getCurrentHook(); + if (forceShowList && currentHook && currentHook.list.hidden) { + currentHook.list.show(); } + } - render(forceRenderContent = false, forceShowList = false) { - this.setAsDropdown(); + render(forceRenderContent = false, forceShowList = false) { + this.setAsDropdown(); - const currentHook = this.getCurrentHook(); - const firstTimeInitialized = currentHook === null; + 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); - } + 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(); - } + 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 CustomEvent('input', { - bubbles: true, - cancelable: true, - })); - } + dispatchInputEvent() { + // Propogate input change to FilteredSearchDropdownManager + // so that it can determine which dropdowns to open + this.input.dispatchEvent(new CustomEvent('input', { + bubbles: true, + cancelable: true, + })); + } - dispatchFormSubmitEvent() { - // dispatchEvent() is necessary as form.submit() does not - // trigger event handlers - this.input.form.dispatchEvent(new Event('submit')); - } + dispatchFormSubmitEvent() { + // dispatchEvent() is necessary as form.submit() does not + // trigger event handlers + this.input.form.dispatchEvent(new Event('submit')); + } - hideDropdown() { - const currentHook = this.getCurrentHook(); - if (currentHook) { - currentHook.list.hide(); - } + hideDropdown() { + const currentHook = this.getCurrentHook(); + if (currentHook) { + currentHook.list.hide(); } + } - resetFilters() { - const hook = this.getCurrentHook(); - - if (hook) { - const data = hook.list.data || []; - const results = data.map((o) => { - const updated = o; - updated.droplab_hidden = false; - return updated; - }); - hook.list.render(results); - } + resetFilters() { + const hook = this.getCurrentHook(); + + if (hook) { + 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; -})(); +window.gl = window.gl || {}; +gl.FilteredSearchDropdown = FilteredSearchDropdown; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 5fbe0450bb8..49a6cd1ac77 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -1,191 +1,189 @@ -/* global DropLab */ +import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; -(() => { - class FilteredSearchDropdownManager { - constructor(baseEndpoint = '', page) { - this.container = FilteredSearchContainer.container; - this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); - this.tokenizer = gl.FilteredSearchTokenizer; - this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; - this.filteredSearchInput = this.container.querySelector('.filtered-search'); - this.page = page; +class FilteredSearchDropdownManager { + constructor(baseEndpoint = '', page) { + this.container = FilteredSearchContainer.container; + this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); + this.tokenizer = gl.FilteredSearchTokenizer; + this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + this.filteredSearchInput = this.container.querySelector('.filtered-search'); + this.page = page; - this.setupMapping(); + this.setupMapping(); - this.cleanupWrapper = this.cleanup.bind(this); - document.addEventListener('beforeunload', this.cleanupWrapper); + this.cleanupWrapper = this.cleanup.bind(this); + document.addEventListener('beforeunload', this.cleanupWrapper); + } + + cleanup() { + if (this.droplab) { + this.droplab.destroy(); + this.droplab = null; } - cleanup() { - if (this.droplab) { - this.droplab.destroy(); - this.droplab = null; - } + this.setupMapping(); - this.setupMapping(); + document.removeEventListener('beforeunload', this.cleanupWrapper); + } - document.removeEventListener('beforeunload', this.cleanupWrapper); - } + setupMapping() { + this.mapping = { + author: { + reference: null, + gl: 'DropdownUser', + element: this.container.querySelector('#js-dropdown-author'), + }, + assignee: { + reference: null, + gl: 'DropdownUser', + element: this.container.querySelector('#js-dropdown-assignee'), + }, + milestone: { + reference: null, + gl: 'DropdownNonUser', + extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'], + element: this.container.querySelector('#js-dropdown-milestone'), + }, + label: { + reference: null, + gl: 'DropdownNonUser', + extraArguments: [`${this.baseEndpoint}/labels.json`, '~'], + element: this.container.querySelector('#js-dropdown-label'), + }, + hint: { + reference: null, + gl: 'DropdownHint', + element: this.container.querySelector('#js-dropdown-hint'), + }, + }; + } - setupMapping() { - this.mapping = { - author: { - reference: null, - gl: 'DropdownUser', - element: this.container.querySelector('#js-dropdown-author'), - }, - assignee: { - reference: null, - gl: 'DropdownUser', - element: this.container.querySelector('#js-dropdown-assignee'), - }, - milestone: { - reference: null, - gl: 'DropdownNonUser', - extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'], - element: this.container.querySelector('#js-dropdown-milestone'), - }, - label: { - reference: null, - gl: 'DropdownNonUser', - extraArguments: [`${this.baseEndpoint}/labels.json`, '~'], - element: this.container.querySelector('#js-dropdown-label'), - }, - hint: { - reference: null, - gl: 'DropdownHint', - element: this.container.querySelector('#js-dropdown-hint'), - }, - }; + static addWordToInput(tokenName, tokenValue = '', clicked = false) { + const input = FilteredSearchContainer.container.querySelector('.filtered-search'); + + gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue); + input.value = ''; + + if (clicked) { + gl.FilteredSearchVisualTokens.moveInputToTheRight(); } + } - static addWordToInput(tokenName, tokenValue = '', clicked = false) { - const input = FilteredSearchContainer.container.querySelector('.filtered-search'); + updateCurrentDropdownOffset() { + this.updateDropdownOffset(this.currentDropdown); + } - gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue); - input.value = ''; + updateDropdownOffset(key) { + // Always align dropdown with the input field + let offset = this.filteredSearchInput.getBoundingClientRect().left - this.container.querySelector('.scroll-container').getBoundingClientRect().left; - if (clicked) { - gl.FilteredSearchVisualTokens.moveInputToTheRight(); - } - } + const maxInputWidth = 240; + const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth; - updateCurrentDropdownOffset() { - this.updateDropdownOffset(this.currentDropdown); + // Make sure offset never exceeds the input container + const offsetMaxWidth = this.container.querySelector('.scroll-container').clientWidth - currentDropdownWidth; + if (offsetMaxWidth < offset) { + offset = offsetMaxWidth; } - updateDropdownOffset(key) { - // Always align dropdown with the input field - let offset = this.filteredSearchInput.getBoundingClientRect().left - this.container.querySelector('.scroll-container').getBoundingClientRect().left; + this.mapping[key].reference.setOffset(offset); + } - const maxInputWidth = 240; - const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth; + load(key, firstLoad = false) { + const mappingKey = this.mapping[key]; + const glClass = mappingKey.gl; + const element = mappingKey.element; + let forceShowList = false; - // Make sure offset never exceeds the input container - const offsetMaxWidth = this.container.querySelector('.scroll-container').clientWidth - currentDropdownWidth; - if (offsetMaxWidth < offset) { - offset = offsetMaxWidth; - } + if (!mappingKey.reference) { + const dl = this.droplab; + const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; + const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); - this.mapping[key].reference.setOffset(offset); + // Passing glArguments to `new gl[glClass](<arguments>)` + mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))(); } - load(key, firstLoad = false) { - const mappingKey = this.mapping[key]; - const glClass = mappingKey.gl; - const element = mappingKey.element; - let forceShowList = false; - - if (!mappingKey.reference) { - const dl = this.droplab; - const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; - const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); + if (firstLoad) { + mappingKey.reference.init(); + } - // Passing glArguments to `new gl[glClass](<arguments>)` - mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))(); - } + if (this.currentDropdown === 'hint') { + // Force the dropdown to show if it was clicked from the hint dropdown + forceShowList = true; + } - if (firstLoad) { - mappingKey.reference.init(); - } + this.updateDropdownOffset(key); + mappingKey.reference.render(firstLoad, forceShowList); - if (this.currentDropdown === 'hint') { - // Force the dropdown to show if it was clicked from the hint dropdown - forceShowList = true; - } + this.currentDropdown = key; + } - this.updateDropdownOffset(key); - mappingKey.reference.render(firstLoad, forceShowList); + loadDropdown(dropdownName = '') { + let firstLoad = false; - this.currentDropdown = key; + if (!this.droplab) { + firstLoad = true; + this.droplab = new DropLab(); } - loadDropdown(dropdownName = '') { - let firstLoad = false; + const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); + const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key + && this.mapping[match.key]; + const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; - if (!this.droplab) { - firstLoad = true; - this.droplab = new DropLab(); - } + if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { + const key = match && match.key ? match.key : 'hint'; + this.load(key, firstLoad); + } + } - const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); - const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key - && this.mapping[match.key]; - const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; + setDropdown() { + const query = gl.DropdownUtils.getSearchQuery(true); + const { lastToken, searchToken } = this.tokenizer.processTokens(query); - if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { - const key = match && match.key ? match.key : 'hint'; - this.load(key, firstLoad); - } + if (this.currentDropdown) { + this.updateCurrentDropdownOffset(); } - setDropdown() { - const query = gl.DropdownUtils.getSearchQuery(true); - const { lastToken, searchToken } = this.tokenizer.processTokens(query); - - if (this.currentDropdown) { - this.updateCurrentDropdownOffset(); - } - - if (lastToken === searchToken && lastToken !== null) { - // Token is not fully initialized yet because it has no value - // Eg. token = 'label:' - - const split = lastToken.split(':'); - const dropdownName = split[0].split(' ').last(); - this.loadDropdown(split.length > 1 ? dropdownName : ''); - } else if (lastToken) { - // Token has been initialized into an object because it has a value - this.loadDropdown(lastToken.key); - } else { - this.loadDropdown('hint'); - } + if (lastToken === searchToken && lastToken !== null) { + // Token is not fully initialized yet because it has no value + // Eg. token = 'label:' + + const split = lastToken.split(':'); + const dropdownName = split[0].split(' ').last(); + this.loadDropdown(split.length > 1 ? dropdownName : ''); + } else if (lastToken) { + // Token has been initialized into an object because it has a value + this.loadDropdown(lastToken.key); + } else { + this.loadDropdown('hint'); } + } - resetDropdowns() { - if (!this.currentDropdown) { - return; - } + resetDropdowns() { + if (!this.currentDropdown) { + return; + } - // Force current dropdown to hide - this.mapping[this.currentDropdown].reference.hideDropdown(); + // Force current dropdown to hide + this.mapping[this.currentDropdown].reference.hideDropdown(); - // Re-Load dropdown - this.setDropdown(); + // Re-Load dropdown + this.setDropdown(); - // Reset filters for current dropdown - this.mapping[this.currentDropdown].reference.resetFilters(); + // Reset filters for current dropdown + this.mapping[this.currentDropdown].reference.resetFilters(); - // Reposition dropdown so that it is aligned with cursor - this.updateDropdownOffset(this.currentDropdown); - } + // Reposition dropdown so that it is aligned with cursor + this.updateDropdownOffset(this.currentDropdown); + } - destroyDroplab() { - this.droplab.destroy(); - } + destroyDroplab() { + this.droplab.destroy(); } +} - window.gl = window.gl || {}; - gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager; -})(); +window.gl = window.gl || {}; +gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager; diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 22352950452..57d247e11a9 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,423 +1,521 @@ import FilteredSearchContainer from './container'; - -(() => { - class FilteredSearchManager { - constructor(page) { - this.container = FilteredSearchContainer.container; - this.filteredSearchInput = this.container.querySelector('.filtered-search'); - this.clearSearchButton = this.container.querySelector('.clear-search'); - this.tokensContainer = this.container.querySelector('.tokens-container'); - this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; - - if (this.filteredSearchInput) { - this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); - - this.bindEvents(); - this.loadSearchParamsFromURL(); - this.dropdownManager.setDropdown(); - - this.cleanupWrapper = this.cleanup.bind(this); - document.addEventListener('beforeunload', this.cleanupWrapper); - } +import RecentSearchesRoot from './recent_searches_root'; +import RecentSearchesStore from './stores/recent_searches_store'; +import RecentSearchesService from './services/recent_searches_service'; +import eventHub from './event_hub'; + +class FilteredSearchManager { + constructor(page) { + this.container = FilteredSearchContainer.container; + this.filteredSearchInput = this.container.querySelector('.filtered-search'); + this.filteredSearchInputForm = this.filteredSearchInput.form; + this.clearSearchButton = this.container.querySelector('.clear-search'); + this.tokensContainer = this.container.querySelector('.tokens-container'); + this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + + this.recentSearchesStore = new RecentSearchesStore({ + isLocalStorageAvailable: RecentSearchesService.isAvailable(), + }); + const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); + const projectPath = searchHistoryDropdownElement ? + searchHistoryDropdownElement.dataset.projectFullPath : 'project'; + let recentSearchesPagePrefix = 'issue-recent-searches'; + if (page === 'merge_requests') { + recentSearchesPagePrefix = 'merge-request-recent-searches'; } + const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`; + this.recentSearchesService = new RecentSearchesService(recentSearchesKey); + + // Fetch recent searches from localStorage + this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() + .catch((error) => { + if (error.name === 'RecentSearchesServiceError') return undefined; + // eslint-disable-next-line no-new + new window.Flash('An error occured while parsing recent searches'); + // Gracefully fail to empty array + return []; + }) + .then((searches) => { + // Put any searches that may have come in before + // we fetched the saved searches ahead of the already saved ones + const resultantSearches = this.recentSearchesStore.setRecentSearches( + this.recentSearchesStore.state.recentSearches.concat(searches), + ); + this.recentSearchesService.save(resultantSearches); + }); - cleanup() { - this.unbindEvents(); - document.removeEventListener('beforeunload', this.cleanupWrapper); - } + if (this.filteredSearchInput) { + this.tokenizer = gl.FilteredSearchTokenizer; + this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); - bindEvents() { - this.handleFormSubmit = this.handleFormSubmit.bind(this); - this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); - this.toggleClearSearchButtonWrapper = this.toggleClearSearchButton.bind(this); - this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this); - this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this); - this.checkForEnterWrapper = this.checkForEnter.bind(this); - this.clearSearchWrapper = this.clearSearch.bind(this); - this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); - this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); - this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); - this.editTokenWrapper = this.editToken.bind(this); - this.tokenChange = this.tokenChange.bind(this); - this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); - this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this); - - this.filteredSearchInputForm = this.filteredSearchInput.form; - this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit); - this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); - this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); - this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper); - this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper); - this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); - this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); - this.filteredSearchInput.addEventListener('click', this.tokenChange); - this.filteredSearchInput.addEventListener('keyup', this.tokenChange); - this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); - this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); - this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); - this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); - document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); - document.addEventListener('click', this.unselectEditTokensWrapper); - document.addEventListener('click', this.removeInputContainerFocusWrapper); - document.addEventListener('keydown', this.removeSelectedTokenWrapper); + this.recentSearchesRoot = new RecentSearchesRoot( + this.recentSearchesStore, + this.recentSearchesService, + searchHistoryDropdownElement, + ); + this.recentSearchesRoot.init(); + + this.bindEvents(); + this.loadSearchParamsFromURL(); + this.dropdownManager.setDropdown(); + + this.cleanupWrapper = this.cleanup.bind(this); + document.addEventListener('beforeunload', this.cleanupWrapper); } + } + + cleanup() { + this.unbindEvents(); + document.removeEventListener('beforeunload', this.cleanupWrapper); - unbindEvents() { - this.filteredSearchInputForm.removeEventListener('submit', this.handleFormSubmit); - this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); - this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); - this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); - this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper); - this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); - this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); - this.filteredSearchInput.removeEventListener('click', this.tokenChange); - this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); - this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); - this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); - this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); - this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); - document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); - document.removeEventListener('click', this.unselectEditTokensWrapper); - document.removeEventListener('click', this.removeInputContainerFocusWrapper); - document.removeEventListener('keydown', this.removeSelectedTokenWrapper); + if (this.recentSearchesRoot) { + this.recentSearchesRoot.destroy(); } + } - checkForBackspace(e) { - // 8 = Backspace Key - // 46 = Delete Key - if (e.keyCode === 8 || e.keyCode === 46) { - const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + bindEvents() { + this.handleFormSubmit = this.handleFormSubmit.bind(this); + this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); + this.toggleClearSearchButtonWrapper = this.toggleClearSearchButton.bind(this); + this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this); + this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this); + this.checkForEnterWrapper = this.checkForEnter.bind(this); + this.onClearSearchWrapper = this.onClearSearch.bind(this); + this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); + this.removeSelectedTokenKeydownWrapper = this.removeSelectedTokenKeydown.bind(this); + this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); + this.editTokenWrapper = this.editToken.bind(this); + this.tokenChange = this.tokenChange.bind(this); + this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); + this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this); + this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this); + this.removeTokenWrapper = this.removeToken.bind(this); + + this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit); + this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); + this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); + this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper); + this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper); + this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); + this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); + this.filteredSearchInput.addEventListener('click', this.tokenChange); + this.filteredSearchInput.addEventListener('keyup', this.tokenChange); + this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); + this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); + this.tokensContainer.addEventListener('click', this.removeTokenWrapper); + this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); + this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper); + document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); + document.addEventListener('click', this.unselectEditTokensWrapper); + document.addEventListener('click', this.removeInputContainerFocusWrapper); + document.addEventListener('keydown', this.removeSelectedTokenKeydownWrapper); + eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); + } - if (this.filteredSearchInput.value === '' && lastVisualToken) { - this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); - gl.FilteredSearchVisualTokens.removeLastTokenPartial(); - } + unbindEvents() { + this.filteredSearchInputForm.removeEventListener('submit', this.handleFormSubmit); + this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); + this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); + this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); + this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper); + this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); + this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); + this.filteredSearchInput.removeEventListener('click', this.tokenChange); + this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); + this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); + this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); + this.tokensContainer.removeEventListener('click', this.removeTokenWrapper); + this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); + this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper); + document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); + document.removeEventListener('click', this.unselectEditTokensWrapper); + document.removeEventListener('click', this.removeInputContainerFocusWrapper); + document.removeEventListener('keydown', this.removeSelectedTokenKeydownWrapper); + eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); + } - // Reposition dropdown so that it is aligned with cursor - this.dropdownManager.updateCurrentDropdownOffset(); + checkForBackspace(e) { + // 8 = Backspace Key + // 46 = Delete Key + if (e.keyCode === 8 || e.keyCode === 46) { + const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + if (this.filteredSearchInput.value === '' && lastVisualToken) { + this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); + gl.FilteredSearchVisualTokens.removeLastTokenPartial(); } - } - checkForEnter(e) { - if (e.keyCode === 38 || e.keyCode === 40) { - const selectionStart = this.filteredSearchInput.selectionStart; + // Reposition dropdown so that it is aligned with cursor + this.dropdownManager.updateCurrentDropdownOffset(); + } + } - e.preventDefault(); - this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart); - } + checkForEnter(e) { + if (e.keyCode === 38 || e.keyCode === 40) { + const selectionStart = this.filteredSearchInput.selectionStart; - if (e.keyCode === 13) { - const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; - const dropdownEl = dropdown.element; - const activeElements = dropdownEl.querySelectorAll('.dropdown-active'); + e.preventDefault(); + this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart); + } - e.preventDefault(); + if (e.keyCode === 13) { + const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; + const dropdownEl = dropdown.element; + const activeElements = dropdownEl.querySelectorAll('.droplab-item-active'); - if (!activeElements.length) { - if (this.isHandledAsync) { - e.stopImmediatePropagation(); + e.preventDefault(); - this.filteredSearchInput.blur(); - this.dropdownManager.resetDropdowns(); - } else { - // Prevent droplab from opening dropdown - this.dropdownManager.destroyDroplab(); - } + if (!activeElements.length) { + if (this.isHandledAsync) { + e.stopImmediatePropagation(); - this.search(); + this.filteredSearchInput.blur(); + this.dropdownManager.resetDropdowns(); + } else { + // Prevent droplab from opening dropdown + this.dropdownManager.destroyDroplab(); } + + this.search(); } } + } - addInputContainerFocus() { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + addInputContainerFocus() { + const inputContainer = this.filteredSearchInput.closest('.filtered-search-box'); - if (inputContainer) { - inputContainer.classList.add('focus'); - } + if (inputContainer) { + inputContainer.classList.add('focus'); } + } - removeInputContainerFocus(e) { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); - const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); - const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; - const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; + removeInputContainerFocus(e) { + const inputContainer = this.filteredSearchInput.closest('.filtered-search-box'); + const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); + const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; + const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; - if (!isElementInFilteredSearch && !isElementInDynamicFilterDropdown && - !isElementInStaticFilterDropdown && inputContainer) { - inputContainer.classList.remove('focus'); - } + if (!isElementInFilteredSearch && !isElementInDynamicFilterDropdown && + !isElementInStaticFilterDropdown && inputContainer) { + inputContainer.classList.remove('focus'); } + } - static selectToken(e) { - const button = e.target.closest('.selectable'); + static selectToken(e) { + const button = e.target.closest('.selectable'); + const removeButtonSelected = e.target.closest('.remove-token'); - if (button) { - e.preventDefault(); - e.stopPropagation(); - gl.FilteredSearchVisualTokens.selectToken(button); - } + if (!removeButtonSelected && button) { + e.preventDefault(); + e.stopPropagation(); + gl.FilteredSearchVisualTokens.selectToken(button); } + } - unselectEditTokens(e) { - const inputContainer = this.container.querySelector('.filtered-search-input-container'); - const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); - const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; - const isElementTokensContainer = e.target.classList.contains('tokens-container'); + removeToken(e) { + const removeButtonSelected = e.target.closest('.remove-token'); - if ((!isElementInFilteredSearch && !isElementInFilterDropdown) || isElementTokensContainer) { - gl.FilteredSearchVisualTokens.moveInputToTheRight(); - this.dropdownManager.resetDropdowns(); - } + if (removeButtonSelected) { + e.preventDefault(); + e.stopPropagation(); + + const button = e.target.closest('.selectable'); + gl.FilteredSearchVisualTokens.selectToken(button, true); + this.removeSelectedToken(); } + } - editToken(e) { - const token = e.target.closest('.js-visual-token'); + unselectEditTokens(e) { + const inputContainer = this.container.querySelector('.filtered-search-box'); + const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); + const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; + const isElementTokensContainer = e.target.classList.contains('tokens-container'); - if (token) { - gl.FilteredSearchVisualTokens.editToken(token); - this.tokenChange(); - } + if ((!isElementInFilteredSearch && !isElementInFilterDropdown) || isElementTokensContainer) { + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + this.dropdownManager.resetDropdowns(); } + } - toggleClearSearchButton() { - const query = gl.DropdownUtils.getSearchQuery(); - const hidden = 'hidden'; - const hasHidden = this.clearSearchButton.classList.contains(hidden); + editToken(e) { + const token = e.target.closest('.js-visual-token'); - if (query.length === 0 && !hasHidden) { - this.clearSearchButton.classList.add(hidden); - } else if (query.length && hasHidden) { - this.clearSearchButton.classList.remove(hidden); - } + if (token) { + gl.FilteredSearchVisualTokens.editToken(token); + this.tokenChange(); } + } - handleInputPlaceholder() { - const query = gl.DropdownUtils.getSearchQuery(); - const placeholder = 'Search or filter results...'; - const currentPlaceholder = this.filteredSearchInput.placeholder; + toggleClearSearchButton() { + const query = gl.DropdownUtils.getSearchQuery(); + const hidden = 'hidden'; + const hasHidden = this.clearSearchButton.classList.contains(hidden); - if (query.length === 0 && currentPlaceholder !== placeholder) { - this.filteredSearchInput.placeholder = placeholder; - } else if (query.length > 0 && currentPlaceholder !== '') { - this.filteredSearchInput.placeholder = ''; - } + if (query.length === 0 && !hasHidden) { + this.clearSearchButton.classList.add(hidden); + } else if (query.length && hasHidden) { + this.clearSearchButton.classList.remove(hidden); } + } - removeSelectedToken(e) { - // 8 = Backspace Key - // 46 = Delete Key - if (e.keyCode === 8 || e.keyCode === 46) { - gl.FilteredSearchVisualTokens.removeSelectedToken(); - this.handleInputPlaceholder(); - this.toggleClearSearchButton(); - } + handleInputPlaceholder() { + const query = gl.DropdownUtils.getSearchQuery(); + const placeholder = 'Search or filter results...'; + const currentPlaceholder = this.filteredSearchInput.placeholder; + + if (query.length === 0 && currentPlaceholder !== placeholder) { + this.filteredSearchInput.placeholder = placeholder; + } else if (query.length > 0 && currentPlaceholder !== '') { + this.filteredSearchInput.placeholder = ''; } + } - clearSearch(e) { - e.preventDefault(); + removeSelectedTokenKeydown(e) { + // 8 = Backspace Key + // 46 = Delete Key + if (e.keyCode === 8 || e.keyCode === 46) { + this.removeSelectedToken(); + } + } - this.filteredSearchInput.value = ''; + removeSelectedToken() { + gl.FilteredSearchVisualTokens.removeSelectedToken(); + this.handleInputPlaceholder(); + this.toggleClearSearchButton(); + this.dropdownManager.updateCurrentDropdownOffset(); + } - const removeElements = []; + onClearSearch(e) { + e.preventDefault(); + this.clearSearch(); + } - [].forEach.call(this.tokensContainer.children, (t) => { - if (t.classList.contains('js-visual-token')) { - removeElements.push(t); - } - }); + clearSearch() { + this.filteredSearchInput.value = ''; - removeElements.forEach((el) => { - el.parentElement.removeChild(el); - }); + const removeElements = []; - this.clearSearchButton.classList.add('hidden'); - this.handleInputPlaceholder(); + [].forEach.call(this.tokensContainer.children, (t) => { + if (t.classList.contains('js-visual-token')) { + removeElements.push(t); + } + }); - this.dropdownManager.resetDropdowns(); + removeElements.forEach((el) => { + el.parentElement.removeChild(el); + }); - if (this.isHandledAsync) { - this.search(); - } + this.clearSearchButton.classList.add('hidden'); + this.handleInputPlaceholder(); + + this.dropdownManager.resetDropdowns(); + + if (this.isHandledAsync) { + this.search(); } + } - handleInputVisualToken() { - const input = this.filteredSearchInput; - const { tokens, searchToken } - = gl.FilteredSearchTokenizer.processTokens(input.value); - const { isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - - if (isLastVisualTokenValid) { - tokens.forEach((t) => { - input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, ''); - gl.FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`); - }); - - const fragments = searchToken.split(':'); - if (fragments.length > 1) { - const inputValues = fragments[0].split(' '); - const tokenKey = inputValues.last(); - - if (inputValues.length > 1) { - inputValues.pop(); - const searchTerms = inputValues.join(' '); - - input.value = input.value.replace(searchTerms, ''); - gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms); - } + handleInputVisualToken() { + const input = this.filteredSearchInput; + const { tokens, searchToken } + = gl.FilteredSearchTokenizer.processTokens(input.value); + const { isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + if (isLastVisualTokenValid) { + tokens.forEach((t) => { + input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, ''); + gl.FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`); + }); - gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenKey); - input.value = input.value.replace(`${tokenKey}:`, ''); - } - } else { - // Keep listening to token until we determine that the user is done typing the token value - const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g; + const fragments = searchToken.split(':'); + if (fragments.length > 1) { + const inputValues = fragments[0].split(' '); + const tokenKey = inputValues.last(); - if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') { - gl.FilteredSearchVisualTokens.addFilterVisualToken(searchToken); + if (inputValues.length > 1) { + inputValues.pop(); + const searchTerms = inputValues.join(' '); - // Trim the last space as seen in the if statement above - input.value = input.value.replace(searchToken, '').trim(); + input.value = input.value.replace(searchTerms, ''); + gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms); } + + gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenKey); + input.value = input.value.replace(`${tokenKey}:`, ''); } - } + } else { + // Keep listening to token until we determine that the user is done typing the token value + const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g; - handleFormSubmit(e) { - e.preventDefault(); - this.search(); + if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') { + gl.FilteredSearchVisualTokens.addFilterVisualToken(searchToken); + + // Trim the last space as seen in the if statement above + input.value = input.value.replace(searchToken, '').trim(); + } } + } + + handleFormSubmit(e) { + e.preventDefault(); + this.search(); + } + + saveCurrentSearchQuery() { + // Don't save before we have fetched the already saved searches + this.fetchingRecentSearchesPromise.then(() => { + const searchQuery = gl.DropdownUtils.getSearchQuery(); + if (searchQuery.length > 0) { + const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery); + this.recentSearchesService.save(resultantSearches); + } + }).catch(() => { + // https://gitlab.com/gitlab-org/gitlab-ce/issues/30821 + }); + } - loadSearchParamsFromURL() { - const params = gl.utils.getUrlParamsArray(); - const usernameParams = this.getUsernameParams(); - let hasFilteredSearch = false; + loadSearchParamsFromURL() { + const params = gl.utils.getUrlParamsArray(); + const usernameParams = this.getUsernameParams(); + let hasFilteredSearch = false; - params.forEach((p) => { - const split = p.split('='); - const keyParam = decodeURIComponent(split[0]); - const value = split[1]; + params.forEach((p) => { + const split = p.split('='); + const keyParam = decodeURIComponent(split[0]); + const value = split[1]; - // Check if it matches edge conditions listed in this.filteredSearchTokenKeys - const condition = this.filteredSearchTokenKeys.searchByConditionUrl(p); + // Check if it matches edge conditions listed in this.filteredSearchTokenKeys + const condition = this.filteredSearchTokenKeys.searchByConditionUrl(p); - if (condition) { - hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken(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 = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); - - if (match) { - const indexOf = keyParam.indexOf('_'); - const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam; - const symbol = match.symbol; - let quotationsToUse = ''; - - if (sanitizedValue.indexOf(' ') !== -1) { - // Prefer ", but use ' if required - quotationsToUse = sanitizedValue.indexOf('"') === -1 ? '"' : '\''; - } + if (condition) { + hasFilteredSearch = true; + gl.FilteredSearchVisualTokens.addFilterVisualToken(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 = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); + + if (match) { + const indexOf = keyParam.indexOf('_'); + const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam; + const symbol = match.symbol; + let quotationsToUse = ''; + + if (sanitizedValue.indexOf(' ') !== -1) { + // Prefer ", but use ' if required + quotationsToUse = sanitizedValue.indexOf('"') === -1 ? '"' : '\''; + } + hasFilteredSearch = true; + gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); + } else if (!match && keyParam === 'assignee_id') { + const id = parseInt(value, 10); + if (usernameParams[id]) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); - } else if (!match && keyParam === 'assignee_id') { - const id = parseInt(value, 10); - if (usernameParams[id]) { - hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`); - } - } else if (!match && keyParam === 'author_id') { - const id = parseInt(value, 10); - if (usernameParams[id]) { - hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`); - } - } else if (!match && keyParam === 'search') { + gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`); + } + } else if (!match && keyParam === 'author_id') { + const id = parseInt(value, 10); + if (usernameParams[id]) { hasFilteredSearch = true; - this.filteredSearchInput.value = sanitizedValue; + gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`); } + } else if (!match && keyParam === 'search') { + hasFilteredSearch = true; + this.filteredSearchInput.value = sanitizedValue; } - }); - - if (hasFilteredSearch) { - this.clearSearchButton.classList.remove('hidden'); - this.handleInputPlaceholder(); } - } + }); - search() { - const paths = []; - const { tokens, searchToken } - = this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery()); - const currentState = gl.utils.getParameterByName('state') || 'opened'; - paths.push(`state=${currentState}`); - - tokens.forEach((token) => { - const condition = this.filteredSearchTokenKeys - .searchByConditionKeyValue(token.key, token.value.toLowerCase()); - const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; - const keyParam = param ? `${token.key}_${param}` : token.key; - let tokenPath = ''; - - if (condition) { - tokenPath = condition.url; - } else { - let tokenValue = token.value; + this.saveCurrentSearchQuery(); - if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') || - (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) { - tokenValue = tokenValue.slice(1, tokenValue.length - 1); - } + if (hasFilteredSearch) { + this.clearSearchButton.classList.remove('hidden'); + this.handleInputPlaceholder(); + } + } - tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; - } + search() { + const paths = []; + const searchQuery = gl.DropdownUtils.getSearchQuery(); - paths.push(tokenPath); - }); + this.saveCurrentSearchQuery(); - if (searchToken) { - const sanitized = searchToken.split(' ').map(t => encodeURIComponent(t)).join('+'); - paths.push(`search=${sanitized}`); - } + const { tokens, searchToken } + = this.tokenizer.processTokens(searchQuery); + const currentState = gl.utils.getParameterByName('state') || 'opened'; + paths.push(`state=${currentState}`); - const parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`; + tokens.forEach((token) => { + const condition = this.filteredSearchTokenKeys + .searchByConditionKeyValue(token.key, token.value.toLowerCase()); + const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; + const keyParam = param ? `${token.key}_${param}` : token.key; + let tokenPath = ''; - if (this.updateObject) { - this.updateObject(parameterizedUrl); + if (condition) { + tokenPath = condition.url; } else { - gl.utils.visitUrl(parameterizedUrl); + let tokenValue = token.value; + + if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') || + (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) { + tokenValue = tokenValue.slice(1, tokenValue.length - 1); + } + + tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; } + + paths.push(tokenPath); + }); + + if (searchToken) { + const sanitized = searchToken.split(' ').map(t => encodeURIComponent(t)).join('+'); + paths.push(`search=${sanitized}`); } - getUsernameParams() { - const usernamesById = {}; - try { - const attribute = this.filteredSearchInput.getAttribute('data-username-params'); - JSON.parse(attribute).forEach((user) => { - usernamesById[user.id] = user.username; - }); - } catch (e) { - // do nothing - } - return usernamesById; + const parameterizedUrl = `?scope=all&utf8=%E2%9C%93&${paths.join('&')}`; + + if (this.updateObject) { + this.updateObject(parameterizedUrl); + } else { + gl.utils.visitUrl(parameterizedUrl); } + } - tokenChange() { - const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; + getUsernameParams() { + const usernamesById = {}; + try { + const attribute = this.filteredSearchInput.getAttribute('data-username-params'); + JSON.parse(attribute).forEach((user) => { + usernamesById[user.id] = user.username; + }); + } catch (e) { + // do nothing + } + return usernamesById; + } - if (dropdown) { - const currentDropdownRef = dropdown.reference; + tokenChange() { + const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; - this.setDropdownWrapper(); - currentDropdownRef.dispatchInputEvent(); - } + if (dropdown) { + const currentDropdownRef = dropdown.reference; + + this.setDropdownWrapper(); + currentDropdownRef.dispatchInputEvent(); } } - window.gl = window.gl || {}; - gl.FilteredSearchManager = FilteredSearchManager; -})(); + onrecentSearchesItemSelected(text) { + this.clearSearch(); + this.filteredSearchInput.value = text; + this.filteredSearchInput.dispatchEvent(new CustomEvent('input')); + this.search(); + } +} + +window.gl = window.gl || {}; +gl.FilteredSearchManager = FilteredSearchManager; diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 6d5df86f2a5..1abad9d1b73 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -1,100 +1,98 @@ -(() => { - 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 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 alternativeTokenKeys = [{ - key: 'label', - type: 'string', - param: 'name', - symbol: '~', - }]; +const alternativeTokenKeys = [{ + key: 'label', + type: 'string', + param: 'name', + symbol: '~', +}]; - const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys); +const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys); - 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: 'milestone_title=%23started', - tokenKey: 'milestone', - value: 'started', - }, { - url: 'label_name[]=No+Label', - tokenKey: 'label', - value: 'none', - }]; +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: 'milestone_title=%23started', + tokenKey: 'milestone', + value: 'started', +}, { + url: 'label_name[]=No+Label', + tokenKey: 'label', + value: 'none', +}]; - class FilteredSearchTokenKeys { - static get() { - return tokenKeys; - } +class FilteredSearchTokenKeys { + static get() { + return tokenKeys; + } - static getAlternatives() { - return alternativeTokenKeys; - } + static getAlternatives() { + return alternativeTokenKeys; + } - static getConditions() { - return conditions; - } + static getConditions() { + return conditions; + } - static searchByKey(key) { - return tokenKeys.find(tokenKey => tokenKey.key === key) || null; - } + static searchByKey(key) { + return tokenKeys.find(tokenKey => tokenKey.key === key) || null; + } - static searchBySymbol(symbol) { - return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null; - } + static searchBySymbol(symbol) { + return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null; + } - static searchByKeyParam(keyParam) { - return tokenKeysWithAlternative.find((tokenKey) => { - let tokenKeyParam = tokenKey.key; + static searchByKeyParam(keyParam) { + return tokenKeysWithAlternative.find((tokenKey) => { + let tokenKeyParam = tokenKey.key; - if (tokenKey.param) { - tokenKeyParam += `_${tokenKey.param}`; - } + if (tokenKey.param) { + tokenKeyParam += `_${tokenKey.param}`; + } - return keyParam === tokenKeyParam; - }) || null; - } + return keyParam === tokenKeyParam; + }) || null; + } - static searchByConditionUrl(url) { - return conditions.find(condition => condition.url === url) || 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; - } + static searchByConditionKeyValue(key, value) { + return conditions + .find(condition => condition.tokenKey === key && condition.value === value) || null; } +} - window.gl = window.gl || {}; - gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys; -})(); +window.gl = window.gl || {}; +gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys; diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js index a2729dc0e95..aa513b3aeae 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js @@ -1,58 +1,56 @@ -require('./filtered_search_token_keys'); - -(() => { - class FilteredSearchTokenizer { - static processTokens(input) { - const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key); - // Regex extracts `(token):(symbol)(value)` - // Values that start with a double quote must end in a double quote (same for single) - const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g'); - const tokens = []; - const tokenIndexes = []; // stores key+value for simple search - let lastToken = null; - const searchToken = input.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => { - let tokenValue = v1 || v2 || v3; - let tokenSymbol = symbol; - let tokenIndex = ''; - - if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { - tokenSymbol = tokenValue; - tokenValue = ''; - } - - tokenIndex = `${key}:${tokenValue}`; - - // Prevent adding duplicates - if (tokenIndexes.indexOf(tokenIndex) === -1) { - tokenIndexes.push(tokenIndex); - - tokens.push({ - key, - value: tokenValue || '', - symbol: tokenSymbol || '', - }); - } - - return ''; - }).replace(/\s{2,}/g, ' ').trim() || ''; - - if (tokens.length > 0) { - const last = tokens[tokens.length - 1]; - const lastString = `${last.key}:${last.symbol}${last.value}`; - lastToken = input.lastIndexOf(lastString) === - input.length - lastString.length ? last : searchToken; - } else { - lastToken = searchToken; +import './filtered_search_token_keys'; + +class FilteredSearchTokenizer { + static processTokens(input) { + const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key); + // Regex extracts `(token):(symbol)(value)` + // Values that start with a double quote must end in a double quote (same for single) + const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g'); + const tokens = []; + const tokenIndexes = []; // stores key+value for simple search + let lastToken = null; + const searchToken = input.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => { + let tokenValue = v1 || v2 || v3; + let tokenSymbol = symbol; + let tokenIndex = ''; + + if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { + tokenSymbol = tokenValue; + tokenValue = ''; } - return { - tokens, - lastToken, - searchToken, - }; + tokenIndex = `${key}:${tokenValue}`; + + // Prevent adding duplicates + if (tokenIndexes.indexOf(tokenIndex) === -1) { + tokenIndexes.push(tokenIndex); + + tokens.push({ + key, + value: tokenValue || '', + symbol: tokenSymbol || '', + }); + } + + return ''; + }).replace(/\s{2,}/g, ' ').trim() || ''; + + if (tokens.length > 0) { + const last = tokens[tokens.length - 1]; + const lastString = `${last.key}:${last.symbol}${last.value}`; + lastToken = input.lastIndexOf(lastString) === + input.length - lastString.length ? last : searchToken; + } else { + lastToken = searchToken; } + + return { + tokens, + lastToken, + searchToken, + }; } +} - window.gl = window.gl || {}; - gl.FilteredSearchTokenizer = FilteredSearchTokenizer; -})(); +window.gl = window.gl || {}; +gl.FilteredSearchTokenizer = FilteredSearchTokenizer; diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index a5657fc8720..f3003b86493 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import '~/flash'; /* global Flash */ import FilteredSearchContainer from './container'; class FilteredSearchVisualTokens { @@ -16,11 +18,11 @@ class FilteredSearchVisualTokens { [].forEach.call(otherTokens, t => t.classList.remove('selected')); } - static selectToken(tokenButton) { + static selectToken(tokenButton, forceSelection = false) { const selected = tokenButton.classList.contains('selected'); FilteredSearchVisualTokens.unselectTokens(); - if (!selected) { + if (!selected || forceSelection) { tokenButton.classList.add('selected'); } } @@ -38,11 +40,50 @@ class FilteredSearchVisualTokens { return ` <div class="selectable" role="button"> <div class="name"></div> - <div class="value"></div> + <div class="value-container"> + <div class="value"></div> + <div class="remove-token" role="button"> + <i class="fa fa-close"></i> + </div> + </div> </div> `; } + static updateLabelTokenColor(tokenValueContainer, tokenValue) { + const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search'); + const baseEndpoint = filteredSearchInput.dataset.baseEndpoint; + const labelsEndpoint = `${baseEndpoint}/labels.json`; + + return AjaxCache.retrieve(labelsEndpoint) + .then((labels) => { + const matchingLabel = (labels || []).find(label => `~${gl.DropdownUtils.getEscapedText(label.title)}` === tokenValue); + + if (!matchingLabel) { + return; + } + + const tokenValueStyle = tokenValueContainer.style; + tokenValueStyle.backgroundColor = matchingLabel.color; + tokenValueStyle.color = matchingLabel.text_color; + + if (matchingLabel.text_color === '#FFFFFF') { + const removeToken = tokenValueContainer.querySelector('.remove-token'); + removeToken.classList.add('inverted'); + } + }) + .catch(() => new Flash('An error occurred while fetching label colors.')); + } + + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + const tokenValueContainer = parentElement.querySelector('.value-container'); + tokenValueContainer.querySelector('.value').innerText = tokenValue; + + if (tokenName.toLowerCase() === 'label') { + FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); + } + } + static addVisualTokenElement(name, value, isSearchTerm) { const li = document.createElement('li'); li.classList.add('js-visual-token'); @@ -50,7 +91,7 @@ class FilteredSearchVisualTokens { if (value) { li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); - li.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { li.innerHTML = '<div class="name"></div>'; } @@ -69,7 +110,7 @@ class FilteredSearchVisualTokens { const name = FilteredSearchVisualTokens.getLastTokenPartial(); lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); lastVisualToken.querySelector('.name').innerText = name; - lastVisualToken.querySelector('.value').innerText = value; + FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value); } } @@ -122,7 +163,8 @@ class FilteredSearchVisualTokens { if (value) { const button = lastVisualToken.querySelector('.selectable'); - button.removeChild(value); + const valueContainer = lastVisualToken.querySelector('.value-container'); + button.removeChild(valueContainer); lastVisualToken.innerHTML = button.innerHTML; } else { lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken); @@ -177,6 +219,9 @@ class FilteredSearchVisualTokens { static moveInputToTheRight() { const input = FilteredSearchContainer.container.querySelector('.filtered-search'); + + if (!input) return; + const inputLi = input.parentElement; const tokenContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js new file mode 100644 index 00000000000..b2e6f63aacf --- /dev/null +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -0,0 +1,62 @@ +import Vue from 'vue'; +import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content'; +import eventHub from './event_hub'; + +class RecentSearchesRoot { + constructor( + recentSearchesStore, + recentSearchesService, + wrapperElement, + ) { + this.store = recentSearchesStore; + this.service = recentSearchesService; + this.wrapperElement = wrapperElement; + } + + init() { + this.bindEvents(); + this.render(); + } + + bindEvents() { + this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this); + + eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper); + } + + unbindEvents() { + eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper); + } + + render() { + const state = this.store.state; + this.vm = new Vue({ + el: this.wrapperElement, + data() { return state; }, + template: ` + <recent-searches-dropdown-content + :items="recentSearches" + :is-local-storage-available="isLocalStorageAvailable" + /> + `, + components: { + 'recent-searches-dropdown-content': RecentSearchesDropdownContent, + }, + }); + } + + onRequestClearRecentSearches() { + const resultantSearches = this.store.setRecentSearches([]); + this.service.save(resultantSearches); + } + + destroy() { + this.unbindEvents(); + if (this.vm) { + this.vm.$destroy(); + } + } + +} + +export default RecentSearchesRoot; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js new file mode 100644 index 00000000000..a056dea928d --- /dev/null +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -0,0 +1,40 @@ +import RecentSearchesServiceError from './recent_searches_service_error'; +import AccessorUtilities from '../../lib/utils/accessor'; + +class RecentSearchesService { + constructor(localStorageKey = 'issuable-recent-searches') { + this.localStorageKey = localStorageKey; + } + + fetch() { + if (!RecentSearchesService.isAvailable()) { + const error = new RecentSearchesServiceError(); + return Promise.reject(error); + } + + const input = window.localStorage.getItem(this.localStorageKey); + + let searches = []; + if (input && input.length > 0) { + try { + searches = JSON.parse(input); + } catch (err) { + return Promise.reject(err); + } + } + + return Promise.resolve(searches); + } + + save(searches = []) { + if (!RecentSearchesService.isAvailable()) return; + + window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches)); + } + + static isAvailable() { + return AccessorUtilities.isLocalStorageAccessSafe(); + } +} + +export default RecentSearchesService; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js new file mode 100644 index 00000000000..5917b223d63 --- /dev/null +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js @@ -0,0 +1,11 @@ +class RecentSearchesServiceError { + constructor(message) { + this.name = 'RecentSearchesServiceError'; + this.message = message || 'Recent Searches Service is unavailable'; + } +} + +// Can't use `extends` for builtin prototypes and get true inheritance yet +RecentSearchesServiceError.prototype = Error.prototype; + +export default RecentSearchesServiceError; diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js new file mode 100644 index 00000000000..35fc15e4c87 --- /dev/null +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -0,0 +1,24 @@ +import _ from 'underscore'; + +class RecentSearchesStore { + constructor(initialState = {}) { + this.state = Object.assign({ + isLocalStorageAvailable: true, + recentSearches: [], + }, initialState); + } + + addRecentSearch(newSearch) { + this.setRecentSearches([newSearch].concat(this.state.recentSearches)); + + return this.state.recentSearches; + } + + setRecentSearches(searches = []) { + const trimmedSearches = searches.map(search => search.trim()); + this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5); + return this.state.recentSearches; + } +} + +export default RecentSearchesStore; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 9ac4c49d697..b8a923cf619 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,110 +1,33 @@ -/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */ - import emojiMap from 'emojis/digests.json'; import emojiAliases from 'emojis/aliases.json'; import { glEmojiTag } from '~/behaviors/gl_emoji'; - -// Creates the variables for setting up GFM auto-completion -window.gl = window.gl || {}; +import glRegexp from '~/lib/utils/regexp'; function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); } -window.gl.GfmAutoComplete = { - dataSources: {}, - defaultLoadingData: ['loading'], - cachedData: {}, - isLoadingData: {}, - atTypeMap: { - ':': 'emojis', - '@': 'members', - '#': 'issues', - '!': 'mergeRequests', - '~': 'labels', - '%': 'milestones', - '/': 'commands' - }, - // Emoji - Emoji: { - templateFunction: function(name) { - return `<li> - ${name} ${glEmojiTag(name)} - </li> - `; - } - }, - // Team Members - Members: { - template: '<li>${avatarTag} ${username} <small>${title}</small></li>' - }, - Labels: { - template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>' - }, - // Issues and MergeRequests - Issues: { - template: '<li><small>${id}</small> ${title}</li>' - }, - // Milestones - Milestones: { - template: '<li>${title}</li>' - }, - Loading: { - template: '<li style="pointer-events: none;"><i class="fa fa-refresh fa-spin"></i> Loading...</li>' - }, - DefaultOptions: { - sorter: function(query, items, searchKey) { - this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; - if (gl.GfmAutoComplete.isLoading(items)) { - this.setting.highlightFirst = false; - return items; - } - return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey); - }, - filter: function(query, data, searchKey) { - if (gl.GfmAutoComplete.isLoading(data)) { - gl.GfmAutoComplete.fetchData(this.$inputor, this.at); - return data; - } else { - return $.fn.atwho["default"].callbacks.filter(query, data, searchKey); - } - }, - beforeInsert: function(value) { - if (value && !this.setting.skipSpecialCharacterTest) { - var withoutAt = value.substring(1); - if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"'; - } - return value; - }, - matcher: function (flag, subtext) { - // The below is taken from At.js source - // Tweaked to commands to start without a space only if char before is a non-word character - // https://github.com/ichord/At.js - var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar; - atSymbolsWithBar = Object.keys(this.app.controllers).join('|'); - atSymbolsWithoutBar = Object.keys(this.app.controllers).join(''); - subtext = subtext.split(/\s+/g).pop(); - flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); - - _a = decodeURI("%C3%80"); - _y = decodeURI("%C3%BF"); - - regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi'); - - match = regexp.exec(subtext); +class GfmAutoComplete { + constructor(dataSources) { + this.dataSources = dataSources || {}; + this.cachedData = {}; + this.isLoadingData = {}; + } - if (match) { - return match[1]; - } else { - return null; - } - } - }, - setup: function(input) { + setup(input, enableMap = { + emojis: true, + members: true, + issues: true, + milestones: true, + mergeRequests: true, + labels: true, + }) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); + this.enableMap = enableMap; this.setupLifecycle(); - }, + } + setupLifecycle() { this.input.each((i, input) => { const $input = $(input); @@ -113,48 +36,138 @@ window.gl.GfmAutoComplete = { // Needed for slash commands with suffixes (ex: /label ~) $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); }); - }, - setupAtWho: function($input) { + } + + setupAtWho($input) { + if (this.enableMap.emojis) this.setupEmoji($input); + if (this.enableMap.members) this.setupMembers($input); + if (this.enableMap.issues) this.setupIssues($input); + if (this.enableMap.milestones) this.setupMilestones($input); + if (this.enableMap.mergeRequests) this.setupMergeRequests($input); + if (this.enableMap.labels) this.setupLabels($input); + + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + $input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + skipSpecialCharacterTest: true, + data: GfmAutoComplete.defaultLoadingData, + displayTpl(value) { + if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template; + // eslint-disable-next-line no-template-curly-in-string + let tpl = '<li>/${name}'; + if (value.aliases.length > 0) { + tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; + } + if (value.params.length > 0) { + tpl += ' <small><%- params.join(" ") %></small>'; + } + if (value.description !== '') { + tpl += '<small class="description"><i><%- description %></i></small>'; + } + tpl += '</li>'; + return _.template(tpl)(value); + }, + insertTpl(value) { + // eslint-disable-next-line no-template-curly-in-string + let tpl = '/${name} '; + let referencePrefix = null; + if (value.params.length > 0) { + referencePrefix = value.params[0][0]; + if (/^[@%~]/.test(referencePrefix)) { + tpl += '<%- referencePrefix %>'; + } + } + return _.template(tpl)({ referencePrefix }); + }, + suffix: '', + callbacks: { + ...this.getDefaultCallbacks(), + beforeSave(commands) { + if (GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, (c) => { + let search = c.name; + if (c.aliases.length > 0) { + search = `${search} ${c.aliases.join(' ')}`; + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search, + }; + }); + }, + matcher(flag, subtext) { + const regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + const match = regexp.exec(subtext); + if (match) { + return match[1]; + } + return null; + }, + }, + }); + } + + setupEmoji($input) { // Emoji $input.atwho({ at: ':', - displayTpl: function(value) { - return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template; - }.bind(this), + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value && value.name) { + tmpl = GfmAutoComplete.Emoji.templateFunction(value.name); + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: ':${name}:', skipSpecialCharacterTest: true, - data: this.defaultLoadingData, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - sorter: this.DefaultOptions.sorter, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter - } + ...this.getDefaultCallbacks(), + matcher(flag, subtext) { + const relevantText = subtext.trim().split(/\s/).pop(); + const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); + const match = regexp.exec(relevantText); + + return match && match.length ? match[1] : null; + }, + }, }); + } + + setupMembers($input) { // Team Members $input.atwho({ at: '@', - displayTpl: function(value) { - return value.username != null ? this.Members.template : this.Loading.template; - }.bind(this), + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.username != null) { + tmpl = GfmAutoComplete.Members.template; + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${username}', searchKey: 'search', alwaysHighlightFirst: true, skipSpecialCharacterTest: true, - data: this.defaultLoadingData, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(members) { - return $.map(members, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(members) { + return $.map(members, (m) => { let title = ''; if (m.username == null) { return m; } title = m.name; if (m.count) { - title += " (" + m.count + ")"; + title += ` (${m.count})`; } const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase(); @@ -165,226 +178,271 @@ window.gl.GfmAutoComplete = { username: m.username, avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, title: sanitize(title), - search: sanitize(m.username + " " + m.name) + search: sanitize(`${m.username} ${m.name}`), }; }); - } - } + }, + }, }); + } + + setupIssues($input) { $input.atwho({ at: '#', alias: 'issues', searchKey: 'search', - displayTpl: function(value) { - return value.title != null ? this.Issues.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Issues.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${id}', callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(issues) { - return $.map(issues, function(i) { + ...this.getDefaultCallbacks(), + beforeSave(issues) { + return $.map(issues, (i) => { if (i.title == null) { return i; } return { id: i.iid, title: sanitize(i.title), - search: i.iid + " " + i.title + search: `${i.iid} ${i.title}`, }; }); - } - } + }, + }, }); + } + + setupMilestones($input) { $input.atwho({ at: '%', alias: 'milestones', searchKey: 'search', + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', - displayTpl: function(value) { - return value.title != null ? this.Milestones.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Milestones.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, callbacks: { - matcher: this.DefaultOptions.matcher, - sorter: this.DefaultOptions.sorter, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - beforeSave: function(milestones) { - return $.map(milestones, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(milestones) { + return $.map(milestones, (m) => { if (m.title == null) { return m; } return { id: m.iid, title: sanitize(m.title), - search: "" + m.title + search: m.title, }; }); - } - } + }, + }, }); + } + + setupMergeRequests($input) { $input.atwho({ at: '!', alias: 'mergerequests', searchKey: 'search', - displayTpl: function(value) { - return value.title != null ? this.Issues.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Issues.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${id}', callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(merges) { - return $.map(merges, function(m) { + ...this.getDefaultCallbacks(), + beforeSave(merges) { + return $.map(merges, (m) => { if (m.title == null) { return m; } return { id: m.iid, title: sanitize(m.title), - search: m.iid + " " + m.title + search: `${m.iid} ${m.title}`, }; }); - } - } + }, + }, }); + } + + setupLabels($input) { $input.atwho({ at: '~', alias: 'labels', searchKey: 'search', - data: this.defaultLoadingData, - displayTpl: function(value) { - return this.isLoading(value) ? this.Loading.template : this.Labels.template; - }.bind(this), + data: GfmAutoComplete.defaultLoadingData, + displayTpl(value) { + let tmpl = GfmAutoComplete.Labels.template; + if (GfmAutoComplete.isLoading(value)) { + tmpl = GfmAutoComplete.Loading.template; + } + return tmpl; + }, + // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', callbacks: { - matcher: this.DefaultOptions.matcher, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - sorter: this.DefaultOptions.sorter, - beforeSave: function(merges) { - if (gl.GfmAutoComplete.isLoading(merges)) return merges; - var sanitizeLabelTitle; - sanitizeLabelTitle = function(title) { - if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) { - return "\"" + (sanitize(title)) + "\""; - } else { - return sanitize(title); - } - }; - return $.map(merges, function(m) { - return { - title: sanitize(m.title), - color: m.color, - search: "" + m.title - }; - }); - } - } + ...this.getDefaultCallbacks(), + beforeSave(merges) { + if (GfmAutoComplete.isLoading(merges)) return merges; + return $.map(merges, m => ({ + title: sanitize(m.title), + color: m.color, + search: m.title, + })); + }, + }, }); - // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - $input.filter('[data-supports-slash-commands="true"]').atwho({ - at: '/', - alias: 'commands', - searchKey: 'search', - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '<li>/${name}'; - if (value.aliases.length > 0) { - tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; - } - if (value.params.length > 0) { - tpl += ' <small><%- params.join(" ") %></small>'; + } + + getDefaultCallbacks() { + const fetchData = this.fetchData.bind(this); + + return { + sorter(query, items, searchKey) { + this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; + if (GfmAutoComplete.isLoading(items)) { + this.setting.highlightFirst = false; + return items; } - if (value.description !== '') { - tpl += '<small class="description"><i><%- description %></i></small>'; + return $.fn.atwho.default.callbacks.sorter(query, items, searchKey); + }, + filter(query, data, searchKey) { + if (GfmAutoComplete.isLoading(data)) { + fetchData(this.$inputor, this.at); + return data; } - tpl += '</li>'; - return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; - if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; + return $.fn.atwho.default.callbacks.filter(query, data, searchKey); + }, + beforeInsert(value) { + let resultantValue = value; + if (value && !this.setting.skipSpecialCharacterTest) { + const withoutAt = value.substring(1); + if (withoutAt && /[^\w\d]/.test(withoutAt)) { + resultantValue = `${value.charAt()}"${withoutAt}"`; } } - return _.template(tpl)({ reference_prefix: reference_prefix }); + return resultantValue; }, - suffix: '', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; - if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); - } - return { - name: c.name, - aliases: c.aliases, - params: c.params, - description: c.description, - search: search - }; - }); - }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); - if (match) { - return match[1]; - } else { - return null; - } + matcher(flag, subtext) { + // The below is taken from At.js source + // Tweaked to commands to start without a space only if char before is a non-word character + // https://github.com/ichord/At.js + const atSymbolsWithBar = Object.keys(this.app.controllers).join('|'); + const atSymbolsWithoutBar = Object.keys(this.app.controllers).join(''); + const targetSubtext = subtext.split(/\s+/g).pop(); + const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); + + const accentAChar = decodeURI('%C3%80'); + const accentYChar = decodeURI('%C3%BF'); + + const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi'); + + const match = regexp.exec(targetSubtext); + + if (match) { + return match[1]; } - } - }); - return; - }, - fetchData: function($input, at) { + return null; + }, + }; + } + + fetchData($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); - } else if (this.atTypeMap[at] === 'emojis') { + } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases))); } else { - $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => { + $.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => { this.loadData($input, at, data); }).fail(() => { this.isLoadingData[at] = false; }); } - }, - loadData: function($input, at, data) { + } + loadData($input, at, data) { this.isLoadingData[at] = false; this.cachedData[at] = data; $input.atwho('load', at, data); // This trigger at.js again // otherwise we would be stuck with loading until the user types return $input.trigger('keyup'); - }, - isLoading(data) { - var dataToInspect = data; + } + + static isLoading(data) { + let dataToInspect = data; if (data && data.length > 0) { dataToInspect = data[0]; } - var loadingState = this.defaultLoadingData[0]; + const loadingState = GfmAutoComplete.defaultLoadingData[0]; return dataToInspect && (dataToInspect === loadingState || dataToInspect.name === loadingState); } +} + +GfmAutoComplete.defaultLoadingData = ['loading']; + +GfmAutoComplete.atTypeMap = { + ':': 'emojis', + '@': 'members', + '#': 'issues', + '!': 'mergeRequests', + '~': 'labels', + '%': 'milestones', + '/': 'commands', +}; + +// Emoji +GfmAutoComplete.Emoji = { + templateFunction(name) { + return `<li> + ${name} ${glEmojiTag(name)} + </li> + `; + }, +}; +// Team Members +GfmAutoComplete.Members = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li>${avatarTag} ${username} <small>${title}</small></li>', }; +GfmAutoComplete.Labels = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>', +}; +// Issues and MergeRequests +GfmAutoComplete.Issues = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li><small>${id}</small> ${title}</li>', +}; +// Milestones +GfmAutoComplete.Milestones = { + // eslint-disable-next-line no-template-curly-in-string + template: '<li>${title}</li>', +}; +GfmAutoComplete.Loading = { + template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>', +}; + +export default GfmAutoComplete; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index a03f1202a6d..24c423dd01e 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,9 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */ /* global fuzzaldrinPlus */ +import { isObject } from './lib/utils/type_utility'; -var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, - bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; +var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote; GitLabDropdownFilter = (function() { var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; @@ -95,7 +94,7 @@ GitLabDropdownFilter = (function() { // { prop: 'def' } // ] // } - if (gl.utils.isObject(data)) { + if (isObject(data)) { results = {}; for (key in data) { group = data[key]; @@ -213,10 +212,10 @@ GitLabDropdown = (function() { var searchFields, selector, self; this.el = el1; this.options = options; - this.updateLabel = bind(this.updateLabel, this); - this.hidden = bind(this.hidden, this); - this.opened = bind(this.opened, this); - this.shouldPropagate = bind(this.shouldPropagate, this); + this.updateLabel = this.updateLabel.bind(this); + this.hidden = this.hidden.bind(this); + this.opened = this.opened.bind(this); + this.shouldPropagate = this.shouldPropagate.bind(this); self = this; selector = $(this.el).data("target"); this.dropdown = selector != null ? $(selector) : $(this.el).parent(); @@ -255,7 +254,8 @@ GitLabDropdown = (function() { } }; // Remote data - })(this) + })(this), + instance: this, }); } } @@ -269,6 +269,7 @@ GitLabDropdown = (function() { remote: this.options.filterRemote, query: this.options.data, keys: searchFields, + instance: this, elements: (function(_this) { return function() { selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; @@ -343,21 +344,26 @@ GitLabDropdown = (function() { } this.dropdown.on("click", selector, function(e) { var $el, selected, selectedObj, isMarking; - $el = $(this); + $el = $(e.currentTarget); selected = self.rowClicked($el); selectedObj = selected ? selected[0] : null; isMarking = selected ? selected[1] : null; - if (self.options.clicked) { - self.options.clicked(selectedObj, $el, e, isMarking); + if (this.options.clicked) { + this.options.clicked.call(this, { + selectedObj, + $el, + e, + isMarking, + }); } // Update label right after all modifications in dropdown has been done - if (self.options.toggleLabel) { - self.updateLabel(selectedObj, $el, self); + if (this.options.toggleLabel) { + this.updateLabel(selectedObj, $el, this); } $el.trigger('blur'); - }); + }.bind(this)); } } @@ -391,7 +397,7 @@ GitLabDropdown = (function() { html = [this.noResults()]; } else { // Handle array groups - if (gl.utils.isObject(data)) { + if (isObject(data)) { html = []; for (name in data) { groupData = data[name]; @@ -439,15 +445,34 @@ GitLabDropdown = (function() { } }; + GitLabDropdown.prototype.filteredFullData = function() { + return this.fullData.filter(r => typeof r === 'object' + && !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') + && !Object.prototype.hasOwnProperty.call(r, 'header') + ); + }; + GitLabDropdown.prototype.opened = function(e) { var contentHtml; this.resetRows(); this.addArrowKeyEvent(); + const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle'); + const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update'); + const hasMultiSelect = dropdownToggle.hasClass('js-multiselect'); + // Makes indeterminate items effective - if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + if (this.fullData && hasFilterBulkUpdate) { this.parseData(this.fullData); } + + // Process the data to make sure rendered data + // matches the correct layout + if (this.fullData && hasMultiSelect && this.options.processData) { + const inputValue = this.filterInput.val(); + this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); + } + contentHtml = $('.dropdown-content', this.dropdown).html(); if (this.remote && contentHtml === "") { this.remote.execute(); @@ -584,7 +609,12 @@ GitLabDropdown = (function() { var link = document.createElement('a'); link.href = url; - link.innerHTML = text; + + if (this.highlight) { + link.innerHTML = text; + } else { + link.textContent = text; + } if (selected) { link.className = 'is-active'; @@ -601,8 +631,8 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.highlightTextMatches = function(text, term) { - var occurrences; - occurrences = fuzzaldrinPlus.match(text, term); + const occurrences = fuzzaldrinPlus.match(text, term); + const indexOf = [].indexOf; return text.split('').map(function(character, i) { if (indexOf.call(occurrences, i) !== -1) { return "<b>" + character + "</b>"; @@ -709,6 +739,11 @@ GitLabDropdown = (function() { if (this.options.inputId != null) { $input.attr('id', this.options.inputId); } + + if (this.options.inputMeta) { + $input.attr('data-meta', selectedObject[this.options.inputMeta]); + } + return this.dropdown.before($input); }; @@ -829,7 +864,14 @@ GitLabDropdown = (function() { if (instance == null) { instance = null; } - return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance)); + + let toggleText = this.options.toggleLabel(selected, el, instance); + if (this.options.updateLabel) { + // Option to override the dropdown label text + toggleText = this.options.updateLabel; + } + + return $(this.el).find(".dropdown-toggle-text").text(toggleText); }; GitLabDropdown.prototype.clearField = function(field, isInput) { diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index 76de249ac3b..0add7075254 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -65,6 +65,7 @@ class GlFieldError { this.state = { valid: false, empty: true, + submitted: false, }; this.initFieldValidation(); @@ -108,9 +109,10 @@ class GlFieldError { const currentValue = this.accessCurrentValue(); this.state.valid = false; this.state.empty = currentValue === ''; - + this.state.submitted = true; this.renderValidity(); this.form.focusOnFirstInvalid.apply(this.form); + // For UX, wait til after first invalid submission to check each keyup this.inputElement.off('keyup.fieldValidator') .on('keyup.fieldValidator', this.updateValidity.bind(this)); diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index 636258ec555..4f226ff96ea 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -1,6 +1,6 @@ /* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */ -require('./gl_field_error'); +import './gl_field_error'; const customValidationFlag = 'gl-field-error-ignore'; @@ -37,6 +37,15 @@ class GlFieldErrors { } } + /* Public method for triggering validity updates manually */ + updateFormValidityState() { + this.state.inputs.forEach((field) => { + if (field.state.submitted) { + field.updateValidity(); + } + }); + } + focusOnFirstInvalid () { const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0]; firstInvalid.inputElement.focus(); diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index e7c98e16581..dc9f114af99 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -3,11 +3,14 @@ /* global DropzoneInput */ /* global autosize */ +import GfmAutoComplete from './gfm_auto_complete'; + window.gl = window.gl || {}; -function GLForm(form) { +function GLForm(form, enableGFM = false) { this.form = form; this.textarea = this.form.find('textarea.js-gfm-input'); + this.enableGFM = enableGFM; // Before we start, we should clean up any previous data for this form this.destroy(); // Setup the form @@ -29,13 +32,20 @@ GLForm.prototype.setupForm = function() { this.form.find('.div-dropzone').remove(); this.form.addClass('gfm-form'); // remove notify commit author checkbox for non-commit notes - gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); - gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); + gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion')); + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(this.form.find('.js-gfm-input'), { + emojis: true, + members: this.enableGFM, + issues: this.enableGFM, + milestones: this.enableGFM, + mergeRequests: this.enableGFM, + labels: this.enableGFM, + }); new DropzoneInput(this.form); autosize(this.textarea); - // form and textarea event listeners - this.addEventListeners(); } + // form and textarea event listeners + this.addEventListeners(); gl.text.init(this.form); // hide discard button this.form.find('.js-note-discard').hide(); diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js index 521bc77db66..0deb27e522b 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js @@ -2,7 +2,6 @@ import d3 from 'd3'; -const bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; const hasProp = {}.hasOwnProperty; @@ -95,7 +94,7 @@ export const ContributorsMasterGraph = (function(superClass) { function ContributorsMasterGraph(data1) { this.data = data1; - this.update_content = bind(this.update_content, this); + this.update_content = this.update_content.bind(this); this.width = $('.content').width() - 70; this.height = 200; this.x = null; diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js new file mode 100644 index 00000000000..7732edde1e7 --- /dev/null +++ b/app/assets/javascripts/group.js @@ -0,0 +1,21 @@ +export default class Group { + constructor() { + this.groupPath = $('#group_path'); + this.groupName = $('#group_name'); + this.updateHandler = this.update.bind(this); + this.resetHandler = this.reset.bind(this); + if (this.groupName.val() === '') { + this.groupPath.on('keyup', this.updateHandler); + this.groupName.on('keydown', this.resetHandler); + } + } + + update() { + this.groupName.val(this.groupPath.val()); + } + + reset() { + this.groupPath.off('keyup', this.updateHandler); + this.groupName.off('keydown', this.resetHandler); + } +} diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 602a3b78189..b5975295329 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -1,5 +1,9 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, one-var, camelcase, one-var-declaration-per-line, quotes, object-shorthand, prefer-arrow-callback, comma-dangle, consistent-return, yoda, prefer-rest-params, prefer-spread, no-unused-vars, prefer-template, max-len */ -/* global Api */ +/* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, one-var, + camelcase, one-var-declaration-per-line, quotes, object-shorthand, + prefer-arrow-callback, comma-dangle, consistent-return, yoda, + prefer-rest-params, prefer-spread, no-unused-vars, prefer-template, + promise/catch-or-return */ +import Api from './api'; var slice = [].slice; @@ -45,14 +49,14 @@ window.GroupsSelect = (function() { page, per_page: GroupsSelect.PER_PAGE, all_available, - skip_groups, }; }, results: function (data, page) { if (data.length) return { results: [] }; - const results = data.length ? data : data.results || []; + const groups = data.length ? data : data.results || []; const more = data.pagination ? data.pagination.more : false; + const results = groups.filter(group => skip_groups.indexOf(group.id) === -1); return { results, diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js new file mode 100644 index 00000000000..2203a56315e --- /dev/null +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -0,0 +1,38 @@ +let instanceCount = 0; + +class AutoWidthDropdownSelect { + constructor(selectElement) { + this.$selectElement = $(selectElement); + this.dropdownClass = `js-auto-width-select-dropdown-${instanceCount}`; + instanceCount += 1; + } + + init() { + const dropdownClass = this.dropdownClass; + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + dropdownCss() { + let resultantWidth = 'auto'; + const $dropdown = $(`.${dropdownClass}`); + + // We have to look at the parent because + // `offsetParent` on a `display: none;` is `null` + const offsetParentWidth = $(this).parent().offsetParent().width(); + // Reset any width to let it naturally flow + $dropdown.css('width', 'auto'); + if ($dropdown.outerWidth(false) > offsetParentWidth) { + resultantWidth = offsetParentWidth; + } + + return { + width: resultantWidth, + maxWidth: offsetParentWidth, + }; + }, + }); + + return this; + } +} + +export default AutoWidthDropdownSelect; diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js deleted file mode 100644 index e927cc0077c..00000000000 --- a/app/assets/javascripts/issuable/issuable_bundle.js +++ /dev/null @@ -1 +0,0 @@ -require('./time_tracking/time_tracking_bundle'); diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js deleted file mode 100644 index aec13e78f42..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import stopwatchSvg from 'icons/_icon_stopwatch.svg'; - -require('../../../lib/utils/pretty_time'); - -(() => { - Vue.component('time-tracking-collapsed-state', { - name: 'time-tracking-collapsed-state', - props: [ - 'showComparisonState', - 'showSpentOnlyState', - 'showEstimateOnlyState', - 'showNoTimeTrackingState', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - methods: { - abbreviateTime(timeStr) { - return gl.utils.prettyTime.abbreviateTime(timeStr); - }, - }, - template: ` - <div class='sidebar-collapsed-icon'> - ${stopwatchSvg} - <div class='time-tracking-collapsed-summary'> - <div class='compare' v-if='showComparisonState'> - <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='estimate-only' v-if='showEstimateOnlyState'> - <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> - </div> - <div class='spend-only' v-if='showSpentOnlyState'> - <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span> - </div> - <div class='no-tracking' v-if='showNoTimeTrackingState'> - <span class='no-value'>None</span> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js deleted file mode 100644 index c55e263f6f4..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js +++ /dev/null @@ -1,70 +0,0 @@ -import Vue from 'vue'; - -require('../../../lib/utils/pretty_time'); - -(() => { - const prettyTime = gl.utils.prettyTime; - - Vue.component('time-tracking-comparison-pane', { - name: 'time-tracking-comparison-pane', - props: [ - 'timeSpent', - 'timeEstimate', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - computed: { - parsedRemaining() { - const diffSeconds = this.timeEstimate - this.timeSpent; - return prettyTime.parseSeconds(diffSeconds); - }, - timeRemainingHumanReadable() { - return prettyTime.stringifyTime(this.parsedRemaining); - }, - timeRemainingTooltip() { - const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; - return `${prefix} ${this.timeRemainingHumanReadable}`; - }, - /* Diff values for comparison meter */ - timeRemainingMinutes() { - return this.timeEstimate - this.timeSpent; - }, - timeRemainingPercent() { - return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; - }, - timeRemainingStatusClass() { - return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; - }, - /* Parsed time values */ - parsedEstimate() { - return prettyTime.parseSeconds(this.timeEstimate); - }, - parsedSpent() { - return prettyTime.parseSeconds(this.timeSpent); - }, - }, - template: ` - <div class='time-tracking-comparison-pane'> - <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay' - :aria-valuenow='timeRemainingTooltip' - :title='timeRemainingTooltip' - :data-original-title='timeRemainingTooltip' - :class='timeRemainingStatusClass'> - <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'> - <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div> - </div> - <div class='compare-display-container'> - <div class='compare-display pull-left'> - <span class='compare-label'>Spent</span> - <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span> - </div> - <div class='compare-display estimated pull-right'> - <span class='compare-label'>Est</span> - <span class='compare-value'>{{ timeEstimateHumanReadable }}</span> - </div> - </div> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js deleted file mode 100644 index a7fbd704c40..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-estimate-only-pane', { - name: 'time-tracking-estimate-only-pane', - props: ['timeEstimateHumanReadable'], - template: ` - <div class='time-tracking-estimate-only-pane'> - <span class='bold'>Estimated:</span> - {{ timeEstimateHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js deleted file mode 100644 index 344b29ebea4..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js +++ /dev/null @@ -1,25 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-help-state', { - name: 'time-tracking-help-state', - props: ['docsUrl'], - template: ` - <div class='time-tracking-help-state'> - <div class='time-tracking-info'> - <h4>Track time with slash commands</h4> - <p>Slash commands can be used in the issues description and comment boxes.</p> - <p> - <code>/estimate</code> - will update the estimated time with the latest command. - </p> - <p> - <code>/spend</code> - will update the sum of the time spent. - </p> - <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js deleted file mode 100644 index b081adf5e64..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-no-tracking-pane', { - name: 'time-tracking-no-tracking-pane', - template: ` - <div class='time-tracking-no-tracking-pane'> - <span class='no-value'>No estimate or time spent</span> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js deleted file mode 100644 index edb9169112f..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue'; - -(() => { - Vue.component('time-tracking-spent-only-pane', { - name: 'time-tracking-spent-only-pane', - props: ['timeSpentHumanReadable'], - template: ` - <div class='time-tracking-spend-only-pane'> - <span class='bold'>Spent:</span> - {{ timeSpentHumanReadable }} - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js deleted file mode 100644 index 0213522f551..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js +++ /dev/null @@ -1,117 +0,0 @@ -import Vue from 'vue'; - -require('./help_state'); -require('./collapsed_state'); -require('./spent_only_pane'); -require('./no_tracking_pane'); -require('./estimate_only_pane'); -require('./comparison_pane'); - -(() => { - Vue.component('issuable-time-tracker', { - name: 'issuable-time-tracker', - props: [ - 'time_estimate', - 'time_spent', - 'human_time_estimate', - 'human_time_spent', - 'docsUrl', - ], - data() { - return { - showHelp: false, - }; - }, - computed: { - timeSpent() { - return this.time_spent; - }, - timeEstimate() { - return this.time_estimate; - }, - timeEstimateHumanReadable() { - return this.human_time_estimate; - }, - timeSpentHumanReadable() { - return this.human_time_spent; - }, - hasTimeSpent() { - return !!this.timeSpent; - }, - hasTimeEstimate() { - return !!this.timeEstimate; - }, - showComparisonState() { - return this.hasTimeEstimate && this.hasTimeSpent; - }, - showEstimateOnlyState() { - return this.hasTimeEstimate && !this.hasTimeSpent; - }, - showSpentOnlyState() { - return this.hasTimeSpent && !this.hasTimeEstimate; - }, - showNoTimeTrackingState() { - return !this.hasTimeEstimate && !this.hasTimeSpent; - }, - showHelpState() { - return !!this.showHelp; - }, - }, - methods: { - toggleHelpState(show) { - this.showHelp = show; - }, - }, - template: ` - <div class='time_tracker time-tracking-component-wrap' v-cloak> - <time-tracking-collapsed-state - :show-comparison-state='showComparisonState' - :show-help-state='showHelpState' - :show-spent-only-state='showSpentOnlyState' - :show-estimate-only-state='showEstimateOnlyState' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-collapsed-state> - <div class='title hide-collapsed'> - Time tracking - <div class='help-button pull-right' - v-if='!showHelpState' - @click='toggleHelpState(true)'> - <i class='fa fa-question-circle' aria-hidden='true'></i> - </div> - <div class='close-help-button pull-right' - v-if='showHelpState' - @click='toggleHelpState(false)'> - <i class='fa fa-close' aria-hidden='true'></i> - </div> - </div> - <div class='time-tracking-content hide-collapsed'> - <time-tracking-estimate-only-pane - v-if='showEstimateOnlyState' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-estimate-only-pane> - <time-tracking-spent-only-pane - v-if='showSpentOnlyState' - :time-spent-human-readable='timeSpentHumanReadable'> - </time-tracking-spent-only-pane> - <time-tracking-no-tracking-pane - v-if='showNoTimeTrackingState'> - </time-tracking-no-tracking-pane> - <time-tracking-comparison-pane - v-if='showComparisonState' - :time-estimate='timeEstimate' - :time-spent='timeSpent' - :time-spent-human-readable='timeSpentHumanReadable' - :time-estimate-human-readable='timeEstimateHumanReadable'> - </time-tracking-comparison-pane> - <transition name='help-state-toggle'> - <time-tracking-help-state - v-if='showHelpState' - :docs-url='docsUrl'> - </time-tracking-help-state> - </transition> - </div> - </div> - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js deleted file mode 100644 index 1689a69e1ed..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js +++ /dev/null @@ -1,66 +0,0 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -require('./components/time_tracker'); -require('../../smart_interval'); -require('../../subbable_resource'); - -Vue.use(VueResource); - -(() => { - /* This Vue instance represents what will become the parent instance for the - * sidebar. It will be responsible for managing `issuable` state and propagating - * changes to sidebar components. We will want to create a separate service to - * interface with the server at that point. - */ - - class IssuableTimeTracking { - constructor(issuableJSON) { - const parsedIssuable = JSON.parse(issuableJSON); - return this.initComponent(parsedIssuable); - } - - initComponent(parsedIssuable) { - this.parentInstance = new Vue({ - el: '#issuable-time-tracker', - data: { - issuable: parsedIssuable, - }, - methods: { - fetchIssuable() { - return gl.IssuableResource.get.call(gl.IssuableResource, { - type: 'GET', - url: gl.IssuableResource.endpoint, - }); - }, - updateState(data) { - this.issuable = data; - }, - subscribeToUpdates() { - gl.IssuableResource.subscribe(data => this.updateState(data)); - }, - listenForSlashCommands() { - $(document).on('ajax:success', '.gfm-form', (e, data) => { - const subscribedCommands = ['spend_time', 'time_estimate']; - const changedCommands = data.commands_changes - ? Object.keys(data.commands_changes) - : []; - if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { - this.fetchIssuable(); - } - }); - }, - }, - created() { - this.fetchIssuable(); - }, - mounted() { - this.subscribeToUpdates(); - this.listenForSlashCommands(); - }, - }); - } - } - - gl.IssuableTimeTracking = IssuableTimeTracking; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 834b98e8601..a4d7bf096ef 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -1,8 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-new, comma-dangle, quotes, prefer-arrow-callback, consistent-return, one-var, no-var, one-var-declaration-per-line, no-underscore-dangle, max-len */ -/* global UsersSelect */ /* global bp */ import Cookies from 'js-cookie'; +import UsersSelect from './users_select'; (function() { this.IssuableContext = (function() { @@ -47,7 +47,6 @@ import Cookies from 'js-cookie'; Cookies.set('collapsed_gutter', true); } }); - $(".right-sidebar").niceScroll(); } IssuableContext.prototype.initParticipants = function() { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index de184ab2675..92f6f0d4117 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -1,14 +1,14 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-useless-escape, no-new, quotes, object-shorthand, no-unused-vars, comma-dangle, no-alert, consistent-return, no-else-return, prefer-template, one-var, one-var-declaration-per-line, curly, max-len */ /* global GitLab */ -/* global UsersSelect */ /* global ZenMode */ /* global Autosave */ /* global dateFormat */ /* global Pikaday */ -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; +import UsersSelect from './users_select'; +import GfmAutoComplete from './gfm_auto_complete'; +(function() { this.IssuableForm = (function() { IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; @@ -17,11 +17,11 @@ function IssuableForm(form) { var $issuableDueDate, calendar; this.form = form; - this.toggleWip = bind(this.toggleWip, this); - this.renderWipExplanation = bind(this.renderWipExplanation, this); - this.resetAutosave = bind(this.resetAutosave, this); - this.handleSubmit = bind(this.handleSubmit, this); - gl.GfmAutoComplete.setup(); + this.toggleWip = this.toggleWip.bind(this); + this.renderWipExplanation = this.renderWipExplanation.bind(this); + this.resetAutosave = this.resetAutosave.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(); new UsersSelect(); new ZenMode(); this.titleField = this.form.find("input[name*='[title]']"); @@ -39,8 +39,9 @@ if ($issuableDueDate.length) { calendar = new Pikaday({ field: $issuableDueDate.get(0), - theme: 'gitlab-theme', + theme: 'gitlab-theme animate-picker', format: 'yyyy-mm-dd', + container: $issuableDueDate.parent().get(0), onSelect: function(dateText) { $issuableDueDate.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); } diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 47e675f537e..0860e237ce1 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,10 +1,11 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ /* global Flash */ -require('./flash'); -require('~/lib/utils/text_utility'); -require('vendor/jquery.waitforimages'); -require('./task_list'); +import 'vendor/jquery.waitforimages'; +import '~/lib/utils/text_utility'; +import './flash'; +import './task_list'; +import CreateMergeRequestDropdown from './create_merge_request_dropdown'; class Issue { constructor() { @@ -18,59 +19,72 @@ class Issue { document.querySelector('#task_status_short').innerText = result.task_status_short; } }); - Issue.initIssueBtnEventListeners(); + this.initIssueBtnEventListeners(); } + + Issue.$btnNewBranch = $('#new-branch'); + Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); + Issue.initMergeRequests(); Issue.initRelatedBranches(); - Issue.initCanCreateBranch(); + + if (Issue.createMrDropdownWrap) { + this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap); + } } - static initIssueBtnEventListeners() { - var issueFailMessage; - issueFailMessage = 'Unable to update this issue at this time.'; - return $('a.btn-close, a.btn-reopen').on('click', function(e) { - var $this, isClose, shouldSubmit, url; + initIssueBtnEventListeners() { + const issueFailMessage = 'Unable to update this issue at this time.'; + const closeButtons = $('a.btn-close'); + const isClosedBadge = $('div.status-box-closed'); + const isOpenBadge = $('div.status-box-open'); + const projectIssuesCounter = $('.issue_counter'); + const reopenButtons = $('a.btn-reopen'); + + return closeButtons.add(reopenButtons).on('click', (e) => { + var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); - $this = $(this); - isClose = $this.hasClass('btn-close'); - shouldSubmit = $this.hasClass('btn-comment'); + $button = $(e.currentTarget); + shouldSubmit = $button.hasClass('btn-comment'); if (shouldSubmit) { - Issue.submitNoteForm($this.closest('form')); + Issue.submitNoteForm($button.closest('form')); } - $this.prop('disabled', true); - url = $this.attr('href'); + $button.prop('disabled', true); + url = $button.attr('href'); return $.ajax({ type: 'PUT', - url: url, - error: function(jqXHR, textStatus, errorThrown) { - var issueStatus; - issueStatus = isClose ? 'close' : 'open'; - return new Flash(issueFailMessage, 'alert'); - }, - success: function(data, textStatus, jqXHR) { - if ('id' in data) { - $(document).trigger('issuable:change'); - let total = Number($('.issue_counter').text().replace(/[^\d]/, '')); - if (isClose) { - $('a.btn-close').addClass('hidden'); - $('a.btn-reopen').removeClass('hidden'); - $('div.status-box-closed').removeClass('hidden'); - $('div.status-box-open').addClass('hidden'); - total -= 1; + url: url + }) + .fail(() => new Flash(issueFailMessage)) + .done((data) => { + if ('id' in data) { + $(document).trigger('issuable:change'); + + const isClosed = $button.hasClass('btn-close'); + closeButtons.toggleClass('hidden', isClosed); + reopenButtons.toggleClass('hidden', !isClosed); + isClosedBadge.toggleClass('hidden', !isClosed); + isOpenBadge.toggleClass('hidden', isClosed); + + let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); + numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; + projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues)); + + if (this.createMergeRequestDropdown) { + if (isClosed) { + this.createMergeRequestDropdown.unavailable(); + this.createMergeRequestDropdown.disable(); } else { - $('a.btn-reopen').addClass('hidden'); - $('a.btn-close').removeClass('hidden'); - $('div.status-box-closed').addClass('hidden'); - $('div.status-box-open').removeClass('hidden'); - total += 1; + // We should check in case a branch was created in another tab + this.createMergeRequestDropdown.checkAbilityToCreateBranch(); } - $('.issue_counter').text(gl.text.addDelimiter(total)); - } else { - new Flash(issueFailMessage, 'alert'); } - return $this.prop('disabled', false); + } else { + new Flash(issueFailMessage); } + + $button.prop('disabled', false); }); }); } @@ -86,9 +100,9 @@ class Issue { static initMergeRequests() { var $container; $container = $('#merge-requests'); - return $.getJSON($container.data('url')).error(function() { - return new Flash('Failed to load referenced merge requests', 'alert'); - }).success(function(data) { + return $.getJSON($container.data('url')).fail(function() { + return new Flash('Failed to load referenced merge requests'); + }).done(function(data) { if ('html' in data) { return $container.html(data.html); } @@ -98,34 +112,14 @@ class Issue { static initRelatedBranches() { var $container; $container = $('#related-branches'); - return $.getJSON($container.data('url')).error(function() { - return new Flash('Failed to load related branches', 'alert'); - }).success(function(data) { + return $.getJSON($container.data('url')).fail(function() { + return new Flash('Failed to load related branches'); + }).done(function(data) { if ('html' in data) { return $container.html(data.html); } }); } - - static initCanCreateBranch() { - var $container; - $container = $('#new-branch'); - // If the user doesn't have the required permissions the container isn't - // rendered at all. - if ($container.length === 0) { - return; - } - return $.getJSON($container.data('path')).error(function() { - $container.find('.unavailable').show(); - return new Flash('Failed to check if a new branch can be created.', 'alert'); - }).success(function(data) { - if (data.can_create_branch) { - $container.find('.available').show(); - } else { - return $container.find('.unavailable').show(); - } - }); - } } export default Issue; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue new file mode 100644 index 00000000000..770a0dcd27e --- /dev/null +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -0,0 +1,96 @@ +<script> +import Visibility from 'visibilityjs'; +import Poll from '../../lib/utils/poll'; +import Service from '../services/index'; +import Store from '../stores'; +import titleComponent from './title.vue'; +import descriptionComponent from './description.vue'; + +export default { + props: { + endpoint: { + required: true, + type: String, + }, + canUpdate: { + required: true, + type: Boolean, + }, + issuableRef: { + type: String, + required: true, + }, + initialTitle: { + type: String, + required: true, + }, + initialDescriptionHtml: { + type: String, + required: false, + default: '', + }, + initialDescriptionText: { + type: String, + required: false, + default: '', + }, + }, + data() { + const store = new Store({ + titleHtml: this.initialTitle, + descriptionHtml: this.initialDescriptionHtml, + descriptionText: this.initialDescriptionText, + }); + + return { + store, + state: store.state, + }; + }, + components: { + descriptionComponent, + titleComponent, + }, + created() { + const resource = new Service(this.endpoint); + const poll = new Poll({ + resource, + method: 'getData', + successCallback: (res) => { + this.store.updateState(res.json()); + }, + errorCallback(err) { + throw new Error(err); + }, + }); + + if (!Visibility.hidden()) { + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + }, +}; +</script> + +<template> + <div> + <title-component + :issuable-ref="issuableRef" + :title-html="state.titleHtml" + :title-text="state.titleText" /> + <description-component + v-if="state.descriptionHtml" + :can-update="canUpdate" + :description-html="state.descriptionHtml" + :description-text="state.descriptionText" + :updated-at="state.updatedAt" + :task-status="state.taskStatus" /> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue new file mode 100644 index 00000000000..4ad3eb7dfd7 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -0,0 +1,105 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + props: { + canUpdate: { + type: Boolean, + required: true, + }, + descriptionHtml: { + type: String, + required: true, + }, + descriptionText: { + type: String, + required: true, + }, + updatedAt: { + type: String, + required: true, + }, + taskStatus: { + type: String, + required: true, + }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + timeAgoEl: $('.js-issue-edited-ago'), + }; + }, + watch: { + descriptionHtml() { + this.animateChange(); + + this.$nextTick(() => { + const toolTipTime = gl.utils.formatDate(this.updatedAt); + + this.timeAgoEl.attr('datetime', this.updatedAt) + .attr('title', toolTipTime) + .tooltip('fixTitle'); + + this.renderGFM(); + }); + }, + taskStatus() { + const taskRegexMatches = this.taskStatus.match(/(\d+) of (\d+)/); + const $issuableHeader = $('.issuable-meta'); + const $tasks = $('#task_status', $issuableHeader); + const $tasksShort = $('#task_status_short', $issuableHeader); + + if (taskRegexMatches) { + $tasks.text(this.taskStatus); + $tasksShort.text(`${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`); + } else { + $tasks.text(''); + $tasksShort.text(''); + } + }, + }, + methods: { + renderGFM() { + $(this.$refs['gfm-entry-content']).renderGFM(); + + if (this.canUpdate) { + // eslint-disable-next-line no-new + new gl.TaskList({ + dataType: 'issue', + fieldName: 'description', + selector: '.detail-page-description', + }); + } + }, + }, + mounted() { + this.renderGFM(); + }, + }; +</script> + +<template> + <div + class="description" + :class="{ + 'js-task-list-container': canUpdate + }"> + <div + class="wiki" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="descriptionHtml" + ref="gfm-content"> + </div> + <textarea + class="hidden js-task-list-field" + v-if="descriptionText" + v-model="descriptionText"> + </textarea> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue new file mode 100644 index 00000000000..a9dabd4cff1 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -0,0 +1,53 @@ +<script> + import animateMixin from '../mixins/animate'; + + export default { + mixins: [animateMixin], + data() { + return { + preAnimation: false, + pulseAnimation: false, + titleEl: document.querySelector('title'), + }; + }, + props: { + issuableRef: { + type: String, + required: true, + }, + titleHtml: { + type: String, + required: true, + }, + titleText: { + type: String, + required: true, + }, + }, + watch: { + titleHtml() { + this.setPageTitle(); + this.animateChange(); + }, + }, + methods: { + setPageTitle() { + const currentPageTitleScope = this.titleEl.innerText.split('·'); + currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; + this.titleEl.textContent = currentPageTitleScope.join('·'); + }, + }, + }; +</script> + +<template> + <h2 + class="title" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="titleHtml" + > + </h2> +</template> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js new file mode 100644 index 00000000000..f06e33dee60 --- /dev/null +++ b/app/assets/javascripts/issue_show/index.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import issuableApp from './components/app.vue'; +import '../vue_shared/vue_resource_interceptor'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: document.getElementById('js-issuable-app'), + components: { + issuableApp, + }, + data() { + const issuableElement = this.$options.el; + const issuableTitleElement = issuableElement.querySelector('.title'); + const issuableDescriptionElement = issuableElement.querySelector('.wiki'); + const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field'); + const { + canUpdate, + endpoint, + issuableRef, + } = issuableElement.dataset; + + return { + canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), + endpoint, + issuableRef, + initialTitle: issuableTitleElement.innerHTML, + initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '', + initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '', + }; + }, + render(createElement) { + return createElement('issuable-app', { + props: { + canUpdate: this.canUpdate, + endpoint: this.endpoint, + issuableRef: this.issuableRef, + initialTitle: this.initialTitle, + initialDescriptionHtml: this.initialDescriptionHtml, + initialDescriptionText: this.initialDescriptionText, + }, + }); + }, +})); diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js new file mode 100644 index 00000000000..eda6302aa8b --- /dev/null +++ b/app/assets/javascripts/issue_show/mixins/animate.js @@ -0,0 +1,13 @@ +export default { + methods: { + animateChange() { + this.preAnimation = true; + this.pulseAnimation = false; + + this.$nextTick(() => { + this.preAnimation = false; + this.pulseAnimation = true; + }); + }, + }, +}; diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js new file mode 100644 index 00000000000..348ad8d6813 --- /dev/null +++ b/app/assets/javascripts/issue_show/services/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class Service { + constructor(endpoint) { + this.endpoint = endpoint; + + this.resource = Vue.resource(this.endpoint); + } + + getData() { + return this.resource.get(); + } +} diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js new file mode 100644 index 00000000000..8e89a2b7730 --- /dev/null +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -0,0 +1,25 @@ +export default class Store { + constructor({ + titleHtml, + descriptionHtml, + descriptionText, + }) { + this.state = { + titleHtml, + titleText: '', + descriptionHtml, + descriptionText, + taskStatus: '', + updatedAt: '', + }; + } + + updateState(data) { + this.state.titleHtml = data.title; + this.state.titleText = data.title_text; + this.state.descriptionHtml = data.description; + this.state.descriptionText = data.description_text; + this.state.taskStatus = data.task_status; + this.state.updatedAt = data.updated_at; + } +} diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js index b2cfd3ef2a3..56cb536dcde 100644 --- a/app/assets/javascripts/issue_status_select.js +++ b/app/assets/javascripts/issue_status_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js index e0ebd36a65c..fee3429e2b8 100644 --- a/app/assets/javascripts/issues_bulk_assignment.js +++ b/app/assets/javascripts/issues_bulk_assignment.js @@ -88,7 +88,10 @@ const formData = { update: { state_event: this.form.find('input[name="update[state_event]"]').val(), + // For Merge Requests assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + // For Issues + assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()], milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels.js index 17a3fc1b1e4..03dd61b4263 100644 --- a/app/assets/javascripts/labels.js +++ b/app/assets/javascripts/labels.js @@ -1,11 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, vars-on-top, no-unused-vars, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Labels = (function() { function Labels() { - this.setSuggestedColor = bind(this.setSuggestedColor, this); - this.updateColorPreview = bind(this.updateColorPreview, this); + this.setSuggestedColor = this.setSuggestedColor.bind(this); + this.updateColorPreview = this.updateColorPreview.bind(this); var form; form = $('.label-form'); this.cleanBinding(); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 443fb3e0ca9..ac5ce84e31b 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -330,8 +330,14 @@ }, multiSelect: $dropdown.hasClass('js-multiselect'), vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(label, $el, e, isMarking) { + clicked: function(options) { + const { $el, e, isMarking } = options; + const label = options.selectedObj; + var isIssueIndex, isMRIndex, page, boardsModel; + var fadeOutLoader = () => { + $loading.fadeOut(); + }; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; @@ -349,7 +355,7 @@ if ($dropdown.hasClass('js-filter-bulk-update')) { _this.enableBulkLabelDropdown(); - _this.setDropdownData($dropdown, isMarking, this.id(label)); + _this.setDropdownData($dropdown, isMarking, label.id); return; } @@ -396,9 +402,8 @@ $loading.fadeIn(); gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) - .then(function () { - $loading.fadeOut(); - }); + .then(fadeOutLoader) + .catch(fadeOutLoader); } else { if ($dropdown.hasClass('js-multiselect')) { diff --git a/app/assets/javascripts/landing.js b/app/assets/javascripts/landing.js new file mode 100644 index 00000000000..8c0950ad5d5 --- /dev/null +++ b/app/assets/javascripts/landing.js @@ -0,0 +1,37 @@ +import Cookies from 'js-cookie'; + +class Landing { + constructor(landingElement, dismissButton, cookieName) { + this.landingElement = landingElement; + this.cookieName = cookieName; + this.dismissButton = dismissButton; + this.eventWrapper = {}; + } + + toggle() { + const isDismissed = this.isDismissed(); + + this.landingElement.classList.toggle('hidden', isDismissed); + if (!isDismissed) this.addEvents(); + } + + addEvents() { + this.eventWrapper.dismissLanding = this.dismissLanding.bind(this); + this.dismissButton.addEventListener('click', this.eventWrapper.dismissLanding); + } + + removeEvents() { + this.dismissButton.removeEventListener('click', this.eventWrapper.dismissLanding); + } + + dismissLanding() { + this.landingElement.classList.add('hidden'); + Cookies.set(this.cookieName, 'true', { expires: 365 }); + } + + isDismissed() { + return Cookies.get(this.cookieName) === 'true'; + } +} + +export default Landing; diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index a5f99bcdd8f..71064ccc539 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */ +import _ from 'underscore'; (function() { var hideEndFade; @@ -45,4 +46,13 @@ } }); }); + + function applyScrollNavClass() { + const scrollOpacityHeight = 40; + $('.navbar-border').css('opacity', Math.min($(window).scrollTop() / scrollOpacityHeight, 1)); + } + + $(() => { + $(window).on('scroll', _.throttle(applyScrollNavClass, 100)); + }); }).call(window); diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js new file mode 100644 index 00000000000..1d18992af63 --- /dev/null +++ b/app/assets/javascripts/lib/utils/accessor.js @@ -0,0 +1,47 @@ +function isPropertyAccessSafe(base, property) { + let safe; + + try { + safe = !!base[property]; + } catch (error) { + safe = false; + } + + return safe; +} + +function isFunctionCallSafe(base, functionName, ...args) { + let safe = true; + + try { + base[functionName](...args); + } catch (error) { + safe = false; + } + + return safe; +} + +function isLocalStorageAccessSafe() { + let safe; + + const TEST_KEY = 'isLocalStorageAccessSafe'; + const TEST_VALUE = 'true'; + + safe = isPropertyAccessSafe(window, 'localStorage'); + if (!safe) return safe; + + safe = isFunctionCallSafe(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE); + + if (safe) window.localStorage.removeItem(TEST_KEY); + + return safe; +} + +const AccessorUtilities = { + isPropertyAccessSafe, + isFunctionCallSafe, + isLocalStorageAccessSafe, +}; + +export default AccessorUtilities; diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js new file mode 100644 index 00000000000..cf030d613df --- /dev/null +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -0,0 +1,54 @@ +class AjaxCache { + constructor() { + this.internalStorage = { }; + this.pendingRequests = { }; + } + + get(endpoint) { + return this.internalStorage[endpoint]; + } + + hasData(endpoint) { + return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint); + } + + remove(endpoint) { + delete this.internalStorage[endpoint]; + } + + retrieve(endpoint) { + if (this.hasData(endpoint)) { + return Promise.resolve(this.get(endpoint)); + } + + let pendingRequest = this.pendingRequests[endpoint]; + + if (!pendingRequest) { + pendingRequest = new Promise((resolve, reject) => { + // jQuery 2 is not Promises/A+ compatible (missing catch) + $.ajax(endpoint) // eslint-disable-line promise/catch-or-return + .then(data => resolve(data), + (jqXHR, textStatus, errorThrown) => { + const error = new Error(`${endpoint}: ${errorThrown}`); + error.textStatus = textStatus; + reject(error); + }, + ); + }) + .then((data) => { + this.internalStorage[endpoint] = data; + delete this.pendingRequests[endpoint]; + }) + .catch((error) => { + delete this.pendingRequests[endpoint]; + throw error; + }); + + this.pendingRequests[endpoint] = pendingRequest; + } + + return pendingRequest.then(() => this.get(endpoint)); + } +} + +export default new AjaxCache(); diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js index 2955bda1a36..0bf2ba6acc2 100644 --- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js +++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js @@ -31,82 +31,78 @@ * * ### How to use * - * new window.gl.LinkedTabs({ + * new LinkedTabs({ * action: "#{controller.action_name}", * defaultAction: 'tab1', * parentEl: '.tab-links' * }); */ -(() => { - window.gl = window.gl || {}; +export default class LinkedTabs { + /** + * Binds the events and activates de default tab. + * + * @param {Object} options + */ + constructor(options = {}) { + this.options = options; - window.gl.LinkedTabs = class LinkedTabs { - /** - * Binds the events and activates de default tab. - * - * @param {Object} options - */ - constructor(options) { - this.options = options || {}; + this.defaultAction = this.options.defaultAction; + this.action = this.options.action || this.defaultAction; - this.defaultAction = this.options.defaultAction; - this.action = this.options.action || this.defaultAction; - - if (this.action === 'show') { - this.action = this.defaultAction; - } + if (this.action === 'show') { + this.action = this.defaultAction; + } - this.currentLocation = window.location; + this.currentLocation = window.location; - const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`; + const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`; - // since this is a custom event we need jQuery :( - $(document) - .off('shown.bs.tab', tabSelector) - .on('shown.bs.tab', tabSelector, e => this.tabShown(e)); + // since this is a custom event we need jQuery :( + $(document) + .off('shown.bs.tab', tabSelector) + .on('shown.bs.tab', tabSelector, e => this.tabShown(e)); - this.activateTab(this.action); - } + this.activateTab(this.action); + } - /** - * Handles the `shown.bs.tab` event to set the currect url action. - * - * @param {type} evt - * @return {Function} - */ - tabShown(evt) { - const source = evt.target.getAttribute('href'); + /** + * Handles the `shown.bs.tab` event to set the currect url action. + * + * @param {type} evt + * @return {Function} + */ + tabShown(evt) { + const source = evt.target.getAttribute('href'); - return this.setCurrentAction(source); - } + return this.setCurrentAction(source); + } - /** - * Updates the URL with the path that matched the given action. - * - * @param {String} source - * @return {String} - */ - setCurrentAction(source) { - const copySource = source; + /** + * Updates the URL with the path that matched the given action. + * + * @param {String} source + * @return {String} + */ + setCurrentAction(source) { + const copySource = source; - copySource.replace(/\/+$/, ''); + copySource.replace(/\/+$/, ''); - const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; + const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`; - history.replaceState({ - url: newState, - }, document.title, newState); - return newState; - } + history.replaceState({ + url: newState, + }, document.title, newState); + return newState; + } - /** - * Given the current action activates the correct tab. - * http://getbootstrap.com/javascript/#tab-show - * Note: Will trigger `shown.bs.tab` - */ - activateTab() { - return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show'); - } - }; -})(); + /** + * Given the current action activates the correct tab. + * http://getbootstrap.com/javascript/#tab-show + * Note: Will trigger `shown.bs.tab` + */ + activateTab() { + return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show'); + } +} diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 4aad0128aef..7e62773ae6c 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -2,6 +2,8 @@ (function() { (function(w) { var base; + const faviconEl = document.getElementById('favicon'); + const originalFavicon = faviconEl ? faviconEl.getAttribute('href') : null; w.gl || (w.gl = {}); (base = w.gl).utils || (base.utils = {}); w.gl.utils.isInGroupsPage = function() { @@ -33,6 +35,14 @@ }); }; + w.gl.utils.ajaxPost = function(url, data) { + return $.ajax({ + type: 'POST', + url: url, + data: data, + }); + }; + w.gl.utils.extractLast = function(term) { return this.split(term).pop(); }; @@ -45,6 +55,10 @@ } }; + gl.utils.updateTooltipTitle = function($tooltipEl, newTitle) { + return $tooltipEl.attr('title', newTitle).tooltip('fixTitle'); + }; + w.gl.utils.disableButtonIfEmptyField = function(field_selector, button_selector, event_name) { event_name = event_name || 'input'; var closest_submit, field, that; @@ -121,7 +135,10 @@ 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('&'); + return window.location.search.slice(1).split('&').map((param) => { + const split = param.split('='); + return [decodeURI(split[0]), split[1]].join('='); + }); }; gl.utils.isMetaKey = function(e) { @@ -163,7 +180,10 @@ w.gl.utils.getSelectedFragment = () => { const selection = window.getSelection(); if (selection.rangeCount === 0) return null; - const documentFragment = selection.getRangeAt(0).cloneContents(); + const documentFragment = document.createDocumentFragment(); + for (let i = 0; i < selection.rangeCount; i += 1) { + documentFragment.appendChild(selection.getRangeAt(i).cloneContents()); + } if (documentFragment.textContent.length === 0) return null; return documentFragment; @@ -263,7 +283,7 @@ }); /** - * Updates the search parameter of a URL given the parameter and values provided. + * Updates the search parameter of a URL given the parameter and value provided. * * If no search params are present we'll add it. * If param for page is already present, we'll update it @@ -278,17 +298,24 @@ let search; const locationSearch = window.location.search; - if (locationSearch.length === 0) { - search = `?${param}=${value}`; - } + if (locationSearch.length) { + const parameters = locationSearch.substring(1, locationSearch.length) + .split('&') + .reduce((acc, element) => { + const val = element.split('='); + acc[val[0]] = decodeURIComponent(val[1]); + return acc; + }, {}); - if (locationSearch.indexOf(param) !== -1) { - const regex = new RegExp(param + '=\\d'); - search = locationSearch.replace(regex, `${param}=${value}`); - } + parameters[param] = value; + + const toString = Object.keys(parameters) + .map(val => `${val}=${encodeURIComponent(parameters[val])}`) + .join('&'); - if (locationSearch.length && locationSearch.indexOf(param) === -1) { - search = `${locationSearch}&${param}=${value}`; + search = `?${toString}`; + } else { + search = `?${param}=${value}`; } return search; @@ -354,5 +381,34 @@ fn(next, stop); }); }; + + w.gl.utils.setFavicon = (faviconPath) => { + if (faviconEl && faviconPath) { + faviconEl.setAttribute('href', faviconPath); + } + }; + + w.gl.utils.resetFavicon = () => { + if (faviconEl) { + faviconEl.setAttribute('href', originalFavicon); + } + }; + + w.gl.utils.setCiStatusFavicon = (pageUrl) => { + $.ajax({ + url: pageUrl, + dataType: 'json', + success: function(data) { + if (data && data.favicon) { + gl.utils.setFavicon(data.favicon); + } else { + gl.utils.resetFavicon(); + } + }, + error: function() { + gl.utils.resetFavicon(); + } + }); + }; })(window); }).call(window); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js new file mode 100644 index 00000000000..1e96c7ab5cd --- /dev/null +++ b/app/assets/javascripts/lib/utils/constants.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export const BYTES_IN_KIB = 1024; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 82dcbdc26c8..b2f48049bb4 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -1,9 +1,10 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, comma-dangle, no-unused-expressions, prefer-template, max-len */ -/* global timeago */ -/* global dateFormat */ -window.timeago = require('timeago.js'); -window.dateFormat = require('vendor/date.format'); +import timeago from 'timeago.js'; +import dateFormat from 'vendor/date.format'; + +window.timeago = timeago; +window.dateFormat = dateFormat; (function() { (function(w) { @@ -101,8 +102,7 @@ window.dateFormat = require('vendor/date.format'); }; w.gl.utils.updateTimeagoText = function(el) { - const timeago = gl.utils.getTimeago(); - const formattedDate = timeago.format(el.getAttribute('datetime'), 'gl_en'); + const formattedDate = gl.utils.getTimeago().format(el.getAttribute('datetime'), 'gl_en'); if (el.textContent !== formattedDate) { el.textContent = formattedDate; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index bc109a69c20..415e50f32ae 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -2,9 +2,7 @@ * exports HTTP status codes */ -const statusCodes = { +export default { NO_CONTENT: 204, OK: 200, }; - -module.exports = statusCodes; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js new file mode 100644 index 00000000000..f1b07408671 --- /dev/null +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -0,0 +1,44 @@ +import { BYTES_IN_KIB } from './constants'; + +/** + * Function that allows a number with an X amount of decimals + * to be formatted in the following fashion: + * * For 1 digit to the left of the decimal point and X digits to the right of it + * * * Show 3 digits to the right + * * For 2 digits to the left of the decimal point and X digits to the right of it + * * * Show 2 digits to the right +*/ +export function formatRelevantDigits(number) { + let digitsLeft = ''; + let relevantDigits = 0; + let formattedNumber = ''; + if (!isNaN(Number(number))) { + digitsLeft = number.split('.')[0]; + switch (digitsLeft.length) { + case 1: + relevantDigits = 3; + break; + case 2: + relevantDigits = 2; + break; + case 3: + relevantDigits = 1; + break; + default: + relevantDigits = 4; + break; + } + formattedNumber = Number(number).toFixed(relevantDigits); + } + return formattedNumber; +} + +/** + * Utility function that calculates KiB of the given bytes. + * + * @param {Number} number bytes + * @return {Number} KiB + */ +export function bytesToKiB(number) { + return number / BYTES_IN_KIB; +} diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js index 5c22aea51cd..e31cc5fbabe 100644 --- a/app/assets/javascripts/lib/utils/poll.js +++ b/app/assets/javascripts/lib/utils/poll.js @@ -65,7 +65,6 @@ export default class Poll { this.makeRequest(); }, pollInterval); } - this.options.successCallback(response); } @@ -76,8 +75,14 @@ export default class Poll { notificationCallback(true); return resource[method](data) - .then(response => this.checkConditions(response)) - .catch(error => errorCallback(error)); + .then((response) => { + this.checkConditions(response); + notificationCallback(false); + }) + .catch((error) => { + notificationCallback(false); + errorCallback(error); + }); } /** diff --git a/app/assets/javascripts/lib/utils/regexp.js b/app/assets/javascripts/lib/utils/regexp.js new file mode 100644 index 00000000000..baa0b51d59b --- /dev/null +++ b/app/assets/javascripts/lib/utils/regexp.js @@ -0,0 +1,10 @@ +/** + * Regexp utility for the convenience of working with regular expressions. + * + */ + +// Inspired by https://github.com/mishoo/UglifyJS/blob/2bc1d02363db3798d5df41fb5059a19edca9b7eb/lib/parse-js.js#L203 +// Unicode 6.1 +const unicodeLetters = '\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u0527\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0\\u08A2-\\u08AC\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0977\\u0979-\\u097F\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16EE-\\u16F0\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191C\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2160-\\u2188\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3007\\u3021-\\u3029\\u3031-\\u3035\\u3038-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA697\\uA6A0-\\uA6EF\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA793\\uA7A0-\\uA7AA\\uA7F8-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA80-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uABC0-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC'; + +export default { unicodeLetters }; diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js new file mode 100644 index 00000000000..25ca98afbe7 --- /dev/null +++ b/app/assets/javascripts/lib/utils/simple_poll.js @@ -0,0 +1,15 @@ +export default (fn, interval = 2000, timeout = 60000) => { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const stop = arg => ((arg instanceof Error) ? reject(arg) : resolve(arg)); + const next = () => { + if (Date.now() - startTime < timeout) { + setTimeout(fn.bind(null, next, stop), interval); + } else { + reject(new Error('SIMPLE_POLL_TIMEOUT')); + } + }; + fn(next, stop); + }); +}; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 2e5f8a09fc1..b43c1c3aac6 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -1,192 +1,189 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len */ +/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */ -require('vendor/latinise'); +import 'vendor/latinise'; -(function() { - (function(w) { - var base; - if (w.gl == null) { - w.gl = {}; +var base; +var w = window; +if (w.gl == null) { + w.gl = {}; +} +if ((base = w.gl).text == null) { + base.text = {}; +} +gl.text.addDelimiter = function(text) { + return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text; +}; +gl.text.highCountTrim = function(count) { + return count > 99 ? '99+' : count; +}; +gl.text.randomString = function() { + return Math.random().toString(36).substring(7); +}; +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); +}; +gl.text.lineBefore = function(text, textarea) { + var split; + split = text.substring(0, textarea.selectionStart).trim().split('\n'); + return split[split.length - 1]; +}; +gl.text.lineAfter = function(text, textarea) { + return text.substring(textarea.selectionEnd).trim().split('\n')[0]; +}; +gl.text.blockTagText = function(text, textArea, blockTag, selected) { + var lineAfter, lineBefore; + lineBefore = this.lineBefore(text, textArea); + lineAfter = this.lineAfter(text, textArea); + if (lineBefore === blockTag && lineAfter === blockTag) { + // To remove the block tag we have to select the line before & after + if (blockTag != null) { + textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); + textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); } - if ((base = w.gl).text == null) { - base.text = {}; - } - gl.text.addDelimiter = function(text) { - return text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") : text; - }; - gl.text.highCountTrim = function(count) { - return count > 99 ? '99+' : count; - }; - gl.text.randomString = function() { - return Math.random().toString(36).substring(7); - }; - 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); - }; - gl.text.lineBefore = function(text, textarea) { - var split; - split = text.substring(0, textarea.selectionStart).trim().split('\n'); - return split[split.length - 1]; - }; - gl.text.lineAfter = function(text, textarea) { - return text.substring(textarea.selectionEnd).trim().split('\n')[0]; - }; - gl.text.blockTagText = function(text, textArea, blockTag, selected) { - var lineAfter, lineBefore; - lineBefore = this.lineBefore(text, textArea); - lineAfter = this.lineAfter(text, textArea); - if (lineBefore === blockTag && lineAfter === blockTag) { - // To remove the block tag we have to select the line before & after - if (blockTag != null) { - textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); - textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); - } - return selected; - } else { - return blockTag + "\n" + selected + "\n" + blockTag; - } - }; - gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) { - var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; - removedLastNewLine = false; - removedFirstNewLine = false; - currentLineEmpty = false; + return selected; + } else { + return blockTag + "\n" + selected + "\n" + blockTag; + } +}; +gl.text.insertText = function(textArea, text, tag, blockTag, selected, wrap) { + var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine; + removedLastNewLine = false; + removedFirstNewLine = false; + currentLineEmpty = false; - // Remove the first newline - if (selected.indexOf('\n') === 0) { - removedFirstNewLine = true; - selected = selected.replace(/\n+/, ''); - } + // Remove the first newline + if (selected.indexOf('\n') === 0) { + removedFirstNewLine = true; + selected = selected.replace(/\n+/, ''); + } - // Remove the last newline - if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { - removedLastNewLine = true; - selected = selected.replace(/\n$/, ''); - } + // Remove the last newline + if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { + removedLastNewLine = true; + selected = selected.replace(/\n$/, ''); + } - selectedSplit = selected.split('\n'); + selectedSplit = selected.split('\n'); - if (!wrap) { - lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); + if (!wrap) { + lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); - // Check whether the current line is empty or consists only of spaces(=handle as empty) - if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) { - currentLineEmpty = true; - } - } + // Check whether the current line is empty or consists only of spaces(=handle as empty) + if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) { + currentLineEmpty = true; + } + } - startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; + startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; - if (selectedSplit.length > 1 && (!wrap || (blockTag != null))) { - if (blockTag != null) { - insertText = this.blockTagText(text, textArea, blockTag, selected); + if (selectedSplit.length > 1 && (!wrap || (blockTag != null))) { + if (blockTag != null) { + insertText = this.blockTagText(text, textArea, blockTag, selected); + } else { + insertText = selectedSplit.map(function(val) { + if (val.indexOf(tag) === 0) { + return "" + (val.replace(tag, '')); } else { - insertText = selectedSplit.map(function(val) { - if (val.indexOf(tag) === 0) { - return "" + (val.replace(tag, '')); - } else { - return "" + tag + val; - } - }).join('\n'); + return "" + tag + val; } - } else { - insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); - } + }).join('\n'); + } + } else { + insertText = "" + startChar + tag + selected + (wrap ? tag : ' '); + } - if (removedFirstNewLine) { - insertText = '\n' + insertText; - } + if (removedFirstNewLine) { + insertText = '\n' + insertText; + } - if (removedLastNewLine) { - insertText += '\n'; - } + if (removedLastNewLine) { + insertText += '\n'; + } - if (document.queryCommandSupported('insertText')) { - inserted = document.execCommand('insertText', false, insertText); - } - if (!inserted) { - try { - document.execCommand("ms-beginUndoUnit"); - } catch (error) {} - textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); - try { - document.execCommand("ms-endUndoUnit"); - } catch (error) {} - } - return this.moveCursor(textArea, tag, wrap, removedLastNewLine); - }; - gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { - var pos; - if (!textArea.setSelectionRange) { - return; - } - if (textArea.selectionStart === textArea.selectionEnd) { - if (wrapped) { - pos = textArea.selectionStart - tag.length; - } else { - pos = textArea.selectionStart; - } + if (document.queryCommandSupported('insertText')) { + inserted = document.execCommand('insertText', false, insertText); + } + if (!inserted) { + try { + document.execCommand("ms-beginUndoUnit"); + } catch (error) {} + textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText); + try { + document.execCommand("ms-endUndoUnit"); + } catch (error) {} + } + return this.moveCursor(textArea, tag, wrap, removedLastNewLine); +}; +gl.text.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) { + var pos; + if (!textArea.setSelectionRange) { + return; + } + if (textArea.selectionStart === textArea.selectionEnd) { + if (wrapped) { + pos = textArea.selectionStart - tag.length; + } else { + pos = textArea.selectionStart; + } - if (removedLastNewLine) { - pos -= 1; - } + if (removedLastNewLine) { + pos -= 1; + } - return textArea.setSelectionRange(pos, pos); - } - }; - gl.text.updateText = function(textArea, tag, blockTag, wrap) { - var $textArea, selected, text; - $textArea = $(textArea); - textArea = $textArea.get(0); - text = $textArea.val(); - selected = this.selectedText(text, textArea); - $textArea.focus(); - return this.insertText(textArea, text, tag, blockTag, selected, wrap); - }; - gl.text.init = function(form) { - var self; - self = this; - return $('.js-md', form).off('click').on('click', function() { - var $this; - $this = $(this); - return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); - }); - }; - gl.text.removeListeners = function(form) { - return $('.js-md', form).off(); - }; - gl.text.humanize = function(string) { - return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); - }; - gl.text.pluralize = function(str, count) { - return str + (count > 1 || count === 0 ? 's' : ''); - }; - gl.text.truncate = function(string, maxLength) { - return string.substr(0, (maxLength - 3)) + '...'; - }; - gl.text.dasherize = function(str) { - return str.replace(/[_\s]+/g, '-'); - }; - gl.text.slugify = function(str) { - return str.trim().toLowerCase().latinise(); - }; - })(window); -}).call(window); + return textArea.setSelectionRange(pos, pos); + } +}; +gl.text.updateText = function(textArea, tag, blockTag, wrap) { + var $textArea, selected, text; + $textArea = $(textArea); + textArea = $textArea.get(0); + text = $textArea.val(); + selected = this.selectedText(text, textArea); + $textArea.focus(); + return this.insertText(textArea, text, tag, blockTag, selected, wrap); +}; +gl.text.init = function(form) { + var self; + self = this; + return $('.js-md', form).off('click').on('click', function() { + var $this; + $this = $(this); + return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend')); + }); +}; +gl.text.removeListeners = function(form) { + return $('.js-md', form).off(); +}; +gl.text.humanize = function(string) { + return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); +}; +gl.text.pluralize = function(str, count) { + return str + (count > 1 || count === 0 ? 's' : ''); +}; +gl.text.truncate = function(string, maxLength) { + return string.substr(0, (maxLength - 3)) + '...'; +}; +gl.text.dasherize = function(str) { + return str.replace(/[_\s]+/g, '-'); +}; +gl.text.slugify = function(str) { + return str.trim().toLowerCase().latinise(); +}; diff --git a/app/assets/javascripts/lib/utils/type_utility.js b/app/assets/javascripts/lib/utils/type_utility.js index db62e0be324..be86f336bcd 100644 --- a/app/assets/javascripts/lib/utils/type_utility.js +++ b/app/assets/javascripts/lib/utils/type_utility.js @@ -1,15 +1,2 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, no-return-assign, max-len */ -(function() { - (function(w) { - var base; - if (w.gl == null) { - w.gl = {}; - } - if ((base = w.gl).utils == null) { - base.utils = {}; - } - return w.gl.utils.isObject = function(obj) { - return (obj != null) && (obj.constructor === Object); - }; - })(window); -}).call(window); +// eslint-disable-next-line import/prefer-default-export +export const isObject = obj => obj && obj.constructor === Object; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 09c4261b318..b9d2fc25c39 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,93 +1,90 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, one-var, one-var-declaration-per-line, no-void, guard-for-in, no-restricted-syntax, prefer-template, quotes, max-len */ -(function() { - (function(w) { - var base; - if (w.gl == null) { - w.gl = {}; +var base; +var w = window; +if (w.gl == null) { + w.gl = {}; +} +if ((base = w.gl).utils == null) { + base.utils = {}; +} +// Returns an array containing the value(s) of the +// of the key passed as an argument +w.gl.utils.getParameterValues = function(sParam) { + var i, sPageURL, sParameterName, sURLVariables, values; + sPageURL = decodeURIComponent(window.location.search.substring(1)); + sURLVariables = sPageURL.split('&'); + sParameterName = void 0; + values = []; + i = 0; + while (i < sURLVariables.length) { + sParameterName = sURLVariables[i].split('='); + if (sParameterName[0] === sParam) { + values.push(sParameterName[1].replace(/\+/g, ' ')); } - if ((base = w.gl).utils == null) { - base.utils = {}; + i += 1; + } + return values; +}; +// @param {Object} params - url keys and value to merge +// @param {String} url +w.gl.utils.mergeUrlParams = function(params, url) { + var lastChar, newUrl, paramName, paramValue, pattern; + newUrl = decodeURIComponent(url); + for (paramName in params) { + paramValue = params[paramName]; + pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)"); + if (paramValue == null) { + newUrl = newUrl.replace(pattern, ''); + } else if (url.search(pattern) !== -1) { + newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2"); + } else { + newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue; } - // Returns an array containing the value(s) of the - // of the key passed as an argument - w.gl.utils.getParameterValues = function(sParam) { - var i, sPageURL, sParameterName, sURLVariables, values; - sPageURL = decodeURIComponent(window.location.search.substring(1)); - sURLVariables = sPageURL.split('&'); - sParameterName = void 0; - values = []; - i = 0; - while (i < sURLVariables.length) { - sParameterName = sURLVariables[i].split('='); - if (sParameterName[0] === sParam) { - values.push(sParameterName[1].replace(/\+/g, ' ')); - } - i += 1; + } + // Remove a trailing ampersand + lastChar = newUrl[newUrl.length - 1]; + if (lastChar === '&') { + newUrl = newUrl.slice(0, -1); + } + return newUrl; +}; +// removes parameter query string from url. returns the modified url +w.gl.utils.removeParamQueryString = function(url, param) { + var urlVariables, variables; + url = decodeURIComponent(url); + urlVariables = url.split('&'); + return ((function() { + var j, len, results; + results = []; + for (j = 0, len = urlVariables.length; j < len; j += 1) { + variables = urlVariables[j]; + if (variables.indexOf(param) === -1) { + results.push(variables); } - return values; - }; - // @param {Object} params - url keys and value to merge - // @param {String} url - w.gl.utils.mergeUrlParams = function(params, url) { - var lastChar, newUrl, paramName, paramValue, pattern; - newUrl = decodeURIComponent(url); - for (paramName in params) { - paramValue = params[paramName]; - pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)"); - if (paramValue == null) { - newUrl = newUrl.replace(pattern, ''); - } else if (url.search(pattern) !== -1) { - newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2"); - } else { - newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue; - } - } - // Remove a trailing ampersand - lastChar = newUrl[newUrl.length - 1]; - if (lastChar === '&') { - newUrl = newUrl.slice(0, -1); - } - return newUrl; - }; - // removes parameter query string from url. returns the modified url - w.gl.utils.removeParamQueryString = function(url, param) { - var urlVariables, variables; - url = decodeURIComponent(url); - urlVariables = url.split('&'); - return ((function() { - var j, len, results; - results = []; - for (j = 0, len = urlVariables.length; j < len; j += 1) { - variables = urlVariables[j]; - if (variables.indexOf(param) === -1) { - results.push(variables); - } - } - return results; - })()).join('&'); - }; - w.gl.utils.removeParams = (params) => { - const url = new URL(window.location.href); - params.forEach((param) => { - url.search = w.gl.utils.removeParamQueryString(url.search, param); - }); - return url.href; - }; - w.gl.utils.getLocationHash = function(url) { - var hashIndex; - if (typeof url === 'undefined') { - // Note: We can't use window.location.hash here because it's - // not consistent across browsers - Firefox will pre-decode it - url = window.location.href; - } - hashIndex = url.indexOf('#'); - return hashIndex === -1 ? null : url.substring(hashIndex + 1); - }; + } + return results; + })()).join('&'); +}; +w.gl.utils.removeParams = (params) => { + const url = new URL(window.location.href); + params.forEach((param) => { + url.search = w.gl.utils.removeParamQueryString(url.search, param); + }); + return url.href; +}; +w.gl.utils.getLocationHash = function(url) { + var hashIndex; + if (typeof url === 'undefined') { + // Note: We can't use window.location.hash here because it's + // not consistent across browsers - Firefox will pre-decode it + url = window.location.href; + } + hashIndex = url.indexOf('#'); + return hashIndex === -1 ? null : url.substring(hashIndex + 1); +}; - w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href); +w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href); - w.gl.utils.visitUrl = (url) => { - document.location.href = url; - }; - })(window); -}).call(window); +w.gl.utils.visitUrl = (url) => { + document.location.href = url; +}; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 1821ca18053..7400c22543f 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -4,8 +4,6 @@ // // Handles single- and multi-line selection and highlight for blob views. // -require('vendor/jquery.scrollTo'); - // // ### Example Markup // @@ -31,8 +29,6 @@ require('vendor/jquery.scrollTo'); // </div> // (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.LineHighlighter = (function() { // CSS class applied to highlighted lines LineHighlighter.prototype.highlightClass = 'hll'; @@ -41,20 +37,31 @@ require('vendor/jquery.scrollTo'); LineHighlighter.prototype._hash = ''; function LineHighlighter(hash) { - var range; if (hash == null) { // Initialize a LineHighlighter object // // hash - String URL hash for dependency injection in tests hash = location.hash; } - this.setHash = bind(this.setHash, this); - this.highlightLine = bind(this.highlightLine, this); - this.clickHandler = bind(this.clickHandler, this); + this.setHash = this.setHash.bind(this); + this.highlightLine = this.highlightLine.bind(this); + this.clickHandler = this.clickHandler.bind(this); + this.highlightHash = this.highlightHash.bind(this); this._hash = hash; this.bindEvents(); - if (hash !== '') { - range = this.hashToRange(hash); + this.highlightHash(); + } + + LineHighlighter.prototype.bindEvents = function() { + const $fileHolder = $('.file-holder'); + $fileHolder.on('click', 'a[data-line-number]', this.clickHandler); + $fileHolder.on('highlight:line', this.highlightHash); + }; + + LineHighlighter.prototype.highlightHash = function() { + var range; + if (this._hash !== '') { + range = this.hashToRange(this._hash); if (range[0]) { this.highlightRange(range); $.scrollTo("#L" + range[0], { @@ -64,10 +71,6 @@ require('vendor/jquery.scrollTo'); }); } } - } - - LineHighlighter.prototype.bindEvents = function() { - $('#blob-content-holder').on('click', 'a[data-line-number]', this.clickHandler); }; LineHighlighter.prototype.clickHandler = function(event) { diff --git a/app/assets/javascripts/locale/de/app.js b/app/assets/javascripts/locale/de/app.js new file mode 100644 index 00000000000..9411f078ecf --- /dev/null +++ b/app/assets/javascripts/locale/de/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['de'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-09 13:44+0200","Language-Team":"German","Language":"de","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"de","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["Von"],"Commit":["Commit","Commits"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics liefern einen Ãœberblick darüber, wie viel Zeit in Ihrem Projekt von einer Idee bis zum Produktivdeployment vergeht."],"CycleAnalyticsStage|Code":["Code"],"CycleAnalyticsStage|Issue":["Issue"],"CycleAnalyticsStage|Plan":["Planung"],"CycleAnalyticsStage|Production":["Produktiv"],"CycleAnalyticsStage|Review":["Review"],"CycleAnalyticsStage|Staging":["Staging"],"CycleAnalyticsStage|Test":["Test"],"Deploy":["Deployment","Deployments"],"FirstPushedBy|First":["Erster"],"FirstPushedBy|pushed by":["gepusht von"],"From issue creation until deploy to production":["Vom Anlegen des Issues bis zum Produktivdeployment"],"From merge request merge until deploy to production":["Vom Merge Request bis zum Produktivdeployment"],"Introducing Cycle Analytics":["Was sind Cycle Analytics?"],"Last %d day":["Letzter %d Tag","Letzten %d Tage"],"Limited to showing %d event at most":["Eingeschränkt auf maximal %d Ereignis","Eingeschränkt auf maximal %d Ereignisse"],"Median":["Median"],"New Issue":["Neues Issue","Neue Issues"],"Not available":["Nicht verfügbar"],"Not enough data":["Nicht genügend Daten"],"OpenedNDaysAgo|Opened":["Erstellt"],"Pipeline Health":["Pipeline Kennzahlen"],"ProjectLifecycle|Stage":["Phase"],"Read more":["Mehr"],"Related Commits":["Zugehörige Commits"],"Related Deployed Jobs":["Zugehörige Deploymentjobs"],"Related Issues":["Zugehörige Issues"],"Related Jobs":["Zugehörige Jobs"],"Related Merge Requests":["Zugehörige Merge Requests"],"Related Merged Requests":["Zugehörige abgeschlossene Merge Requests"],"Showing %d event":["Zeige %d Ereignis","Zeige %d Ereignisse"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["Die Code-Phase stellt die Zeit vom ersten Commit bis zum Erstellen eines Merge Requests dar. Sobald Sie Ihren ersten Merge Request anlegen, werden dessen Daten automatisch ergänzt."],"The collection of events added to the data gathered for that stage.":["Ereignisse, die für diese Phase ausgewertet wurden."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["Die Issue-Phase stellt die Zeit vom Anlegen eines Issues bis zum Zuweisen eines Meilensteins oder Hinzufügen zum Issue Board dar. Erstellen Sie einen Issue, damit dessen Daten hier erscheinen."],"The phase of the development lifecycle.":["Die Phase im Entwicklungsprozess."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["Die Planungsphase stellt die Zeit von der vorherigen Phase bis zum Pushen des ersten Commits dar. Sobald Sie den ersten Commit pushen, werden dessen Daten hier erscheinen."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["Die Produktiv-Phase stellt die Gesamtzeit vom Anlegen eines Issues bis zum Deployment auf dem Produktivsystem dar. Sobald Sie den vollständigen Entwicklungszyklus von einer Idee bis zum Produktivdeployment durchlaufen haben, erscheinen die zugehörigen Daten hier."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["Die Review-Phase stellt die Zeit vom Anlegen eines Merge Requests bis zum Mergen dar. Sobald Sie Ihren ersten Merge Request abschließen, werden dessen Daten hier automatisch angezeigt."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["Die Staging-Phase stellt die Zeit zwischen Mergen eines Merge Requests und dem Produktivdeployment dar. Sobald Sie das erste Produktivdeployment durchgeführt haben, werden dessen Daten hier automatisch angezeigt."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["Die Test-Phase stellt die Zeit dar, die GitLab CI benötigt um die Pipelines von Merge Requests abzuarbeiten. Sobald die erste Pipeline abgeschlossen ist, werden deren Daten hier automatisch angezeigt."],"The time taken by each data entry gathered by that stage.":["Zeit die für das jeweilige Ereignis in der Phase ermittelt wurde."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["Der mittlere aller erfassten Werte. Zum Beispiel ist für 3, 5, 9 der Median 5. Bei 3, 5, 7, 8 ist der Median (5+7)/2 = 6."],"Time before an issue gets scheduled":["Zeit bis ein Issue geplant wird"],"Time before an issue starts implementation":["Zeit bis die Implementierung für ein Issue beginnt"],"Time between merge request creation and merge/close":["Zeit zwischen Anlegen und Mergen/Schließen eines Merge Requests"],"Time until first merge request":["Zeit bis zum ersten Merge Request"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Gesamtzeit"],"Total test time for all commits/merges":["Gesamte Testlaufzeit für alle Commits/Merges"],"Want to see the data? Please ask an administrator for access.":["Um diese Daten einsehen zu können, wenden Sie sich bitte an Ihren Administrator."],"We don't have enough data to show this stage.":["Es liegen nicht genügend Daten vor, um diese Phase anzuzeigen."],"You need permission.":["Sie benötigen Zugriffsrechte."],"day":["Tag","Tage"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/en/app.js b/app/assets/javascripts/locale/en/app.js new file mode 100644 index 00000000000..ade9b667b3c --- /dev/null +++ b/app/assets/javascripts/locale/en/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['en'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-04-12 22:36-0500","Last-Translator":"FULL NAME <EMAIL@ADDRESS>","Language-Team":"English","Language":"en","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","lang":"en","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":[""],"Commit":["",""],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":[""],"CycleAnalyticsStage|Code":[""],"CycleAnalyticsStage|Issue":[""],"CycleAnalyticsStage|Plan":[""],"CycleAnalyticsStage|Production":[""],"CycleAnalyticsStage|Review":[""],"CycleAnalyticsStage|Staging":[""],"CycleAnalyticsStage|Test":[""],"Deploy":["",""],"FirstPushedBy|First":[""],"FirstPushedBy|pushed by":[""],"From issue creation until deploy to production":[""],"From merge request merge until deploy to production":[""],"Introducing Cycle Analytics":[""],"Last %d day":["",""],"Limited to showing %d event at most":["",""],"Median":[""],"New Issue":["",""],"Not available":[""],"Not enough data":[""],"OpenedNDaysAgo|Opened":[""],"Pipeline Health":[""],"ProjectLifecycle|Stage":[""],"Read more":[""],"Related Commits":[""],"Related Deployed Jobs":[""],"Related Issues":[""],"Related Jobs":[""],"Related Merge Requests":[""],"Related Merged Requests":[""],"Showing %d event":["",""],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":[""],"The collection of events added to the data gathered for that stage.":[""],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":[""],"The phase of the development lifecycle.":[""],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":[""],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":[""],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":[""],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":[""],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":[""],"The time taken by each data entry gathered by that stage.":[""],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":[""],"Time before an issue gets scheduled":[""],"Time before an issue starts implementation":[""],"Time between merge request creation and merge/close":[""],"Time until first merge request":[""],"Time|hr":["",""],"Time|min":["",""],"Time|s":[""],"Total Time":[""],"Total test time for all commits/merges":[""],"Want to see the data? Please ask an administrator for access.":[""],"We don't have enough data to show this stage.":[""],"You need permission.":[""],"day":["",""]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/es/app.js b/app/assets/javascripts/locale/es/app.js new file mode 100644 index 00000000000..f5f510d7c2b --- /dev/null +++ b/app/assets/javascripts/locale/es/app.js @@ -0,0 +1 @@ +var locales = locales || {}; locales['es'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","PO-Revision-Date":"2017-05-20 22:37-0500","Language-Team":"Spanish","Language":"es","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Plural-Forms":"nplurals=2; plural=n != 1;","Last-Translator":"","X-Generator":"Poedit 2.0.1","lang":"es","domain":"app","plural_forms":"nplurals=2; plural=n != 1;"},"ByAuthor|by":["por"],"Commit":["Cambio","Cambios"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto."],"CycleAnalyticsStage|Code":["Código"],"CycleAnalyticsStage|Issue":["Incidencia"],"CycleAnalyticsStage|Plan":["Planificación"],"CycleAnalyticsStage|Production":["Producción"],"CycleAnalyticsStage|Review":["Revisión"],"CycleAnalyticsStage|Staging":["Puesta en escena"],"CycleAnalyticsStage|Test":["Pruebas"],"Deploy":["Despliegue","Despliegues"],"FirstPushedBy|First":["Primer"],"FirstPushedBy|pushed by":["enviado por"],"From issue creation until deploy to production":["Desde la creación de la incidencia hasta el despliegue a producción"],"From merge request merge until deploy to production":["Desde la integración de la solicitud de fusión hasta el despliegue a producción"],"Introducing Cycle Analytics":["Introducción a Cycle Analytics"],"Last %d day":["Último %d dÃa","Últimos %d dÃas"],"Limited to showing %d event at most":["Limitado a mostrar máximo %d evento","Limitado a mostrar máximo %d eventos"],"Median":["Mediana"],"New Issue":["Nueva incidencia","Nuevas incidencias"],"Not available":["No disponible"],"Not enough data":["No hay suficientes datos"],"OpenedNDaysAgo|Opened":["Abierto"],"Pipeline Health":["Estado del Pipeline"],"ProjectLifecycle|Stage":["Etapa"],"Read more":["Leer más"],"Related Commits":["Cambios Relacionados"],"Related Deployed Jobs":["Trabajos Desplegados Relacionados"],"Related Issues":["Incidencias Relacionadas"],"Related Jobs":["Trabajos Relacionados"],"Related Merge Requests":["Solicitudes de fusión Relacionadas"],"Related Merged Requests":["Solicitudes de fusión Relacionadas"],"Showing %d event":["Mostrando %d evento","Mostrando %d eventos"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquà una vez creada tu primera solicitud de fusión."],"The collection of events added to the data gathered for that stage.":["La colección de eventos agregados a los datos recopilados para esa etapa."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa."],"The phase of the development lifecycle.":["La etapa del ciclo de vida de desarrollo."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapa de planificación muestra el tiempo desde el paso anterior hasta el envÃo de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envÃe el primer cambio."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse."],"The time taken by each data entry gathered by that stage.":["El tiempo utilizado por cada entrada de datos obtenido por esa etapa."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6."],"Time before an issue gets scheduled":["Tiempo antes de que una incidencia sea programada"],"Time before an issue starts implementation":["Tiempo antes de que empieze la implementación de una incidencia"],"Time between merge request creation and merge/close":["Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta"],"Time until first merge request":["Tiempo hasta la primera solicitud de fusión"],"Time|hr":["hr","hrs"],"Time|min":["min","mins"],"Time|s":["s"],"Total Time":["Tiempo Total"],"Total test time for all commits/merges":["Tiempo total de pruebas para todos los cambios o integraciones"],"Want to see the data? Please ask an administrator for access.":["¿Quieres ver los datos? Por favor pide acceso al administrador."],"We don't have enough data to show this stage.":["No hay suficientes datos para mostrar en esta etapa."],"You need permission.":["Necesitas permisos."],"day":["dÃa","dÃas"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js new file mode 100644 index 00000000000..7ba676d6d20 --- /dev/null +++ b/app/assets/javascripts/locale/index.js @@ -0,0 +1,70 @@ +import Jed from 'jed'; + +/** + This is required to require all the translation folders in the current directory + this saves us having to do this manually & keep up to date with new languages +**/ +function requireAll(requireContext) { return requireContext.keys().map(requireContext); } + +const allLocales = requireAll(require.context('./', true, /^(?!.*(?:index.js$)).*\.js$/)); +const locales = allLocales.reduce((d, obj) => { + const data = d; + const localeKey = Object.keys(obj)[0]; + + data[localeKey] = obj[localeKey]; + + return data; +}, {}); + +let lang = document.querySelector('html').getAttribute('lang') || 'en'; +lang = lang.replace(/-/g, '_'); + +const locale = new Jed(locales[lang]); + +/** + Translates `text` + + @param text The text to be translated + @returns {String} The translated text +**/ +const gettext = locale.gettext.bind(locale); + +/** + Translate the text with a number + if the number is more than 1 it will use the `pluralText` translation. + This method allows for contexts, see below re. contexts + + @param text Singular text to translate (eg. '%d day') + @param pluralText Plural text to translate (eg. '%d days') + @param count Number to decide which translation to use (eg. 2) + @returns {String} Translated text with the number replaced (eg. '2 days') +**/ +const ngettext = (text, pluralText, count) => { + const translated = locale.ngettext(text, pluralText, count).replace(/%d/g, count).split('|'); + + return translated[translated.length - 1]; +}; + +/** + Translate context based text + Either pass in the context translation like `Context|Text to translate` + or allow for dynamic text by doing passing in the context first & then the text to translate + + @param keyOrContext Can be either the key to translate including the context + (eg. 'Context|Text') or just the context for the translation + (eg. 'Context') + @param key Is the dynamic variable you want to be translated + @returns {String} Translated context based text +**/ +const pgettext = (keyOrContext, key) => { + const normalizedKey = key ? `${keyOrContext}|${key}` : keyOrContext; + const translated = gettext(normalizedKey).split('|'); + + return translated[translated.length - 1]; +}; + +export { lang }; +export { gettext as __ }; +export { ngettext as n__ }; +export { pgettext as s__ }; +export default locale; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 665a59f3183..f0958972130 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,14 +37,7 @@ import './shortcuts_issuable'; import './shortcuts_network'; // behaviors -import './behaviors/autosize'; -import './behaviors/details_behavior'; -import './behaviors/quick_submit'; -import './behaviors/requires_input'; -import './behaviors/toggler_behavior'; -import './behaviors/bind_in_out'; -import { installGlEmojiElement } from './behaviors/gl_emoji'; -installGlEmojiElement(); +import './behaviors/'; // blob import './blob/create_branch_dropdown'; @@ -66,7 +59,6 @@ import './lib/utils/datetime_utility'; import './lib/utils/notify'; import './lib/utils/pretty_time'; import './lib/utils/text_utility'; -import './lib/utils/type_utility'; import './lib/utils/url_utility'; // u2f @@ -75,12 +67,6 @@ import './u2f/error'; import './u2f/register'; import './u2f/util'; -// droplab -import './droplab/droplab'; -import './droplab/droplab_ajax'; -import './droplab/droplab_ajax_filter'; -import './droplab/droplab_filter'; - // everything else import './abuse_reports'; import './activities'; @@ -110,7 +96,6 @@ import './dropzone_input'; import './due_date_select'; import './files_comment_button'; import './flash'; -import './gfm_auto_complete'; import './gl_dropdown'; import './gl_field_error'; import './gl_field_errors'; @@ -136,8 +121,6 @@ import './member_expiration_date'; import './members'; import './merge_request'; import './merge_request_tabs'; -import './merge_request_widget'; -import './merged_buttons'; import './milestone'; import './milestone_select'; import './mini_pipeline_graph_dropdown'; @@ -171,13 +154,13 @@ import './single_file_diff'; import './smart_interval'; import './snippets_list'; import './star'; -import './subbable_resource'; import './subscription'; import './subscription_select'; import './syntax_highlight'; import './task_list'; import './todos'; import './tree'; +import './usage_ping'; import './user'; import './user_tabs'; import './username_validator'; @@ -187,6 +170,9 @@ import './visibility_select'; import './wikis'; import './zen_mode'; +// eslint-disable-next-line global-require, import/no-commonjs +if (process.env.NODE_ENV !== 'production') require('./test_utils/'); + document.addEventListener('beforeunload', function () { // Unbind scroll events $(document).off('scroll'); @@ -220,6 +206,14 @@ $(function () { } }); + if (bootstrapBreakpoint === 'xs') { + const $rightSidebar = $('aside.right-sidebar, .page-with-sidebar'); + + $rightSidebar + .removeClass('right-sidebar-expanded') + .addClass('right-sidebar-collapsed'); + } + // prevent default action for disabled buttons $('.btn').click(function(e) { if ($(this).hasClass('disabled')) { @@ -282,7 +276,7 @@ $(function () { // Disable form buttons while a form is submitting $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) { var buttons; - buttons = $('[type="submit"]', this); + buttons = $('[type="submit"], .js-disable-on-submit', this); switch (e.type) { case 'ajax:beforeSend': case 'submit': diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js index 129d2dc5f0a..e034729bd39 100644 --- a/app/assets/javascripts/member_expiration_date.js +++ b/app/assets/javascripts/member_expiration_date.js @@ -18,9 +18,10 @@ const calendar = new Pikaday({ field: $input.get(0), - theme: 'gitlab-theme', + theme: 'gitlab-theme animate-picker', format: 'yyyy-mm-dd', minDate: new Date(), + container: $input.parent().get(0), onSelect(dateText) { $input.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index e3f367a11eb..8291b8c4a70 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -31,8 +31,8 @@ toggleLabel(selected, $el) { return $el.text(); }, - clicked: (selected, $link) => { - this.formSubmit(null, $link); + clicked: (options) => { + this.formSubmit(null, options.$el); }, }); }); diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index 15992460146..17030c3e4d3 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -2,14 +2,13 @@ /* global Flash */ import Vue from 'vue'; - -require('./merge_conflict_store'); -require('./merge_conflict_service'); -require('./mixins/line_conflict_utils'); -require('./mixins/line_conflict_actions'); -require('./components/diff_file_editor'); -require('./components/inline_conflict_lines'); -require('./components/parallel_conflict_lines'); +import './merge_conflict_store'; +import './merge_conflict_service'; +import './mixins/line_conflict_utils'; +import './mixins/line_conflict_actions'; +import './components/diff_file_editor'; +import './components/inline_conflict_lines'; +import './components/parallel_conflict_lines'; $(() => { const INTERACTIVE_RESOLVE_MODE = 'interactive'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 5e01aacf2ba..f93feeec1c2 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,13 +1,11 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */ /* global MergeRequestTabs */ -require('vendor/jquery.waitforimages'); -require('./task_list'); -require('./merge_request_tabs'); +import 'vendor/jquery.waitforimages'; +import './task_list'; +import './merge_request_tabs'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.MergeRequest = (function() { function MergeRequest(opts) { // Initialize MergeRequest behavior @@ -16,7 +14,7 @@ require('./merge_request_tabs'); // action - String, current controller action // this.opts = opts != null ? opts : {}; - this.submitNoteForm = bind(this.submitNoteForm, this); + this.submitNoteForm = this.submitNoteForm.bind(this); this.$el = $('.merge-request'); this.$('.show-all-commits').on('click', (function(_this) { return function() { @@ -106,6 +104,21 @@ require('./merge_request_tabs'); }); }; + MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) { + $('.detail-page-header .status-box') + .removeClass(classToRemove) + .addClass(classToAdd) + .find('span') + .text(newStatusText); + }; + + MergeRequest.prototype.decreaseCounter = function(by = 1) { + const $el = $('.nav-links .js-merge-counter'); + const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); + + $el.text(gl.text.addDelimiter(count)); + }; + return MergeRequest; })(); }).call(window); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 3c4e6102469..22032d0f914 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,13 +1,12 @@ /* eslint-disable no-new, class-methods-use-this */ /* global Breakpoints */ /* global Flash */ +/* global notes */ import Cookies from 'js-cookie'; - -import CommitPipelinesTable from './commit/pipelines/pipelines_table'; - import './breakpoints'; import './flash'; +import BlobForkSuggestion from './blob/blob_fork_suggestion'; /* eslint-disable max-len */ // MergeRequestTabs @@ -90,6 +89,7 @@ import './flash'; .on('click', this.clickTab); } + // Used in tests unbindEvents() { $(document) .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) @@ -99,10 +99,12 @@ import './flash'; .off('click', this.clickTab); } - destroy() { - this.unbindEvents(); + destroyPipelinesView() { if (this.commitPipelinesTable) { this.commitPipelinesTable.$destroy(); + this.commitPipelinesTable = null; + + document.querySelector('#commit-pipeline-table-view').innerHTML = ''; } } @@ -128,6 +130,7 @@ import './flash'; this.loadCommits($target.attr('href')); this.expandView(); this.resetViewContainer(); + this.destroyPipelinesView(); } else if (this.isDiffAction(action)) { this.loadDiff($target.attr('href')); if (Breakpoints.get().getBreakpointSize() !== 'lg') { @@ -136,12 +139,14 @@ import './flash'; if (this.diffViewType() === 'parallel') { this.expandViewContainer(); } + this.destroyPipelinesView(); } else if (action === 'pipelines') { this.resetViewContainer(); - this.loadPipelines(); + this.mountPipelinesView(); } else { this.expandView(); this.resetViewContainer(); + this.destroyPipelinesView(); } if (this.setUrl) { this.setCurrentAction(action); @@ -227,16 +232,12 @@ import './flash'; }); } - loadPipelines() { - if (this.pipelinesLoaded) { - return; - } - const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); - // Could already be mounted from the `pipelines_bundle` - if (pipelineTableViewEl) { - this.commitPipelinesTable = new CommitPipelinesTable().$mount(pipelineTableViewEl); - } - this.pipelinesLoaded = true; + mountPipelinesView() { + this.commitPipelinesTable = new gl.CommitPipelinesTable().$mount(); + // $mount(el) replaces the el with the new rendered component. We need it in order to mount + // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount + document.querySelector('#commit-pipeline-table-view') + .appendChild(this.commitPipelinesTable.$el); } loadDiff(source) { @@ -251,7 +252,8 @@ import './flash'; this.ajaxGet({ url: `${urlPathname}.json${location.search}`, success: (data) => { - $('#diffs').html(data.html); + const $container = $('#diffs'); + $container.html(data.html); if (typeof gl.diffNotesCompileComponents !== 'undefined') { gl.diffNotesCompileComponents(); @@ -267,6 +269,35 @@ import './flash'; new gl.Diff(); this.scrollToElement('#diffs'); + + $('.diff-file').each((i, el) => { + new BlobForkSuggestion({ + openButtons: $(el).find('.js-edit-blob-link-fork-toggler'), + forkButtons: $(el).find('.js-fork-suggestion-button'), + cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'), + suggestionSections: $(el).find('.js-file-fork-suggestion-section'), + actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'), + }) + .init(); + }); + + // Scroll any linked note into view + // Similar to `toggler_behavior` in the discussion tab + const hash = window.gl.utils.getLocationHash(); + const anchor = hash && $container.find(`[id="${hash}"]`); + if (anchor) { + const notesContent = anchor.closest('.notes_content'); + const lineType = notesContent.hasClass('new') ? 'new' : 'old'; + notes.toggleDiffNote({ + target: anchor, + lineType, + forceShow: true, + }); + anchor[0].scrollIntoView(); + // We have multiple elements on the page with `#note_xxx` + // (discussion and diff tabs) and `:target` only applies to the first + anchor.addClass('target'); + } }, }); } @@ -342,18 +373,26 @@ import './flash'; initAffix() { const $tabs = $('.js-tabs-affix'); + const $fixedNav = $('.navbar-gitlab'); // Screen space on small screens is usually very sparse // So we dont affix the tabs on these if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return; + /** + If the browser does not support position sticky, it returns the position as static. + If the browser does support sticky, then we allow the browser to handle it, if not + then we default back to Bootstraps affix + **/ + if ($tabs.css('position') !== 'static') return; + const $diffTabs = $('#diff-notes-app'); $tabs.off('affix.bs.affix affix-top.bs.affix') .affix({ offset: { top: () => ( - $diffTabs.offset().top - $tabs.height() + $diffTabs.offset().top - $tabs.height() - $fixedNav.height() ), }, }) diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js deleted file mode 100644 index 0e2af3df071..00000000000 --- a/app/assets/javascripts/merge_request_widget.js +++ /dev/null @@ -1,296 +0,0 @@ -/* eslint-disable max-len, no-var, func-names, space-before-function-paren, vars-on-top, comma-dangle, no-return-assign, consistent-return, no-param-reassign, one-var, one-var-declaration-per-line, quotes, prefer-template, no-else-return, prefer-arrow-callback, no-unused-vars, no-underscore-dangle, no-shadow, no-mixed-operators, camelcase, default-case, wrap-iife */ -/* global notify */ -/* global notifyPermissions */ -/* global merge_request_widget */ - -import './smart_interval'; -import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; - -((global) => { - var indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; - - const DEPLOYMENT_TEMPLATE = `<div class="mr-widget-heading" id="<%- id %>"> - <div class="ci_widget ci-success"> - <%= ci_success_icon %> - <span> - Deployed to - <a href="<%- url %>" target="_blank" rel="noopener noreferrer" class="environment"> - <%- name %> - </a> - <span class="js-environment-timeago" data-toggle="tooltip" data-placement="top" data-title="<%- deployed_at_formatted %>"> - <%- deployed_at %> - </span> - <a class="js-environment-link" href="<%- external_url %>" target="_blank" rel="noopener noreferrer"> - <i class="fa fa-external-link"></i> - View on <%- external_url_formatted %> - </a> - </span> - <span class="stop-env-container js-stop-env-link"> - <a href="<%- stop_url %>" class="close-evn-link" data-method="post" rel="nofollow" data-confirm="Are you sure you want to stop this environment?"> - <i class="fa fa-stop-circle-o"/> - Stop environment - </a> - </span> - </div> - </div>`; - - global.MergeRequestWidget = (function() { - function MergeRequestWidget(opts) { - // Initialize MergeRequestWidget behavior - // - // check_enable - Boolean, whether to check automerge status - // merge_check_url - String, URL to use to check automerge status - // ci_status_url - String, URL to use to check CI status - // - this.opts = opts; - this.$widgetBody = $('.mr-widget-body'); - $('#modal_merge_info').modal({ - show: false - }); - this.clearEventListeners(); - this.addEventListeners(); - this.getCIStatus(false); - this.retrieveSuccessIcon(); - - this.initMiniPipelineGraph(); - - 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(); - } - - MergeRequestWidget.prototype.clearEventListeners = function() { - return $(document).off('DOMContentLoaded'); - }; - - MergeRequestWidget.prototype.addEventListeners = function() { - var allowedPages; - allowedPages = ['show', 'commits', 'pipelines', 'changes']; - $(document).on('DOMContentLoaded', (function(_this) { - return function() { - var page; - page = $('body').data('page').split(':').last(); - if (allowedPages.indexOf(page) === -1) { - return _this.clearEventListeners(); - } - }; - })(this)); - }; - - MergeRequestWidget.prototype.retrieveSuccessIcon = function() { - const $ciSuccessIcon = $('.js-success-icon'); - this.$ciSuccessIcon = $ciSuccessIcon.html(); - $ciSuccessIcon.remove(); - }; - - MergeRequestWidget.prototype.mergeInProgress = function(deleteSourceBranch) { - if (deleteSourceBranch == null) { - deleteSourceBranch = false; - } - return $.ajax({ - type: 'GET', - url: $('.merge-request').data('url'), - success: (function(_this) { - return function(data) { - var callback, urlSuffix; - if (data.state === "merged") { - urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : ''; - return window.location.href = window.location.pathname + urlSuffix; - } else if (data.merge_error) { - return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>"); - } else { - callback = function() { - return merge_request_widget.mergeInProgress(deleteSourceBranch); - }; - return setTimeout(callback, 2000); - } - }; - })(this), - dataType: 'json' - }); - }; - - MergeRequestWidget.prototype.cancelPolling = function () { - this.ciStatusInterval.cancel(); - this.ciEnvironmentStatusInterval.cancel(); - }; - - MergeRequestWidget.prototype.getMergeStatus = function() { - return $.get(this.opts.merge_check_url, (data) => { - var $html = $(data); - this.updateMergeButton(this.status, this.hasCi, $html); - $('.mr-widget-body').replaceWith($html.find('.mr-widget-body')); - $('.mr-widget-footer').replaceWith($html.find('.mr-widget-footer')); - }); - }; - - MergeRequestWidget.prototype.ciLabelForStatus = function(status) { - switch (status) { - case 'success': - return 'passed'; - case 'success_with_warnings': - return 'passed with warnings'; - default: - return status; - } - }; - - MergeRequestWidget.prototype.getCIStatus = function(showNotification) { - var _this; - _this = this; - $('.ci-widget-fetching').show(); - return $.getJSON(this.opts.ci_status_url, (function(_this) { - return function(data) { - var message, status, title; - _this.status = data.status; - _this.hasCi = data.has_ci; - _this.updateMergeButton(_this.status, _this.hasCi); - if (data.environments && data.environments.length) _this.renderEnvironments(data.environments); - if (data.status !== _this.opts.ci_status || - data.sha !== _this.opts.ci_sha || - data.pipeline !== _this.opts.ci_pipeline) { - _this.opts.ci_status = data.status; - _this.showCIStatus(data.status); - if (data.coverage) { - _this.showCICoverage(data.coverage); - } - if (data.pipeline) { - _this.opts.ci_pipeline = data.pipeline; - _this.updatePipelineUrls(data.pipeline); - } - if (data.sha) { - _this.opts.ci_sha = data.sha; - _this.updateCommitUrls(data.sha); - } - if (showNotification && data.status) { - status = _this.ciLabelForStatus(data.status); - if (status === "preparing") { - title = _this.opts.ci_title.preparing; - status = status.charAt(0).toUpperCase() + status.slice(1); - message = _this.opts.ci_message.preparing.replace('{{status}}', status); - } else { - title = _this.opts.ci_title.normal; - message = _this.opts.ci_message.normal.replace('{{status}}', status); - } - title = title.replace('{{status}}', status); - message = message.replace('{{sha}}', data.sha); - message = message.replace('{{title}}', data.title); - notify(title, message, _this.opts.gitlab_icon, function() { - this.close(); - }); - } - } - }; - })(this)); - }; - - MergeRequestWidget.prototype.getCIEnvironmentsStatus = function() { - $.getJSON(this.opts.ci_environments_status_url, (environments) => { - if (environments && environments.length) this.renderEnvironments(environments); - }); - }; - - MergeRequestWidget.prototype.renderEnvironments = function(environments) { - for (let i = 0; i < environments.length; i += 1) { - const environment = environments[i]; - 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 { - $('.js-environment-timeago', $template).remove(); - environment.name += '.'; - } - environment.ci_success_icon = this.$ciSuccessIcon; - const templateString = _.unescape($template[0].outerHTML); - const template = _.template(templateString)(environment); - this.$widgetBody.before(template); - } - }; - - MergeRequestWidget.prototype.showCIStatus = function(state) { - var allowed_states; - if (state == null) { - return; - } - $('.ci_widget').hide(); - $('.ci_widget.ci-' + state).show(); - - this.initMiniPipelineGraph(); - }; - - MergeRequestWidget.prototype.showCICoverage = function(coverage) { - var text = `Coverage ${coverage}%`; - return $('.ci_widget:visible .ci-coverage').text(text); - }; - - MergeRequestWidget.prototype.updateMergeButton = function(state, hasCi, $html) { - const allowed_states = ["failed", "canceled", "running", "pending", "success", "success_with_warnings", "skipped", "not_found"]; - let stateClass = 'btn-danger'; - if (!hasCi) { - stateClass = 'btn-create'; - } else if (indexOf.call(allowed_states, state) !== -1) { - switch (state) { - case "failed": - case "canceled": - case "not_found": - stateClass = 'btn-danger'; - break; - case "running": - stateClass = 'btn-info'; - break; - case "success": - case "success_with_warnings": - stateClass = 'btn-create'; - } - } else { - $('.ci_widget.ci-error').show(); - stateClass = 'btn-danger'; - } - - this.setMergeButtonClass(stateClass, $html); - }; - - MergeRequestWidget.prototype.setMergeButtonClass = function(css_class, $html = $('.mr-state-widget')) { - return $html.find('.js-merge-button').removeClass('btn-danger btn-info btn-create').addClass(css_class); - }; - - MergeRequestWidget.prototype.updatePipelineUrls = function(id) { - const pipelineUrl = this.opts.pipeline_path; - $('.pipeline').text(`#${id}`).attr('href', [pipelineUrl, id].join('/')); - }; - - MergeRequestWidget.prototype.updateCommitUrls = function(id) { - const commitsUrl = this.opts.commits_path; - $('.js-commit-link').text(`#${id}`).attr('href', [commitsUrl, id].join('/')); - }; - - MergeRequestWidget.prototype.initMiniPipelineGraph = function() { - new MiniPipelineGraph({ - container: '.js-pipeline-inline-mr-widget-graph:visible', - }).bindEvents(); - }; - - return MergeRequestWidget; - })(); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_request_widget/ci_bundle.js b/app/assets/javascripts/merge_request_widget/ci_bundle.js deleted file mode 100644 index 21d7c3e168e..00000000000 --- a/app/assets/javascripts/merge_request_widget/ci_bundle.js +++ /dev/null @@ -1,53 +0,0 @@ -/* global merge_request_widget */ - -(() => { - $(() => { - /* TODO: This needs a better home, or should be refactored. It was previously contained - * in a script tag in app/views/projects/merge_requests/widget/open/_accept.html.haml, - * but Vue chokes on script tags and prevents their execution. So it was moved here - * temporarily. - * */ - - $(document) - .off('ajax:send', '.accept-mr-form') - .on('ajax:send', '.accept-mr-form', () => { - $('.accept-mr-form :input').disable(); - }); - - $(document) - .off('click', '.accept-merge-request') - .on('click', '.accept-merge-request', () => { - $('.js-merge-button, .js-merge-when-pipeline-succeeds-button').html('<i class="fa fa-spinner fa-spin"></i> Merge in progress'); - }); - - $(document) - .off('click', '.merge-when-pipeline-succeeds') - .on('click', '.merge-when-pipeline-succeeds', () => { - $('#merge_when_pipeline_succeeds').val('1'); - }); - - $(document) - .off('click', '.js-merge-dropdown a') - .on('click', '.js-merge-dropdown a', (e) => { - e.preventDefault(); - $(e.target).closest('form').submit(); - }); - if ($('.rebase-in-progress').length) { - merge_request_widget.rebaseInProgress(); - } else if ($('.rebase-mr-form').length) { - $(document) - .off('ajax:send', '.rebase-mr-form') - .on('ajax:send', '.rebase-mr-form', () => { - $('.rebase-mr-form :input').disable(); - }); - - $(document) - .off('click', '.js-rebase-button') - .on('click', '.js-rebase-button', () => { - $('.js-rebase-button').html("<i class='fa fa-spinner fa-spin'></i> Rebase in progress"); - }); - } else { - setTimeout(() => merge_request_widget.getMergeStatus(), 200); - } - }); -})(); diff --git a/app/assets/javascripts/merged_buttons.js b/app/assets/javascripts/merged_buttons.js deleted file mode 100644 index 9548a98f499..00000000000 --- a/app/assets/javascripts/merged_buttons.js +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */ - -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - - this.MergedButtons = (function() { - function MergedButtons() { - this.removeSourceBranch = bind(this.removeSourceBranch, this); - this.$removeBranchWidget = $('.remove_source_branch_widget'); - this.$removeBranchProgress = $('.remove_source_branch_in_progress'); - this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); - this.cleanEventListeners(); - this.initEventListeners(); - } - - MergedButtons.prototype.cleanEventListeners = function() { - $(document).off('click', '.remove_source_branch'); - $(document).off('ajax:success', '.remove_source_branch'); - return $(document).off('ajax:error', '.remove_source_branch'); - }; - - MergedButtons.prototype.initEventListeners = function() { - $(document).on('click', '.remove_source_branch', this.removeSourceBranch); - $(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess); - return $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError); - }; - - MergedButtons.prototype.removeSourceBranch = function() { - this.$removeBranchWidget.hide(); - return this.$removeBranchProgress.show(); - }; - - MergedButtons.prototype.removeBranchSuccess = function() { - return location.reload(); - }; - - MergedButtons.prototype.removeBranchError = function() { - this.$removeBranchWidget.hide(); - this.$removeBranchProgress.hide(); - return this.$removeBranchFailed.show(); - }; - - return MergedButtons; - })(); -}).call(window); diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 38c673e8907..841b24a60a3 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -19,12 +19,10 @@ }); }; - Milestone.sortIssues = function(data) { - var sort_issues_url; - sort_issues_url = location.href + "/sort_issues"; + Milestone.sortIssues = function(url, data) { return $.ajax({ type: "PUT", - url: sort_issues_url, + url, data: data, success: function(_data) { return Milestone.successCallback(_data); @@ -36,12 +34,10 @@ }); }; - Milestone.sortMergeRequests = function(data) { - var sort_mr_url; - sort_mr_url = location.href + "/sort_merge_requests"; + Milestone.sortMergeRequests = function(url, data) { return $.ajax({ type: "PUT", - url: sort_mr_url, + url, data: data, success: function(_data) { return Milestone.successCallback(_data); @@ -81,42 +77,55 @@ }; function Milestone() { - var oldMouseStart; + this.issuesSortEndpoint = $('#tab-issues').data('sort-endpoint'); + this.mergeRequestsSortEndpoint = $('#tab-merge-requests').data('sort-endpoint'); + this.bindIssuesSorting(); - this.bindMergeRequestSorting(); this.bindTabsSwitching(); + + // Load merge request tab if it is active + // merge request tab is active based on different conditions in the backend + this.loadTab($('.js-milestone-tabs .active a')); + + this.loadInitialTab(); } Milestone.prototype.bindIssuesSorting = function() { + if (!this.issuesSortEndpoint) return; + $('#issues-list-unassigned, #issues-list-ongoing, #issues-list-closed').each(function (i, el) { this.createSortable(el, { group: 'issue-list', listEls: $('.issues-sortable-list'), fieldName: 'issue', - sortCallback: Milestone.sortIssues, + sortCallback: (data) => { + Milestone.sortIssues(this.issuesSortEndpoint, data); + }, updateCallback: Milestone.updateIssue, }); }.bind(this)); }; Milestone.prototype.bindTabsSwitching = function() { - return $('a[data-toggle="tab"]').on('show.bs.tab', function(e) { - var currentTabClass, previousTabClass; - currentTabClass = $(e.target).data('show'); - previousTabClass = $(e.relatedTarget).data('show'); - $(previousTabClass).hide(); - $(currentTabClass).removeClass('hidden'); - return $(currentTabClass).show(); + return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => { + const $target = $(e.target); + + location.hash = $target.attr('href'); + this.loadTab($target); }); }; Milestone.prototype.bindMergeRequestSorting = function() { + if (!this.mergeRequestsSortEndpoint) return; + $("#merge_requests-list-unassigned, #merge_requests-list-ongoing, #merge_requests-list-closed").each(function (i, el) { this.createSortable(el, { group: 'merge-request-list', listEls: $(".merge_requests-sortable-list:not(#merge_requests-list-merged)"), fieldName: 'merge_request', - sortCallback: Milestone.sortMergeRequests, + sortCallback: (data) => { + Milestone.sortMergeRequests(this.mergeRequestsSortEndpoint, data); + }, updateCallback: Milestone.updateMergeRequest, }); }.bind(this)); @@ -169,6 +178,35 @@ }); }; + Milestone.prototype.loadInitialTab = function() { + const $target = $(`.js-milestone-tabs a[href="${location.hash}"]`); + + if ($target.length) { + $target.tab('show'); + } + }; + + Milestone.prototype.loadTab = function($target) { + const endpoint = $target.data('endpoint'); + const tabElId = $target.attr('href'); + + if (endpoint && !$target.hasClass('is-loaded')) { + $.ajax({ + url: endpoint, + dataType: 'JSON', + }) + .fail(() => new Flash('Error loading milestone tab')) + .done((data) => { + $(tabElId).html(data.html); + $target.addClass('is-loaded'); + + if (tabElId === '#tab-merge-requests') { + this.bindMergeRequestSorting(); + } + }); + } + }; + return Milestone; })(); }).call(window); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index ac4fad88fe5..9d481d7c003 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -2,8 +2,6 @@ /* global Issuable */ /* global ListMilestone */ -import Vue from 'vue'; - (function() { this.MilestoneSelect = (function() { function MilestoneSelect(currentProject, els) { @@ -20,12 +18,11 @@ import Vue from 'vue'; } $els.each(function(i, dropdown) { - var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove; + var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, defaultNo, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, selectedMilestoneDefault, showAny, showNo, showUpcoming, showStarted, useId, showMenuAbove; $dropdown = $(dropdown); projectId = $dropdown.data('project-id'); milestonesUrl = $dropdown.data('milestones'); issueUpdateURL = $dropdown.data('issueUpdate'); - selectedMilestone = $dropdown.data('selected'); showNo = $dropdown.data('show-no'); showAny = $dropdown.data('show-any'); showMenuAbove = $dropdown.data('showMenuAbove'); @@ -33,6 +30,7 @@ import Vue from 'vue'; showStarted = $dropdown.data('show-started'); useId = $dropdown.data('use-id'); defaultLabel = $dropdown.data('default-label'); + defaultNo = $dropdown.data('default-no'); issuableId = $dropdown.data('issuable-id'); abilityName = $dropdown.data('ability-name'); $selectbox = $dropdown.closest('.selectbox'); @@ -40,6 +38,9 @@ import Vue from 'vue'; $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); $value = $block.find('.value'); $loading = $block.find('.block-loading').fadeOut(); + selectedMilestoneDefault = (showAny ? '' : null); + selectedMilestoneDefault = (showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault); + selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault; if (issueUpdateURL) { milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>'); milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; @@ -88,8 +89,18 @@ import Vue from 'vue'; if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } + $(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active'); }); }, + renderRow: function(milestone) { + return ` + <li data-milestone-id="${milestone.name}"> + <a href='#' class='dropdown-menu-milestone-link'> + ${_.escape(milestone.title)} + </a> + </li> + `; + }, filterable: true, search: { fields: ['title'] @@ -122,12 +133,24 @@ import Vue from 'vue'; // display:block overrides the hide-collapse rule return $value.css('display', ''); }, + opened: function(e) { + const $el = $(e.currentTarget); + if ($dropdown.hasClass('js-issue-board-sidebar')) { + selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; + } + $('a.is-active', $el).removeClass('is-active'); + $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); + }, vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(selected, $el, e) { - var data, isIssueIndex, isMRIndex, page, boardsStore; + clicked: function(options) { + const { $el, e } = options; + let selected = options.selectedObj; + var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); + isSelecting = (selected.name !== selectedMilestone); + selectedMilestone = isSelecting ? selected.name : selectedMilestoneDefault; if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { e.preventDefault(); return; @@ -141,22 +164,17 @@ import Vue from 'vue'; boardsStore[$dropdown.data('field-name')] = selected.name; e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - if (selected.name != null) { - selectedMilestone = selected.name; - } else { - selectedMilestone = ''; - } return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - if (selected.id !== -1) { - Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'milestone', new ListMilestone({ + if (selected.id !== -1 && isSelecting) { + gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({ id: selected.id, title: selected.name })); } else { - Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'milestone'); + gl.issueBoards.boardStoreIssueDelete('milestone'); } $dropdown.trigger('loading.gl.dropdown'); @@ -166,6 +184,9 @@ import Vue from 'vue'; .then(function () { $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); + }) + .catch(() => { + $loading.fadeOut(); }); } else { selected = $selectbox.find('input[type="hidden"]').val(); diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js index 9c58c465001..64c1447f427 100644 --- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js +++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js @@ -28,7 +28,9 @@ export default class MiniPipelineGraph { * All dropdown events are fired at the .dropdown-menu's parent element. */ bindEvents() { - $(document).off('shown.bs.dropdown', this.container).on('shown.bs.dropdown', this.container, this.getBuildsList); + $(document) + .off('shown.bs.dropdown', this.container) + .on('shown.bs.dropdown', this.container, this.getBuildsList); } /** @@ -91,6 +93,9 @@ export default class MiniPipelineGraph { }, error: () => { this.toggleLoading(button); + if ($(button).parent().hasClass('open')) { + $(button).dropdown('toggle'); + } new Flash('An error occurred while fetching the builds.', 'alert'); }, }); diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js new file mode 100644 index 00000000000..c3a8da52404 --- /dev/null +++ b/app/assets/javascripts/monitoring/constants.js @@ -0,0 +1,4 @@ +import d3 from 'd3'; + +export const dateFormat = d3.time.format('%b %d, %Y'); +export const timeFormat = d3.time.format('%H:%M%p'); diff --git a/app/assets/javascripts/monitoring/deployments.js b/app/assets/javascripts/monitoring/deployments.js new file mode 100644 index 00000000000..fc92ab61b31 --- /dev/null +++ b/app/assets/javascripts/monitoring/deployments.js @@ -0,0 +1,211 @@ +/* global Flash */ +import d3 from 'd3'; +import { + dateFormat, + timeFormat, +} from './constants'; + +export default class Deployments { + constructor(width, height) { + this.width = width; + this.height = height; + + this.endpoint = document.getElementById('js-metrics').dataset.deploymentEndpoint; + + this.createGradientDef(); + } + + init(chartData) { + this.chartData = chartData; + + this.x = d3.time.scale().range([0, this.width]); + this.x.domain(d3.extent(this.chartData, d => d.time)); + + this.charts = d3.selectAll('.prometheus-graph'); + + this.getData(); + } + + getData() { + $.ajax({ + url: this.endpoint, + dataType: 'JSON', + }) + .fail(() => new Flash('Error getting deployment information.')) + .done((data) => { + this.data = data.deployments.reduce((deploymentDataArray, deployment) => { + const time = new Date(deployment.created_at); + const xPos = Math.floor(this.x(time)); + + time.setSeconds(this.chartData[0].time.getSeconds()); + + if (xPos >= 0) { + deploymentDataArray.push({ + id: deployment.id, + time, + sha: deployment.sha, + tag: deployment.tag, + ref: deployment.ref.name, + xPos, + }); + } + + return deploymentDataArray; + }, []); + + this.plotData(); + }); + } + + plotData() { + this.charts.each((d, i) => { + const svg = d3.select(this.charts[0][i]); + const chart = svg.select('.graph-container'); + const key = svg.node().getAttribute('graph-type'); + + this.createLine(chart, key); + this.createDeployInfoBox(chart, key); + }); + } + + createGradientDef() { + const defs = d3.select('body') + .append('svg') + .attr({ + height: 0, + width: 0, + }) + .append('defs'); + + defs.append('linearGradient') + .attr({ + id: 'shadow-gradient', + }) + .append('stop') + .attr({ + offset: '0%', + 'stop-color': '#000', + 'stop-opacity': 0.4, + }) + .select(this.selectParentNode) + .append('stop') + .attr({ + offset: '100%', + 'stop-color': '#000', + 'stop-opacity': 0, + }); + } + + createLine(chart, key) { + chart.append('g') + .attr({ + class: 'deploy-info', + }) + .selectAll('.deploy-info') + .data(this.data) + .enter() + .append('g') + .attr({ + class: d => `deploy-info-${d.id}-${key}`, + transform: d => `translate(${Math.floor(d.xPos) + 1}, 0)`, + }) + .append('rect') + .attr({ + x: 1, + y: 0, + height: this.height + 1, + width: 3, + fill: 'url(#shadow-gradient)', + }) + .select(this.selectParentNode) + .append('line') + .attr({ + class: 'deployment-line', + x1: 0, + x2: 0, + y1: 0, + y2: this.height + 1, + }); + } + + createDeployInfoBox(chart, key) { + chart.selectAll('.deploy-info') + .selectAll('.js-deploy-info-box') + .data(this.data) + .enter() + .select(d => document.querySelector(`.deploy-info-${d.id}-${key}`)) + .append('svg') + .attr({ + class: 'js-deploy-info-box hidden', + x: 3, + y: 0, + width: 92, + height: 60, + }) + .append('rect') + .attr({ + class: 'rect-text-metric deploy-info-rect rect-metric', + x: 1, + y: 1, + rx: 2, + width: 90, + height: 58, + }) + .select(this.selectParentNode) + .append('g') + .attr({ + transform: 'translate(5, 2)', + }) + .append('text') + .attr({ + class: 'deploy-info-text text-metric-bold', + }) + .text(Deployments.refText) + .select(this.selectParentNode) + .append('text') + .attr({ + class: 'deploy-info-text', + y: 18, + }) + .text(d => dateFormat(d.time)) + .select(this.selectParentNode) + .append('text') + .attr({ + class: 'deploy-info-text text-metric-bold', + y: 38, + }) + .text(d => timeFormat(d.time)); + } + + static toggleDeployTextbox(deploy, key, showInfoBox) { + d3.selectAll(`.deploy-info-${deploy.id}-${key} .js-deploy-info-box`) + .classed('hidden', !showInfoBox); + } + + mouseOverDeployInfo(mouseXPos, key) { + if (!this.data) return false; + + let dataFound = false; + + this.data.forEach((d) => { + if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { + dataFound = d.xPos + 1; + + Deployments.toggleDeployTextbox(d, key, true); + } else { + Deployments.toggleDeployTextbox(d, key, false); + } + }); + + return dataFound; + } + + /* `this` is bound to the D3 node */ + selectParentNode() { + return this.parentNode; + } + + static refText(d) { + return d.tag ? d.ref : d.sha.slice(0, 6); + } +} diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index 844a0785bc9..6af88769129 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -1,101 +1,146 @@ -/* eslint-disable no-new*/ +/* eslint-disable no-new */ /* global Flash */ import d3 from 'd3'; import statusCodes from '~/lib/utils/http_status'; +import Deployments from './deployments'; import '../lib/utils/common_utils'; +import { formatRelevantDigits } from '../lib/utils/number_utils'; import '../flash'; +import { + dateFormat, + timeFormat, +} from './constants'; +const prometheusContainer = '.prometheus-container'; +const prometheusParentGraphContainer = '.prometheus-graphs'; const prometheusGraphsContainer = '.prometheus-graph'; +const prometheusStatesContainer = '.prometheus-state'; const metricsEndpoint = 'metrics.json'; -const timeFormat = d3.time.format('%H:%M'); -const dayFormat = d3.time.format('%b %e, %a'); const bisectDate = d3.bisector(d => d.time).left; const extraAddedWidthParent = 100; class PrometheusGraph { - constructor() { - this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; - this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; - const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + - extraAddedWidthParent; - this.originalWidth = parentContainerWidth; - this.originalHeight = 400; - this.width = parentContainerWidth - this.margin.left - this.margin.right; - this.height = 400 - this.margin.top - this.margin.bottom; - this.backOffRequestCounter = 0; - this.configureGraph(); - this.init(); + const $prometheusContainer = $(prometheusContainer); + const hasMetrics = $prometheusContainer.data('has-metrics'); + this.docLink = $prometheusContainer.data('doc-link'); + this.integrationLink = $prometheusContainer.data('prometheus-integration'); + this.state = ''; + + $(document).ajaxError(() => {}); + + if (hasMetrics) { + this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; + this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; + const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + + extraAddedWidthParent; + this.originalWidth = parentContainerWidth; + this.originalHeight = 330; + this.width = parentContainerWidth - this.margin.left - this.margin.right; + this.height = this.originalHeight - this.margin.top - this.margin.bottom; + this.backOffRequestCounter = 0; + this.deployments = new Deployments(this.width, this.height); + this.configureGraph(); + this.init(); + } else { + const prevState = this.state; + this.state = '.js-getting-started'; + this.updateState(prevState); + } } createGraph() { - Object.keys(this.data).forEach((key) => { - const value = this.data[key]; - if (value.length > 0) { - this.plotValues(value, key); + Object.keys(this.graphSpecificProperties).forEach((key) => { + const value = this.graphSpecificProperties[key]; + if (value.data.length > 0) { + this.plotValues(key); } }); } init() { - this.getData().then((metricsResponse) => { - if (Object.keys(metricsResponse).length === 0) { - new Flash('Empty metrics', 'alert'); + return this.getData().then((metricsResponse) => { + let enoughData = true; + if (typeof metricsResponse === 'undefined') { + enoughData = false; } else { + Object.keys(metricsResponse.metrics).forEach((key) => { + if (key === 'cpu_values' || key === 'memory_values') { + const currentData = (metricsResponse.metrics[key])[0]; + if (currentData.values.length <= 2) { + enoughData = false; + } + } + }); + } + if (enoughData) { + $(prometheusStatesContainer).hide(); + $(prometheusParentGraphContainer).show(); this.transformData(metricsResponse); this.createGraph(); + + const firstMetricData = this.graphSpecificProperties[ + Object.keys(this.graphSpecificProperties)[0] + ].data; + + this.deployments.init(firstMetricData); } }); } - plotValues(valuesToPlot, key) { + plotValues(key) { + const graphSpecifics = this.graphSpecificProperties[key]; + const x = d3.time.scale() .range([0, this.width]); const y = d3.scale.linear() .range([this.height, 0]); - const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; + graphSpecifics.xScale = x; + graphSpecifics.yScale = y; - const graphSpecifics = this.graphSpecificProperties[key]; + const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; const chart = d3.select(prometheusGraphContainer) - .attr('width', this.width + this.margin.left + this.margin.right) - .attr('height', this.height + this.margin.bottom + this.margin.top) - .append('g') - .attr('transform', `translate(${this.margin.left},${this.margin.top})`); + .attr('width', this.width + this.margin.left + this.margin.right) + .attr('height', this.height + this.margin.bottom + this.margin.top) + .append('g') + .attr('class', 'graph-container') + .attr('transform', `translate(${this.margin.left},${this.margin.top})`); const axisLabelContainer = d3.select(prometheusGraphContainer) - .attr('width', this.originalWidth + this.marginLabelContainer.left + this.marginLabelContainer.right) - .attr('height', this.originalHeight + this.marginLabelContainer.bottom + this.marginLabelContainer.top) + .attr('width', this.originalWidth) + .attr('height', this.originalHeight) .append('g') .attr('transform', `translate(${this.marginLabelContainer.left},${this.marginLabelContainer.top})`); - x.domain(d3.extent(valuesToPlot, d => d.time)); - y.domain([0, d3.max(valuesToPlot.map(metricValue => metricValue.value))]); + x.domain(d3.extent(graphSpecifics.data, d => d.time)); + y.domain([0, d3.max(graphSpecifics.data.map(metricValue => metricValue.value))]); const xAxis = d3.svg.axis() - .scale(x) - .ticks(this.commonGraphProperties.axis_no_ticks) - .orient('bottom'); + .scale(x) + .ticks(this.commonGraphProperties.axis_no_ticks) + .orient('bottom'); const yAxis = d3.svg.axis() - .scale(y) - .ticks(this.commonGraphProperties.axis_no_ticks) - .tickSize(-this.width) - .orient('left'); + .scale(y) + .ticks(this.commonGraphProperties.axis_no_ticks) + .tickSize(-this.width) + .outerTickSize(0) + .orient('left'); this.createAxisLabelContainers(axisLabelContainer, key); chart.append('g') - .attr('class', 'x-axis') - .attr('transform', `translate(0,${this.height})`) - .call(xAxis); + .attr('class', 'x-axis') + .attr('transform', `translate(0,${this.height})`) + .call(xAxis); chart.append('g') - .attr('class', 'y-axis') - .call(yAxis); + .attr('class', 'y-axis') + .call(yAxis); const area = d3.svg.area() .x(d => x(d.time)) @@ -108,13 +153,13 @@ class PrometheusGraph { .y(d => y(d.value)); chart.append('path') - .datum(valuesToPlot) - .attr('d', area) - .attr('class', 'metric-area') - .attr('fill', graphSpecifics.area_fill_color); + .datum(graphSpecifics.data) + .attr('d', area) + .attr('class', 'metric-area') + .attr('fill', graphSpecifics.area_fill_color); chart.append('path') - .datum(valuesToPlot) + .datum(graphSpecifics.data) .attr('class', 'metric-line') .attr('stroke', graphSpecifics.line_color) .attr('fill', 'none') @@ -126,7 +171,7 @@ class PrometheusGraph { .attr('class', 'prometheus-graph-overlay') .attr('width', this.width) .attr('height', this.height) - .on('mousemove', this.handleMouseOverGraph.bind(this, x, y, valuesToPlot, chart, prometheusGraphContainer, key)); + .on('mousemove', this.handleMouseOverGraph.bind(this, prometheusGraphContainer)); } // The legends from the metric @@ -134,128 +179,162 @@ class PrometheusGraph { const graphSpecifics = this.graphSpecificProperties[key]; axisLabelContainer.append('line') - .attr('class', 'label-x-axis-line') - .attr('stroke', '#000000') - .attr('stroke-width', '1') - .attr({ - x1: 0, - y1: this.originalHeight - this.marginLabelContainer.top, - x2: this.originalWidth - this.margin.right, - y2: this.originalHeight - this.marginLabelContainer.top, - }); + .attr('class', 'label-x-axis-line') + .attr('stroke', '#000000') + .attr('stroke-width', '1') + .attr({ + x1: 10, + y1: this.originalHeight - this.margin.top, + x2: (this.originalWidth - this.margin.right) + 10, + y2: this.originalHeight - this.margin.top, + }); axisLabelContainer.append('line') - .attr('class', 'label-y-axis-line') - .attr('stroke', '#000000') - .attr('stroke-width', '1') - .attr({ - x1: 0, - y1: 0, - x2: 0, - y2: this.originalHeight - this.marginLabelContainer.top, - }); + .attr('class', 'label-y-axis-line') + .attr('stroke', '#000000') + .attr('stroke-width', '1') + .attr({ + x1: 10, + y1: 0, + x2: 10, + y2: this.originalHeight - this.margin.top, + }); + + axisLabelContainer.append('rect') + .attr('class', 'rect-axis-text') + .attr('x', 0) + .attr('y', 50) + .attr('width', 30) + .attr('height', 150); axisLabelContainer.append('text') - .attr('class', 'label-axis-text') - .attr('text-anchor', 'middle') - .attr('transform', `translate(15, ${(this.originalHeight - this.marginLabelContainer.top) / 2}) rotate(-90)`) - .text(graphSpecifics.graph_legend_title); + .attr('class', 'label-axis-text') + .attr('text-anchor', 'middle') + .attr('transform', `translate(15, ${(this.originalHeight - this.margin.top) / 2}) rotate(-90)`) + .text(graphSpecifics.graph_legend_title); axisLabelContainer.append('rect') - .attr('class', 'rect-axis-text') - .attr('x', (this.originalWidth / 2) - this.margin.right) - .attr('y', this.originalHeight - this.marginLabelContainer.top - 20) - .attr('width', 30) - .attr('height', 80); + .attr('class', 'rect-axis-text') + .attr('x', (this.originalWidth / 2) - this.margin.right) + .attr('y', this.originalHeight - 100) + .attr('width', 30) + .attr('height', 80); axisLabelContainer.append('text') - .attr('class', 'label-axis-text') - .attr('x', (this.originalWidth / 2) - this.margin.right) - .attr('y', this.originalHeight - this.marginLabelContainer.top) - .attr('dy', '.35em') - .text('Time'); + .attr('class', 'label-axis-text') + .attr('x', (this.originalWidth / 2) - this.margin.right) + .attr('y', this.originalHeight - this.margin.top) + .attr('dy', '.35em') + .text('Time'); // Legends // Metric Usage axisLabelContainer.append('rect') - .attr('x', this.originalWidth - 170) - .attr('y', (this.originalHeight / 2) - 60) - .style('fill', graphSpecifics.area_fill_color) - .attr('width', 20) - .attr('height', 35); + .attr('x', this.originalWidth - 170) + .attr('y', (this.originalHeight / 2) - 60) + .style('fill', graphSpecifics.area_fill_color) + .attr('width', 20) + .attr('height', 35); axisLabelContainer.append('text') - .attr('class', 'label-axis-text') - .attr('x', this.originalWidth - 140) - .attr('y', (this.originalHeight / 2) - 50) - .text('Average'); + .attr('class', 'text-metric-title') + .attr('x', this.originalWidth - 140) + .attr('y', (this.originalHeight / 2) - 50) + .text('Average'); axisLabelContainer.append('text') - .attr('class', 'text-metric-usage') - .attr('x', this.originalWidth - 140) - .attr('y', (this.originalHeight / 2) - 25); + .attr('class', 'text-metric-usage') + .attr('x', this.originalWidth - 140) + .attr('y', (this.originalHeight / 2) - 25); } - handleMouseOverGraph(x, y, valuesToPlot, chart, prometheusGraphContainer, key) { + handleMouseOverGraph(prometheusGraphContainer) { const rectOverlay = document.querySelector(`${prometheusGraphContainer} .prometheus-graph-overlay`); - const timeValueFromOverlay = x.invert(d3.mouse(rectOverlay)[0]); - const timeValueIndex = bisectDate(valuesToPlot, timeValueFromOverlay, 1); - const d0 = valuesToPlot[timeValueIndex - 1]; - const d1 = valuesToPlot[timeValueIndex]; - const currentData = timeValueFromOverlay - d0.time > d1.time - timeValueFromOverlay ? d1 : d0; - const maxValueMetric = y(d3.max(valuesToPlot.map(metricValue => metricValue.value))); - const currentTimeCoordinate = x(currentData.time); - const graphSpecifics = this.graphSpecificProperties[key]; - // Remove the current selectors - d3.selectAll(`${prometheusGraphContainer} .selected-metric-line`).remove(); - d3.selectAll(`${prometheusGraphContainer} .circle-metric`).remove(); - d3.selectAll(`${prometheusGraphContainer} .rect-text-metric`).remove(); - d3.selectAll(`${prometheusGraphContainer} .text-metric`).remove(); - - chart.append('line') - .attr('class', 'selected-metric-line') - .attr({ - x1: currentTimeCoordinate, - y1: y(0), - x2: currentTimeCoordinate, - y2: maxValueMetric, - }); + const currentXCoordinate = d3.mouse(rectOverlay)[0]; + + Object.keys(this.graphSpecificProperties).forEach((key) => { + const currentGraphProps = this.graphSpecificProperties[key]; + const timeValueOverlay = currentGraphProps.xScale.invert(currentXCoordinate); + const overlayIndex = bisectDate(currentGraphProps.data, timeValueOverlay, 1); + const d0 = currentGraphProps.data[overlayIndex - 1]; + const d1 = currentGraphProps.data[overlayIndex]; + const evalTime = timeValueOverlay - d0.time > d1.time - timeValueOverlay; + const currentData = evalTime ? d1 : d0; + const currentTimeCoordinate = Math.floor(currentGraphProps.xScale(currentData.time)); + const currentDeployXPos = this.deployments.mouseOverDeployInfo(currentXCoordinate, key); + const currentPrometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; + const maxValueFromData = d3.max(currentGraphProps.data.map(metricValue => metricValue.value)); + const maxMetricValue = currentGraphProps.yScale(maxValueFromData); + + // Clear up all the pieces of the flag + d3.selectAll(`${currentPrometheusGraphContainer} .selected-metric-line`).remove(); + d3.selectAll(`${currentPrometheusGraphContainer} .circle-metric`).remove(); + d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric:not(.deploy-info-rect)`).remove(); + + const currentChart = d3.select(currentPrometheusGraphContainer).select('g'); + currentChart.append('line') + .attr({ + class: `${currentDeployXPos ? 'hidden' : ''} selected-metric-line`, + x1: currentTimeCoordinate, + y1: currentGraphProps.yScale(0), + x2: currentTimeCoordinate, + y2: maxMetricValue, + }); + + currentChart.append('circle') + .attr('class', 'circle-metric') + .attr('fill', currentGraphProps.line_color) + .attr('cx', currentDeployXPos || currentTimeCoordinate) + .attr('cy', currentGraphProps.yScale(currentData.value)) + .attr('r', this.commonGraphProperties.circle_radius_metric); + + if (currentDeployXPos) return; + + // The little box with text + const rectTextMetric = currentChart.append('svg') + .attr({ + class: 'rect-text-metric', + x: currentTimeCoordinate, + y: 0, + }); + + rectTextMetric.append('rect') + .attr({ + class: 'rect-metric', + x: 4, + y: 1, + rx: 2, + width: this.commonGraphProperties.rect_text_width, + height: this.commonGraphProperties.rect_text_height, + }); + + rectTextMetric.append('text') + .attr({ + class: 'text-metric text-metric-bold', + x: 8, + y: 35, + }) + .text(timeFormat(currentData.time)); - chart.append('circle') - .attr('class', 'circle-metric') - .attr('fill', graphSpecifics.line_color) - .attr('cx', currentTimeCoordinate) - .attr('cy', y(currentData.value)) - .attr('r', this.commonGraphProperties.circle_radius_metric); - - // The little box with text - const rectTextMetric = chart.append('g') - .attr('class', 'rect-text-metric') - .attr('translate', `(${currentTimeCoordinate}, ${y(currentData.value)})`); - - rectTextMetric.append('rect') - .attr('class', 'rect-metric') - .attr('x', currentTimeCoordinate + 10) - .attr('y', maxValueMetric) - .attr('width', this.commonGraphProperties.rect_text_width) - .attr('height', this.commonGraphProperties.rect_text_height); - - rectTextMetric.append('text') - .attr('class', 'text-metric') - .attr('x', currentTimeCoordinate + 35) - .attr('y', maxValueMetric + 35) - .text(timeFormat(currentData.time)); - - rectTextMetric.append('text') - .attr('class', 'text-metric-date') - .attr('x', currentTimeCoordinate + 15) - .attr('y', maxValueMetric + 15) - .text(dayFormat(currentData.time)); - - // Update the text - d3.select(`${prometheusGraphContainer} .text-metric-usage`) - .text(currentData.value.substring(0, 8)); + rectTextMetric.append('text') + .attr({ + class: 'text-metric-date', + x: 8, + y: 15, + }) + .text(dateFormat(currentData.time)); + + let currentMetricValue = formatRelevantDigits(currentData.value); + if (key === 'cpu_values') { + currentMetricValue = `${currentMetricValue}%`; + } else { + currentMetricValue = `${currentMetricValue} MB`; + } + + d3.select(`${currentPrometheusGraphContainer} .text-metric-usage`) + .text(currentMetricValue); + }); } configureGraph() { @@ -263,12 +342,18 @@ class PrometheusGraph { cpu_values: { area_fill_color: '#edf3fc', line_color: '#5b99f7', - graph_legend_title: 'CPU utilization (%)', + graph_legend_title: 'CPU Usage (Cores)', + data: [], + xScale: {}, + yScale: {}, }, memory_values: { area_fill_color: '#fca326', line_color: '#fc6d26', - graph_legend_title: 'Memory usage (MB)', + graph_legend_title: 'Memory Usage (MB)', + data: [], + xScale: {}, + yScale: {}, }, }; @@ -284,6 +369,8 @@ class PrometheusGraph { getData() { const maxNumberOfRequests = 3; + this.state = '.js-loading'; + this.updateState(); return gl.utils.backOff((next, stop) => { $.ajax({ url: metricsEndpoint, @@ -294,12 +381,11 @@ class PrometheusGraph { this.backOffRequestCounter = this.backOffRequestCounter += 1; if (this.backOffRequestCounter < maxNumberOfRequests) { next(); - } else { - stop({ - status: resp.status, - metrics: data, - }); + } else if (this.backOffRequestCounter >= maxNumberOfRequests) { + stop(new Error('loading')); } + } else if (!data.success) { + stop(new Error('loading')); } else { stop({ status: resp.status, @@ -314,21 +400,33 @@ class PrometheusGraph { } return resp.metrics; }) - .catch(() => new Flash('An error occurred while fetching metrics.', 'alert')); + .catch(() => { + const prevState = this.state; + this.state = '.js-unable-to-connect'; + this.updateState(prevState); + }); } transformData(metricsResponse) { - const metricTypes = {}; Object.keys(metricsResponse.metrics).forEach((key) => { if (key === 'cpu_values' || key === 'memory_values') { const metricValues = (metricsResponse.metrics[key])[0]; - metricTypes[key] = metricValues.values.map(metric => ({ + this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ time: new Date(metric[0] * 1000), value: metric[1], })); } }); - this.data = metricTypes; + } + + updateState(prevState) { + const $statesContainer = $(prometheusStatesContainer); + $(prometheusParentGraphContainer).hide(); + if (prevState) { + $(`${prevState}`, $statesContainer).addClass('hidden'); + } + $(`${this.state}`, $statesContainer).removeClass('hidden'); + $(prometheusStatesContainer).show(); } } diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js index b98e6121967..5da2db063a4 100644 --- a/app/assets/javascripts/namespace_select.js +++ b/app/assets/javascripts/namespace_select.js @@ -1,12 +1,10 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, vars-on-top, one-var-declaration-per-line, comma-dangle, object-shorthand, no-else-return, prefer-template, quotes, prefer-arrow-callback, no-param-reassign, no-cond-assign, max-len */ -/* global Api */ +import Api from './api'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - window.NamespaceSelect = (function() { function NamespaceSelect(opts) { - this.onSelectItem = bind(this.onSelectItem, this); + this.onSelectItem = this.onSelectItem.bind(this); var fieldName, showAny; this.dropdown = opts.dropdown; showAny = true; @@ -58,7 +56,8 @@ }); } - NamespaceSelect.prototype.onSelectItem = function(item, el, e) { + NamespaceSelect.prototype.onSelectItem = function(options) { + const { e } = options; return e.preventDefault(); }; diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 5828f460a23..39fb302b644 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,15 +1,14 @@ /* eslint-disable func-names, space-before-function-paren, no-var, one-var, prefer-rest-params, max-len, vars-on-top, wrap-iife, consistent-return, comma-dangle, one-var-declaration-per-line, quotes, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, max-len, object-shorthand */ -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; +import RefSelectDropdown from '~/ref_select_dropdown'; +(function() { this.NewBranchForm = (function() { function NewBranchForm(form, availableRefs) { - this.validate = bind(this.validate, this); + this.validate = this.validate.bind(this); this.branchNameError = form.find('.js-branch-name-error'); this.name = form.find('.js-branch-name'); this.ref = form.find('#ref'); - this.setupAvailableRefs(availableRefs); + new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new this.setupRestrictions(); this.addBinding(); this.init(); @@ -25,33 +24,6 @@ } }; - NewBranchForm.prototype.setupAvailableRefs = function(availableRefs) { - var $branchSelect = $('.js-branch-select'); - - $branchSelect.glDropdown({ - data: availableRefs, - filterable: true, - filterByText: true, - remote: false, - fieldName: $branchSelect.data('field-name'), - selectable: true, - isSelectable: function(branch, $el) { - return !$el.hasClass('is-active'); - }, - text: function(branch) { - return branch; - }, - id: function(branch) { - return branch; - }, - toggleLabel: function(branch) { - if (branch) { - return branch; - } - } - }); - }; - NewBranchForm.prototype.setupRestrictions = function() { var endsWith, invalid, single, startsWith; startsWith = { @@ -79,6 +51,8 @@ NewBranchForm.prototype.validate = function() { var errorMessage, errors, formatter, unique, validator; + const indexOf = [].indexOf; + this.branchNameError.empty(); unique = function(values, value) { if (indexOf.call(values, value) === -1) { diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index ad36f08840d..658879607e2 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,12 +1,10 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-return-assign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.NewCommitForm = (function() { function NewCommitForm(form, targetBranchName = 'target_branch') { this.form = form; this.targetBranchName = targetBranchName; - this.renderDestination = bind(this.renderDestination, this); + this.renderDestination = this.renderDestination.bind(this); this.targetBranchDropdown = form.find('button.js-target-branch'); this.originalBranch = form.find('.js-original-branch'); this.createMergeRequest = form.find('.js-create-merge-request'); diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue new file mode 100644 index 00000000000..b8a16356576 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/code.vue @@ -0,0 +1,58 @@ +<template> + <div class="cell"> + <code-cell + type="input" + :raw-code="rawInputCode" + :count="cell.execution_count" + :code-css-class="codeCssClass" /> + <output-cell + v-if="hasOutput" + :count="cell.execution_count" + :output="output" + :code-css-class="codeCssClass" /> + </div> +</template> + +<script> +import CodeCell from './code/index.vue'; +import OutputCell from './output/index.vue'; + +export default { + components: { + 'code-cell': CodeCell, + 'output-cell': OutputCell, + }, + props: { + cell: { + type: Object, + required: true, + }, + codeCssClass: { + type: String, + required: false, + default: '', + }, + }, + computed: { + rawInputCode() { + if (this.cell.source) { + return this.cell.source.join(''); + } + + return ''; + }, + hasOutput() { + return this.cell.outputs.length; + }, + output() { + return this.cell.outputs[0]; + }, + }, +}; +</script> + +<style scoped> +.cell { + flex-direction: column; +} +</style> diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue new file mode 100644 index 00000000000..31b30f601e2 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/code/index.vue @@ -0,0 +1,57 @@ +<template> + <div :class="type"> + <prompt + :type="promptType" + :count="count" /> + <pre + class="language-python" + :class="codeCssClass" + ref="code" + v-text="code"> + </pre> + </div> +</template> + +<script> + import Prism from '../../lib/highlight'; + import Prompt from '../prompt.vue'; + + export default { + components: { + prompt: Prompt, + }, + props: { + count: { + type: Number, + required: false, + default: 0, + }, + codeCssClass: { + type: String, + required: false, + default: '', + }, + type: { + type: String, + required: true, + }, + rawCode: { + type: String, + required: true, + }, + }, + computed: { + code() { + return this.rawCode; + }, + promptType() { + const type = this.type.split('put')[0]; + + return type.charAt(0).toUpperCase() + type.slice(1); + }, + }, + mounted() { + Prism.highlightElement(this.$refs.code); + }, + }; +</script> diff --git a/app/assets/javascripts/notebook/cells/index.js b/app/assets/javascripts/notebook/cells/index.js new file mode 100644 index 00000000000..e4c255609fe --- /dev/null +++ b/app/assets/javascripts/notebook/cells/index.js @@ -0,0 +1,2 @@ +export { default as MarkdownCell } from './markdown.vue'; +export { default as CodeCell } from './code.vue'; diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue new file mode 100644 index 00000000000..3e8240d10ec --- /dev/null +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -0,0 +1,98 @@ +<template> + <div class="cell text-cell"> + <prompt /> + <div class="markdown" v-html="markdown"></div> + </div> +</template> + +<script> + /* global katex */ + import marked from 'marked'; + import Prompt from './prompt.vue'; + + const renderer = new marked.Renderer(); + + /* + Regex to match KaTex blocks. + + Supports the following: + + \begin{equation}<math>\end{equation} + $$<math>$$ + inline $<math>$ + + The matched text then goes through the KaTex renderer & then outputs the HTML + */ + const katexRegexString = `( + ^\\\\begin{[a-zA-Z]+}\\s + | + ^\\$\\$ + | + \\s\\$(?!\\$) + ) + (.+?) + ( + \\s\\\\end{[a-zA-Z]+}$ + | + \\$\\$$ + | + \\$ + ) + `.replace(/\s/g, '').trim(); + + renderer.paragraph = (t) => { + let text = t; + let inline = false; + + if (typeof katex !== 'undefined') { + const katexString = text.replace(/\\/g, '\\'); + const matches = new RegExp(katexRegexString, 'gi').exec(katexString); + + if (matches && matches.length > 0) { + if (matches[1].trim() === '$' && matches[3].trim() === '$') { + inline = true; + + text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`; + } else { + text = katex.renderToString(matches[2]); + } + } + } + + return `<p class="${inline ? 'inline-katex' : ''}">${text}</p>`; + }; + + marked.setOptions({ + sanitize: true, + renderer, + }); + + export default { + components: { + prompt: Prompt, + }, + props: { + cell: { + type: Object, + required: true, + }, + }, + computed: { + markdown() { + return marked(this.cell.source.join('')); + }, + }, + }; +</script> + +<style> +.markdown .katex { + display: block; + text-align: center; +} + +.markdown .inline-katex .katex { + display: inline; + text-align: initial; +} +</style> diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue new file mode 100644 index 00000000000..0f39cd138df --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -0,0 +1,22 @@ +<template> + <div class="output"> + <prompt /> + <div v-html="rawCode"></div> + </div> +</template> + +<script> +import Prompt from '../prompt.vue'; + +export default { + props: { + rawCode: { + type: String, + required: true, + }, + }, + components: { + prompt: Prompt, + }, +}; +</script> diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue new file mode 100644 index 00000000000..f3b873bbc0f --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/image.vue @@ -0,0 +1,27 @@ +<template> + <div class="output"> + <prompt /> + <img + :src="'data:' + outputType + ';base64,' + rawCode" /> + </div> +</template> + +<script> +import Prompt from '../prompt.vue'; + +export default { + props: { + outputType: { + type: String, + required: true, + }, + rawCode: { + type: String, + required: true, + }, + }, + components: { + prompt: Prompt, + }, +}; +</script> diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue new file mode 100644 index 00000000000..23c9ea78939 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/output/index.vue @@ -0,0 +1,83 @@ +<template> + <component :is="componentName" + type="output" + :outputType="outputType" + :count="count" + :raw-code="rawCode" + :code-css-class="codeCssClass" /> +</template> + +<script> +import CodeCell from '../code/index.vue'; +import Html from './html.vue'; +import Image from './image.vue'; + +export default { + props: { + codeCssClass: { + type: String, + required: false, + default: '', + }, + count: { + type: Number, + required: false, + default: 0, + }, + output: { + type: Object, + requred: true, + }, + }, + components: { + 'code-cell': CodeCell, + 'html-output': Html, + 'image-output': Image, + }, + data() { + return { + outputType: '', + }; + }, + computed: { + componentName() { + if (this.output.text) { + return 'code-cell'; + } else if (this.output.data['image/png']) { + this.outputType = 'image/png'; + + return 'image-output'; + } else if (this.output.data['text/html']) { + this.outputType = 'text/html'; + + return 'html-output'; + } else if (this.output.data['image/svg+xml']) { + this.outputType = 'image/svg+xml'; + + return 'html-output'; + } + + this.outputType = 'text/plain'; + return 'code-cell'; + }, + rawCode() { + if (this.output.text) { + return this.output.text.join(''); + } + + return this.dataForType(this.outputType); + }, + }, + methods: { + dataForType(type) { + let data = this.output.data[type]; + + if (typeof data === 'object') { + data = data.join(''); + } + + return data; + }, + }, +}; +</script> diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue new file mode 100644 index 00000000000..4540e4248d8 --- /dev/null +++ b/app/assets/javascripts/notebook/cells/prompt.vue @@ -0,0 +1,30 @@ +<template> + <div class="prompt"> + <span v-if="type && count"> + {{ type }} [{{ count }}]: + </span> + </div> +</template> + +<script> + export default { + props: { + type: { + type: String, + required: false, + }, + count: { + type: Number, + required: false, + }, + }, + }; +</script> + +<style scoped> +.prompt { + padding: 0 10px; + min-width: 7em; + font-family: monospace; +} +</style> diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue new file mode 100644 index 00000000000..fd62c1231ef --- /dev/null +++ b/app/assets/javascripts/notebook/index.vue @@ -0,0 +1,75 @@ +<template> + <div v-if="hasNotebook"> + <component + v-for="(cell, index) in cells" + :is="cellType(cell.cell_type)" + :cell="cell" + :key="index" + :code-css-class="codeCssClass" /> + </div> +</template> + +<script> + import { + MarkdownCell, + CodeCell, + } from './cells'; + + export default { + components: { + 'code-cell': CodeCell, + 'markdown-cell': MarkdownCell, + }, + props: { + notebook: { + type: Object, + required: true, + }, + codeCssClass: { + type: String, + required: false, + default: '', + }, + }, + methods: { + cellType(type) { + return `${type}-cell`; + }, + }, + computed: { + cells() { + if (this.notebook.worksheets) { + const data = { + cells: [], + }; + + return this.notebook.worksheets.reduce((cellData, sheet) => { + const cellDataCopy = cellData; + cellDataCopy.cells = cellDataCopy.cells.concat(sheet.cells); + return cellDataCopy; + }, data).cells; + } + + return this.notebook.cells; + }, + hasNotebook() { + return Object.keys(this.notebook).length; + }, + }, + }; +</script> + +<style> +.cell, +.input, +.output { + display: flex; + width: 100%; + margin-bottom: 10px; +} + +.cell pre { + margin: 0; + width: 100%; +} +</style> diff --git a/app/assets/javascripts/notebook/lib/highlight.js b/app/assets/javascripts/notebook/lib/highlight.js new file mode 100644 index 00000000000..74ade6d2edf --- /dev/null +++ b/app/assets/javascripts/notebook/lib/highlight.js @@ -0,0 +1,22 @@ +import Prism from 'prismjs'; +import 'prismjs/components/prism-python'; +import 'prismjs/plugins/custom-class/prism-custom-class'; + +Prism.plugins.customClass.map({ + comment: 'c', + error: 'err', + operator: 'o', + constant: 'kc', + namespace: 'kn', + keyword: 'k', + string: 's', + number: 'm', + 'attr-name': 'na', + builtin: 'nb', + entity: 'ni', + function: 'nf', + tag: 'nt', + variable: 'nv', +}); + +export default Prism; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 1d563c63f39..a981b61f942 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -4,50 +4,64 @@ /* global ResolveService */ /* global mrRefreshWidgetUrl */ +import $ from 'jquery'; import Cookies from 'js-cookie'; - -require('./autosave'); -window.autosize = require('vendor/autosize'); -window.Dropzone = require('dropzone'); -require('./dropzone_input'); -require('./gfm_auto_complete'); -require('vendor/jquery.caret'); // required by jquery.atwho -require('vendor/jquery.atwho'); -require('./task_list'); +import autosize from 'vendor/autosize'; +import Dropzone from 'dropzone'; +import 'vendor/jquery.caret'; // required by jquery.atwho +import 'vendor/jquery.atwho'; +import CommentTypeToggle from './comment_type_toggle'; +import './autosave'; +import './dropzone_input'; +import './task_list'; + +window.autosize = autosize; +window.Dropzone = Dropzone; + +const normalizeNewlines = function(str) { + return str.replace(/\r\n/g, '\n'); +}; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Notes = (function() { const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + const REGEX_SLASH_COMMANDS = /^\/\w+.*$/gm; Notes.interval = null; - function Notes(notes_url, note_ids, last_fetched_at, view) { - this.updateTargetButtons = bind(this.updateTargetButtons, this); - this.updateCloseButton = bind(this.updateCloseButton, this); - this.visibilityChange = bind(this.visibilityChange, this); - this.cancelDiscussionForm = bind(this.cancelDiscussionForm, this); - this.addDiffNote = bind(this.addDiffNote, this); - this.setupDiscussionNoteForm = bind(this.setupDiscussionNoteForm, this); - this.replyToDiscussionNote = bind(this.replyToDiscussionNote, this); - this.removeNote = bind(this.removeNote, this); - this.cancelEdit = bind(this.cancelEdit, this); - this.updateNote = bind(this.updateNote, this); - this.addDiscussionNote = bind(this.addDiscussionNote, this); - this.addNoteError = bind(this.addNoteError, this); - this.addNote = bind(this.addNote, this); - this.resetMainTargetForm = bind(this.resetMainTargetForm, this); - this.refresh = bind(this.refresh, this); - this.keydownNoteText = bind(this.keydownNoteText, this); - this.toggleCommitList = bind(this.toggleCommitList, this); + function Notes(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { + this.updateTargetButtons = this.updateTargetButtons.bind(this); + this.updateComment = this.updateComment.bind(this); + this.visibilityChange = this.visibilityChange.bind(this); + this.cancelDiscussionForm = this.cancelDiscussionForm.bind(this); + this.onAddDiffNote = this.onAddDiffNote.bind(this); + this.setupDiscussionNoteForm = this.setupDiscussionNoteForm.bind(this); + this.onReplyToDiscussionNote = this.onReplyToDiscussionNote.bind(this); + this.removeNote = this.removeNote.bind(this); + this.cancelEdit = this.cancelEdit.bind(this); + this.updateNote = this.updateNote.bind(this); + this.addDiscussionNote = this.addDiscussionNote.bind(this); + this.addNoteError = this.addNoteError.bind(this); + this.addNote = this.addNote.bind(this); + this.resetMainTargetForm = this.resetMainTargetForm.bind(this); + this.refresh = this.refresh.bind(this); + this.keydownNoteText = this.keydownNoteText.bind(this); + this.toggleCommitList = this.toggleCommitList.bind(this); + this.postComment = this.postComment.bind(this); + this.clearFlashWrapper = this.clearFlash.bind(this); + this.notes_url = notes_url; this.note_ids = note_ids; + this.enableGFM = enableGFM; + // Used to keep track of updated notes while people are editing things + this.updatedNotesTrackingMap = {}; this.last_fetched_at = last_fetched_at; this.noteable_url = document.URL; this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge")); this.basePollingInterval = 15000; this.maxPollingSteps = 4; + this.flashErrors = []; + this.cleanBinding(); this.addBinding(); this.setPollingInterval(); @@ -72,36 +86,27 @@ require('./task_list'); }; Notes.prototype.addBinding = function() { - // add note to UI after creation - $(document).on("ajax:success", ".js-main-target-form", this.addNote); - $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); - // catch note ajax errors - $(document).on("ajax:error", ".js-main-target-form", this.addNoteError); - // change note in UI after update - $(document).on("ajax:success", "form.edit-note", this.updateNote); // Edit note link $(document).on("click", ".js-note-edit", this.showEditForm.bind(this)); $(document).on("click", ".note-edit-cancel", this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - $(document).on("click", ".js-comment-button", this.updateCloseButton); + $(document).on("click", ".js-comment-submit-button", this.postComment); + $(document).on("click", ".js-comment-save-button", this.updateComment); $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); // resolve a discussion - $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion); + $(document).on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) $(document).on("click", ".js-note-delete", this.removeNote); // delete note attachment $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); - // reset main target form after submit - $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); - $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); // reset main target form when clicking discard $(document).on("click", ".js-note-discard", this.resetMainTargetForm); // update the file name when an attachment is selected $(document).on("change", ".js-note-attachment-input", this.updateFormAttachment); // reply to diff/discussion notes - $(document).on("click", ".js-discussion-reply-button", this.replyToDiscussionNote); + $(document).on("click", ".js-discussion-reply-button", this.onReplyToDiscussionNote); // add diff note - $(document).on("click", ".js-add-diff-note-button", this.addDiffNote); + $(document).on("click", ".js-add-diff-note-button", this.onAddDiffNote); // hide diff note form $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm); // toggle commit list @@ -110,31 +115,53 @@ require('./task_list'); $(document).on("visibilitychange", this.visibilityChange); // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); - + // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. + $(document).on("ajax:success", ".js-main-target-form", this.addNote); + $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); + $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); + $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; Notes.prototype.cleanBinding = function() { - $(document).off("ajax:success", ".js-main-target-form"); - $(document).off("ajax:success", ".js-discussion-note-form"); - $(document).off("ajax:success", "form.edit-note"); $(document).off("click", ".js-note-edit"); $(document).off("click", ".note-edit-cancel"); $(document).off("click", ".js-note-delete"); $(document).off("click", ".js-note-attachment-delete"); - $(document).off("ajax:complete", ".js-main-target-form"); - $(document).off("ajax:success", ".js-main-target-form"); $(document).off("click", ".js-discussion-reply-button"); $(document).off("click", ".js-add-diff-note-button"); $(document).off("visibilitychange"); - $(document).off("keyup", ".js-note-text"); + $(document).off("keyup input", ".js-note-text"); $(document).off("click", ".js-note-target-reopen"); $(document).off("click", ".js-note-target-close"); $(document).off("click", ".js-note-discard"); $(document).off("keydown", ".js-note-text"); $(document).off('click', '.js-comment-resolve-button'); $(document).off("click", '.system-note-commit-list-toggler'); + $(document).off("ajax:success", ".js-main-target-form"); + $(document).off("ajax:success", ".js-discussion-note-form"); + $(document).off("ajax:complete", ".js-main-target-form"); + }; + + Notes.initCommentTypeToggle = function (form) { + const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); + const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); + const noteTypeInput = form.querySelector('#note_type'); + const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'); + const closeButton = form.querySelector('.js-note-target-close'); + const reopenButton = form.querySelector('.js-note-target-reopen'); + + const commentTypeToggle = new CommentTypeToggle({ + dropdownTrigger, + dropdownList, + noteTypeInput, + submitButton, + closeButton, + reopenButton, + }); + + commentTypeToggle.initDroplab(); }; Notes.prototype.keydownNoteText = function(e) { @@ -150,7 +177,7 @@ require('./task_list'); if ($textarea.val() !== '') { return; } - myLastNote = $("li.note[data-author-id='" + gon.current_user_id + "'][data-editable]:last"); + myLastNote = $(`li.note[data-author-id='${gon.current_user_id}'][data-editable]:last`, $textarea.closest('.note, #notes')); if (myLastNote.length) { myLastNoteEditBtn = myLastNote.find('.js-note-edit'); return myLastNoteEditBtn.trigger('click', [true, myLastNote]); @@ -192,7 +219,7 @@ require('./task_list'); }; Notes.prototype.refresh = function() { - if (!document.hidden && document.URL.indexOf(this.noteable_url) === 0) { + if (!document.hidden) { return this.getContent(); } }; @@ -213,11 +240,7 @@ require('./task_list'); _this.last_fetched_at = data.last_fetched_at; _this.setPollingInterval(data.notes.length); return $.each(notes, function(i, note) { - if (note.discussion_html != null) { - return _this.renderDiscussionNote(note); - } else { - return _this.renderNote(note); - } + _this.renderNote(note); }); }; })(this) @@ -251,60 +274,82 @@ require('./task_list'); return this.initRefresh(); }; - Notes.prototype.handleCreateChanges = function(note) { + Notes.prototype.handleSlashCommands = function(noteEntity) { var votesBlock; - if (typeof note === 'undefined') { - return; - } - - if (note.commands_changes) { - if ('merge' in note.commands_changes) { - $.get(mrRefreshWidgetUrl); + if (noteEntity.commands_changes) { + if ('merge' in noteEntity.commands_changes) { + Notes.checkMergeRequestStatus(); } - if ('emoji_award' in note.commands_changes) { + if ('emoji_award' in noteEntity.commands_changes) { votesBlock = $('.js-awards-block').eq(0); - gl.awardsHandler.addAwardToEmojiBar(votesBlock, note.commands_changes.emoji_award); + gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); return gl.awardsHandler.scrollToAwards(); } } }; + Notes.prototype.setupNewNote = function($note) { + // Update datetime format on the recent note + gl.utils.localTimeAgo($note.find('.js-timeago'), false); + this.collapseLongCommitList(); + this.taskList.init(); + }; + /* Render note in main comments area. Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderNote = function(note) { - var $notesList; - if (!note.valid) { - if (note.errors.commands_only) { - new Flash(note.errors.commands_only, 'notice', this.parentTimeline); + Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) { + if (noteEntity.discussion_html != null) { + return this.renderDiscussionNote(noteEntity, $form); + } + + if (!noteEntity.valid) { + if (noteEntity.errors.commands_only) { + this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); this.refresh(); } return; } - if (this.isNewNote(note)) { - this.note_ids.push(note.id); - $notesList = $('ul.main-notes-list'); - $notesList.append(note.html).syntaxHighlight(); - // Update datetime format on the recent note - gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); - this.collapseLongCommitList(); - this.taskList.init(); + const $note = $notesList.find(`#note_${noteEntity.id}`); + if (Notes.isNewNote(noteEntity, this.note_ids)) { + this.note_ids.push(noteEntity.id); + + const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); + + this.setupNewNote($newNote); this.refresh(); return this.updateNotesCount(1); } - }; - - /* - Check if note does not exists on page - */ - - Notes.prototype.isNewNote = function(note) { - return $.inArray(note.id, this.note_ids) === -1; + // The server can send the same update multiple times so we need to make sure to only update once per actual update. + else if (Notes.isUpdatedNote(noteEntity, $note)) { + const isEditing = $note.hasClass('is-editing'); + const initialContent = normalizeNewlines( + $note.find('.original-note-content').text().trim() + ); + const $textarea = $note.find('.js-note-text'); + const currentContent = $textarea.val(); + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteNote = normalizeNewlines(noteEntity.note); + const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote; + + if (isEditing && isTextareaUntouched) { + $textarea.val(noteEntity.note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; + } + else if (isEditing && !isTextareaUntouched) { + this.putConflictEditWarningInPlace(noteEntity, $note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; + } + else { + const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); + this.setupNewNote($updatedNote); + } + } }; Notes.prototype.isParallelView = function() { @@ -317,66 +362,56 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderDiscussionNote = function(note) { - var discussionContainer, form, note_html, row, lineType, diffAvatarContainer; - if (!this.isNewNote(note)) { + Notes.prototype.renderDiscussionNote = function(noteEntity, $form) { + var discussionContainer, form, row, lineType, diffAvatarContainer; + if (!Notes.isNewNote(noteEntity, this.note_ids)) { return; } - this.note_ids.push(note.id); - form = $("#new-discussion-note-form-" + note.discussion_id); - if ((note.original_discussion_id != null) && form.length === 0) { - form = $("#new-discussion-note-form-" + note.original_discussion_id); - } + this.note_ids.push(noteEntity.id); + form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']"); row = form.closest("tr"); lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); - note_html = $(note.html); - note_html.renderGFM(); // is this the first note of discussion? - discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); - if ((note.original_discussion_id != null) && discussionContainer.length === 0) { - discussionContainer = $(".notes[data-discussion-id='" + note.original_discussion_id + "']"); + discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); + if (!discussionContainer.length) { + discussionContainer = form.closest('.discussion').find('.notes'); } if (discussionContainer.length === 0) { - if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) { - // insert the note and the reply button after the temp row - row.after(note.diff_discussion_html); + if (noteEntity.diff_discussion_html) { + var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); - // remove the note (will be added again below) - row.next().find(".note").remove(); - } else { - // Merge new discussion HTML in - var $discussion = $(note.diff_discussion_html); - var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]'); - var contentContainerClass = '.' + $notes.closest('.notes_content') - .attr('class') - .split(' ') - .join('.'); - - // remove the note (will be added again below) - $notes.find('.note').remove(); - - row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); + if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) { + // insert the note and the reply button after the temp row + row.after($discussion); + } else { + // Merge new discussion HTML in + var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]'); + var contentContainerClass = '.' + $notes.closest('.notes_content') + .attr('class') + .split(' ') + .join('.'); + + row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); + } } - // Before that, the container didn't exist - discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); - // Add note to 'Changes' page discussions - discussionContainer.append(note_html); // Init discussion on 'Discussion' page if it is merge request page - if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) { - $('ul.main-notes-list').append(note.discussion_html).renderGFM(); + const page = $('body').attr('data-page'); + if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } } else { // append new note to all matching discussions - discussionContainer.append(note_html); + Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_id) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { gl.diffNotesCompileComponents(); - this.renderDiscussionAvatar(diffAvatarContainer, note); + this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); } gl.utils.localTimeAgo($('.js-timeago'), false); + Notes.checkMergeRequestStatus(); return this.updateNotesCount(1); }; @@ -387,13 +422,13 @@ require('./task_list'); .get(0); }; - Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, note) { + Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, noteEntity) { var commentButton = diffAvatarContainer.find('.js-add-diff-note-button'); var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); if (!avatarHolder.length) { avatarHolder = document.createElement('diff-note-avatars'); - avatarHolder.setAttribute('discussion-id', note.discussion_id); + avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id); diffAvatarContainer.append(avatarHolder); @@ -455,9 +490,14 @@ require('./task_list'); form.addClass("js-main-target-form"); form.find("#note_line_code").remove(); form.find("#note_position").remove(); - form.find("#note_type").remove(); + form.find("#note_type").val(''); + form.find("#in_reply_to_discussion_id").remove(); form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); - return this.parentTimeline = form.parents('.timeline'); + this.parentTimeline = form.parents('.timeline'); + + if (form.length) { + Notes.initCommentTypeToggle(form.get(0)); + } }; /* @@ -470,10 +510,24 @@ require('./task_list'); */ Notes.prototype.setupNoteForm = function(form) { - var textarea; - new gl.GLForm(form); + var textarea, key; + new gl.GLForm(form, this.enableGFM); textarea = form.find(".js-note-text"); - return new Autosave(textarea, ["Note", form.find("#note_noteable_type").val(), form.find("#note_noteable_id").val(), form.find("#note_commit_id").val(), form.find("#note_type").val(), form.find("#note_line_code").val(), form.find("#note_position").val()]); + key = [ + "Note", + form.find("#note_noteable_type").val(), + form.find("#note_noteable_id").val(), + form.find("#note_commit_id").val(), + form.find("#note_type").val(), + form.find("#in_reply_to_discussion_id").val(), + + // LegacyDiffNote + form.find("#note_line_code").val(), + + // DiffNote + form.find("#note_position").val() + ]; + return new Autosave(textarea, key); }; /* @@ -482,24 +536,29 @@ require('./task_list'); Adds new note to list. */ - Notes.prototype.addNote = function(xhr, note, status) { - this.handleCreateChanges(note); + Notes.prototype.addNote = function($form, note) { return this.renderNote(note); }; - Notes.prototype.addNoteError = function(xhr, note, status) { - return new Flash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', this.parentTimeline); + Notes.prototype.addNoteError = function($form) { + let formParentTimeline; + if ($form.hasClass('js-main-target-form')) { + formParentTimeline = $form.parents('.timeline'); + } else if ($form.hasClass('js-discussion-note-form')) { + formParentTimeline = $form.closest('.discussion-notes').find('.notes'); + } + return this.addFlash('Your comment could not be submitted! Please check your network connection and try again.', 'alert', formParentTimeline); }; + Notes.prototype.updateNoteError = $parentTimeline => new Flash('Your comment could not be updated! Please check your network connection and try again.'); + /* Called in response to the new note form being submitted Adds new note to list. */ - Notes.prototype.addDiscussionNote = function(xhr, note, status) { - var $form = $(xhr.target); - + Notes.prototype.addDiscussionNote = function($form, note, isNewDiffComment) { if ($form.attr('data-resolve-all') != null) { var projectPath = $form.data('project-path'); var discussionId = $form.data('discussion-id'); @@ -510,9 +569,11 @@ require('./task_list'); } } - this.renderDiscussionNote(note); + this.renderNote(note, $form); // cleanup after successfully creating a diff/discussion note - this.removeDiscussionNoteForm($form); + if (isNewDiffComment) { + this.removeDiscussionNoteForm($form); + } }; /* @@ -521,18 +582,19 @@ require('./task_list'); Updates the current note field. */ - Notes.prototype.updateNote = function(_xhr, note, _status) { - var $html, $note_li; + Notes.prototype.updateNote = function(noteEntity, $targetNote) { + var $noteEntityEl, $note_li; // Convert returned HTML to a jQuery object so we can modify it further - $html = $(note.html); - this.revertNoteEditForm(); - gl.utils.localTimeAgo($('.js-timeago', $html)); - $html.renderGFM(); - $html.find('.js-task-list-container').taskList('enable'); + $noteEntityEl = $(noteEntity.html); + $noteEntityEl.addClass('fade-in-full'); + this.revertNoteEditForm($targetNote); + gl.utils.localTimeAgo($('.js-timeago', $noteEntityEl)); + $noteEntityEl.renderGFM(); + $noteEntityEl.find('.js-task-list-container').taskList('enable'); // Find the note's `li` element by ID and replace it with the updated HTML - $note_li = $('.note-row-' + note.id); + $note_li = $('.note-row-' + noteEntity.id); - $note_li.replaceWith($html); + $note_li.replaceWith($noteEntityEl); if (typeof gl.diffNotesCompileComponents !== 'undefined') { gl.diffNotesCompileComponents(); @@ -541,7 +603,7 @@ require('./task_list'); Notes.prototype.checkContentToAllowEditing = function($el) { var initialContent = $el.find('.original-note-content').text().trim(); - var currentContent = $el.find('.note-textarea').val(); + var currentContent = $el.find('.js-note-text').val(); var isAllowed = true; if (currentContent === initialContent) { @@ -555,7 +617,7 @@ require('./task_list'); gl.utils.scrollToElement($el); } - $el.find('.js-edit-warning').show(); + $el.find('.js-finish-edit-warning').show(); isAllowed = false; } @@ -574,7 +636,7 @@ require('./task_list'); var $target = $(e.target); var $editForm = $(this.getEditFormSelector($target)); var $note = $target.closest('.note'); - var $currentlyEditing = $('.note.is-editting:visible'); + var $currentlyEditing = $('.note.is-editing:visible'); if ($currentlyEditing.length) { var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); @@ -586,7 +648,7 @@ require('./task_list'); $note.find('.js-note-attachment-delete').show(); $editForm.addClass('current-note-edit-form'); - $note.addClass('is-editting'); + $note.addClass('is-editing'); this.putEditFormInPlace($target); }; @@ -598,21 +660,32 @@ require('./task_list'); Notes.prototype.cancelEdit = function(e) { e.preventDefault(); - var $target = $(e.target); - var note = $target.closest('.note'); - note.find('.js-edit-warning').hide(); + const $target = $(e.target); + const $note = $target.closest('.note'); + const noteId = $note.attr('data-note-id'); + this.revertNoteEditForm($target); - return this.removeNoteEditForm(note); + + if (this.updatedNotesTrackingMap[noteId]) { + const $newNote = $(this.updatedNotesTrackingMap[noteId].html); + $note.replaceWith($newNote); + this.setupNewNote($newNote); + this.updatedNotesTrackingMap[noteId] = null; + } + else { + $note.find('.js-finish-edit-warning').hide(); + this.removeNoteEditForm($note); + } }; Notes.prototype.revertNoteEditForm = function($target) { - $target = $target || $('.note.is-editting:visible'); + $target = $target || $('.note.is-editing:visible'); var selector = this.getEditFormSelector($target); var $editForm = $(selector); $editForm.insertBefore('.notes-form'); - $editForm.find('.js-comment-button').enable(); - $editForm.find('.js-edit-warning').hide(); + $editForm.find('.js-comment-save-button').enable(); + $editForm.find('.js-finish-edit-warning').hide(); }; Notes.prototype.getEditFormSelector = function($el) { @@ -625,11 +698,11 @@ require('./task_list'); return selector; }; - Notes.prototype.removeNoteEditForm = function(note) { - var form = note.find('.current-note-edit-form'); - note.removeClass('is-editting'); + Notes.prototype.removeNoteEditForm = function($note) { + var form = $note.find('.current-note-edit-form'); + $note.removeClass('is-editing'); form.removeClass('current-note-edit-form'); - form.find('.js-edit-warning').hide(); + form.find('.js-finish-edit-warning').hide(); // Replace markdown textarea text with original note text. return form.find('.js-note-text').val(form.find('form.edit-note').data('original-note')); }; @@ -654,9 +727,9 @@ require('./task_list'); // to remove all. Using $(".note[id='noteId']") ensure we get all the notes, // where $("#noteId") would return only one. return function(i, el) { - var note, notes; - note = $(el); - notes = note.closest(".notes"); + var $note, $notes; + $note = $(el); + $notes = $note.closest(".discussion-notes"); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -664,26 +737,26 @@ require('./task_list'); } } - note.remove(); + $note.remove(); // check if this is the last note for this line - if (notes.find(".note").length === 0) { - var notesTr = notes.closest("tr"); + if ($notes.find(".note").length === 0) { + var notesTr = $notes.closest("tr"); // "Discussions" tab - notes.closest(".timeline-entry").remove(); + $notes.closest(".timeline-entry").remove(); - if (!_this.isParallelView() || notesTr.find('.note').length === 0) { - // "Changes" tab / commit view - notesTr.remove(); + // The notes tr can contain multiple lists of notes, like on the parallel diff + if (notesTr.find('.discussion-notes').length > 1) { + $notes.remove(); } else { - notes.closest('.content').empty(); + notesTr.remove(); } } - return note.remove(); }; })(this)); - // Decrement the "Discussions" counter only once + + Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); }; @@ -695,12 +768,11 @@ require('./task_list'); */ Notes.prototype.removeAttachment = function() { - var note; - note = $(this).closest(".note"); - note.find(".note-attachment").remove(); - note.find(".note-body > .note-text").show(); - note.find(".note-header").show(); - return note.find(".current-note-edit-form").remove(); + const $note = $(this).closest(".note"); + $note.find(".note-attachment").remove(); + $note.find(".note-body > .note-text").show(); + $note.find(".note-header").show(); + return $note.find(".current-note-edit-form").remove(); }; /* @@ -709,10 +781,14 @@ require('./task_list'); Shows the note form below the notes. */ - Notes.prototype.replyToDiscussionNote = function(e) { + Notes.prototype.onReplyToDiscussionNote = function(e) { + this.replyToDiscussionNote(e.target); + }; + + Notes.prototype.replyToDiscussionNote = function(target) { var form, replyLink; - form = this.formClone.clone(); - replyLink = $(e.target).closest(".js-discussion-reply-button"); + form = this.cleanForm(this.formClone.clone()); + replyLink = $(target).closest(".js-discussion-reply-button"); // insert the form after the button replyLink .closest('.discussion-reply-holder') @@ -727,29 +803,44 @@ require('./task_list'); Sets some hidden fields in the form. - Note: dataHolder must have the "discussionId", "lineCode", "noteableType" - and "noteableId" data attributes set. + Note: dataHolder must have the "discussionId" and "lineCode" data attributes set. */ Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) { // setup note target - form.attr('id', "new-discussion-note-form-" + (dataHolder.data("discussionId"))); + var discussionID = dataHolder.data("discussionId"); + + if (discussionID) { + form.attr("data-discussion-id", discussionID); + form.find("#in_reply_to_discussion_id").val(discussionID); + } + form.attr("data-line-code", dataHolder.data("lineCode")); - form.find("#note_type").val(dataHolder.data("noteType")); form.find("#line_type").val(dataHolder.data("lineType")); + + form.find("#note_noteable_type").val(dataHolder.data("noteableType")); + form.find("#note_noteable_id").val(dataHolder.data("noteableId")); form.find("#note_commit_id").val(dataHolder.data("commitId")); + form.find("#note_type").val(dataHolder.data("noteType")); + + // LegacyDiffNote form.find("#note_line_code").val(dataHolder.data("lineCode")); + + // DiffNote form.find("#note_position").val(dataHolder.attr("data-position")); - form.find("#note_noteable_type").val(dataHolder.data("noteableType")); - form.find("#note_noteable_id").val(dataHolder.data("noteableId")); + form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); form.find('.js-note-target-close').remove(); + form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); + form + .removeClass('js-main-target-form') + .addClass("discussion-form js-discussion-note-form"); + if (typeof gl.diffNotesCompileComponents !== 'undefined') { var $commentBtn = form.find('comment-and-resolve-btn'); - $commentBtn - .attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'"); + $commentBtn.attr(':discussion-id', `'${discussionID}'`); gl.diffNotesCompileComponents(); } @@ -757,10 +848,7 @@ require('./task_list'); form.find(".js-note-text").focus(); form .find('.js-comment-resolve-button') - .attr('data-discussion-id', dataHolder.data('discussionId')); - form - .removeClass('js-main-target-form') - .addClass("discussion-form js-discussion-note-form"); + .attr('data-discussion-id', discussionID); }; /* @@ -770,35 +858,52 @@ require('./task_list'); Sets up the form and shows it. */ - Notes.prototype.addDiffNote = function(e) { - var $link, addForm, hasNotes, lineType, newForm, nextRow, noteForm, notesContent, notesContentSelector, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; + Notes.prototype.onAddDiffNote = function(e) { e.preventDefault(); - $link = $(e.currentTarget || e.target); + const $link = $(e.currentTarget || e.target); + const showReplyInput = !$link.hasClass('js-diff-comment-avatar'); + this.toggleDiffNote({ + target: $link, + lineType: $link.data('lineType'), + showReplyInput + }); + }; + + Notes.prototype.toggleDiffNote = function({ + target, + lineType, + forceShow, + showReplyInput = false, + }) { + var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; + $link = $(target); row = $link.closest("tr"); - nextRow = row.next(); - hasNotes = nextRow.is(".notes_holder"); + const nextRow = row.next(); + let targetRow = row; + if (nextRow.is('.notes_holder')) { + targetRow = nextRow; + } + + hasNotes = targetRow.is(".notes_holder"); addForm = false; - notesContentSelector = ".notes_content"; + let lineTypeSelector = ''; rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>"; - isDiffCommentAvatar = $link.hasClass('js-diff-comment-avatar'); // In parallel view, look inside the correct left/right pane if (this.isParallelView()) { - lineType = $link.data("lineType"); - notesContentSelector += "." + lineType; + lineTypeSelector = `.${lineType}`; rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>"; } - notesContentSelector += " .content"; - notesContent = nextRow.find(notesContentSelector); + const notesContentSelector = `.notes_content${lineTypeSelector} .content`; + let notesContent = targetRow.find(notesContentSelector); - if (hasNotes && !isDiffCommentAvatar) { - nextRow.show(); - notesContent = nextRow.find(notesContentSelector); + if (hasNotes && showReplyInput) { + targetRow.show(); + notesContent = targetRow.find(notesContentSelector); if (notesContent.length) { notesContent.show(); replyButton = notesContent.find(".js-discussion-reply-button:visible"); if (replyButton.length) { - e.target = replyButton[0]; - $.proxy(this.replyToDiscussionNote, replyButton[0], e).call(); + this.replyToDiscussionNote(replyButton[0]); } else { // In parallel view, the form may not be present in one of the panes noteForm = notesContent.find(".js-discussion-note-form"); @@ -807,23 +912,23 @@ require('./task_list'); } } } - } else if (!isDiffCommentAvatar) { + } else if (showReplyInput) { // add a notes row and insert the form row.after(rowCssToAdd); - nextRow = row.next(); - notesContent = nextRow.find(notesContentSelector); + targetRow = row.next(); + notesContent = targetRow.find(notesContentSelector); addForm = true; } else { - nextRow.show(); - notesContent.toggle(!notesContent.is(':visible')); + const isCurrentlyShown = targetRow.find('.content:not(:empty)').is(':visible'); + const isForced = forceShow === true || forceShow === false; + const showNow = forceShow === true || (!isCurrentlyShown && !isForced); - if (!nextRow.find('.content:not(:empty)').is(':visible')) { - nextRow.hide(); - } + targetRow.toggle(showNow); + notesContent.toggle(showNow); } if (addForm) { - newForm = this.formClone.clone(); + newForm = this.cleanForm(this.formClone.clone()); newForm.appendTo(notesContent); // show the form return this.setupDiscussionNoteForm($link, newForm); @@ -885,14 +990,6 @@ require('./task_list'); return this.refresh(); }; - Notes.prototype.updateCloseButton = function(e) { - var closebtn, form, textarea; - textarea = $(e.target); - form = textarea.parents('form'); - closebtn = form.find('.js-note-target-close'); - return closebtn.text(closebtn.data('original-text')); - }; - Notes.prototype.updateTargetButtons = function(e) { var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea; textarea = $(e.target); @@ -900,9 +997,10 @@ require('./task_list'); reopenbtn = form.find('.js-note-target-reopen'); closebtn = form.find('.js-note-target-close'); discardbtn = form.find('.js-note-discard'); + if (textarea.val().trim().length > 0) { - reopentext = reopenbtn.data('alternative-text'); - closetext = closebtn.data('alternative-text'); + reopentext = reopenbtn.attr('data-alternative-text'); + closetext = closebtn.attr('data-alternative-text'); if (reopenbtn.text() !== reopentext) { reopenbtn.text(reopentext); } @@ -963,19 +1061,21 @@ require('./task_list'); $editForm.find('.referenced-users').hide(); }; - Notes.prototype.updateNotesCount = function(updateCount) { - return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); + Notes.prototype.putConflictEditWarningInPlace = function(noteEntity, $note) { + if ($note.find('.js-conflict-edit-warning').length === 0) { + const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> + This comment has changed since you started editing, please review the + <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer"> + updated comment + </a> + to ensure information is not lost + </div>`); + $alert.insertAfter($note.find('.note-text')); + } }; - Notes.prototype.resolveDiscussion = function() { - var $this = $(this); - var discussionId = $this.attr('data-discussion-id'); - - $this - .closest('form') - .attr('data-discussion-id', discussionId) - .attr('data-resolve-all', 'true') - .attr('data-project-path', $this.attr('data-project-path')); + Notes.prototype.updateNotesCount = function(updateCount) { + return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); }; Notes.prototype.toggleCommitList = function(e) { @@ -1009,6 +1109,318 @@ require('./task_list'); }); }; + Notes.prototype.addFlash = function(...flashParams) { + this.flashErrors.push(new Flash(...flashParams)); + }; + + Notes.prototype.clearFlash = function() { + this.flashErrors.forEach(flash => flash.flashContainer.remove()); + this.flashErrors = []; + }; + + Notes.prototype.cleanForm = function($form) { + // Remove JS classes that are not needed here + $form + .find('.js-comment-type-dropdown') + .removeClass('btn-group'); + + // Remove dropdown + $form + .find('.dropdown-menu') + .remove(); + + return $form; + }; + + /** + * Check if note does not exists on page + */ + Notes.isNewNote = function(noteEntity, noteIds) { + return $.inArray(noteEntity.id, noteIds) === -1; + }; + + /** + * Check if $note already contains the `noteEntity` content + */ + Notes.isUpdatedNote = function(noteEntity, $note) { + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteEntityText = normalizeNewlines(noteEntity.note.trim()); + const currentNoteText = normalizeNewlines( + $note.find('.original-note-content').first().text().trim() + ); + return sanitizedNoteEntityText !== currentNoteText; + }; + + Notes.checkMergeRequestStatus = function() { + if (gl.utils.getPagePath(1) === 'merge_requests') { + gl.mrWidget.checkStatus(); + } + }; + + Notes.animateAppendNote = function(noteHtml, $notesList) { + const $note = $(noteHtml); + + $note.addClass('fade-in-full').renderGFM(); + $notesList.append($note); + return $note; + }; + + Notes.animateUpdateNote = function(noteHtml, $note) { + const $updatedNote = $(noteHtml); + + $updatedNote.addClass('fade-in').renderGFM(); + $note.replaceWith($updatedNote); + return $updatedNote; + }; + + /** + * Get data from Form attributes to use for saving/submitting comment. + */ + Notes.prototype.getFormData = function($form) { + return { + formData: $form.serialize(), + formContent: $form.find('.js-note-text').val(), + formAction: $form.attr('action'), + }; + }; + + /** + * Identify if comment has any slash commands + */ + Notes.prototype.hasSlashCommands = function(formContent) { + return REGEX_SLASH_COMMANDS.test(formContent); + }; + + /** + * Remove slash commands and leave comment with pure message + */ + Notes.prototype.stripSlashCommands = function(formContent) { + return formContent.replace(REGEX_SLASH_COMMANDS, '').trim(); + }; + + /** + * Create placeholder note DOM element populated with comment body + * that we will show while comment is being posted. + * Once comment is _actually_ posted on server, we will have final element + * in response that we will show in place of this temporary element. + */ + Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) { + const discussionClass = isDiscussionNote ? 'discussion' : ''; + const escapedFormContent = _.escape(formContent); + const $tempNote = $( + `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <a href="/${currentUsername}"><span class="dummy-avatar"></span></a> + </div> + <div class="timeline-content ${discussionClass}"> + <div class="note-header"> + <div class="note-header-info"> + <a href="/${currentUsername}"> + <span class="hidden-xs">${currentUserFullname}</span> + <span class="note-headline-light">@${currentUsername}</span> + </a> + </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>${escapedFormContent}</p> + </div> + </div> + </div> + </div> + </li>` + ); + + return $tempNote; + }; + + /** + * This method does following tasks step-by-step whenever a new comment + * is submitted by user (both main thread comments as well as discussion comments). + * + * 1) Get Form metadata + * 2) Identify comment type; a) Main thread b) Discussion thread c) Discussion resolve + * 3) Build temporary placeholder element (using `createPlaceholderNote`) + * 4) Show placeholder note on UI + * 5) Perform network request to submit the note using `gl.utils.ajaxPost` + * a) If request is successfully completed + * 1. Remove placeholder element + * 2. Show submitted Note element + * 3. Perform post-submit errands + * a. Mark discussion as resolved if comment submission was for resolve. + * b. Reset comment form to original state. + * b) If request failed + * 1. Remove placeholder element + * 2. Show error Flash message about failure + */ + Notes.prototype.postComment = function(e) { + e.preventDefault(); + + // Get Form metadata + const $submitBtn = $(e.target); + let $form = $submitBtn.parents('form'); + const $closeBtn = $form.find('.js-note-target-close'); + const isDiscussionNote = $submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion'; + const isMainForm = $form.hasClass('js-main-target-form'); + const isDiscussionForm = $form.hasClass('js-discussion-note-form'); + const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); + const { formData, formContent, formAction } = this.getFormData($form); + const uniqueId = _.uniqueId('tempNote_'); + let $notesContainer; + let tempFormContent; + + // Get reference to notes container based on type of comment + if (isDiscussionForm) { + $notesContainer = $form.parent('.discussion-notes').find('.notes'); + } else if (isMainForm) { + $notesContainer = $('ul.main-notes-list'); + } + + // If comment is to resolve discussion, disable submit buttons while + // comment posting is finished. + if (isDiscussionResolve) { + $submitBtn.disable(); + $form.find('.js-comment-submit-button').disable(); + } + + tempFormContent = formContent; + if (this.hasSlashCommands(formContent)) { + tempFormContent = this.stripSlashCommands(formContent); + } + + if (tempFormContent) { + // Show placeholder note + $notesContainer.append(this.createPlaceholderNote({ + formContent: tempFormContent, + uniqueId, + isDiscussionNote, + currentUsername: gon.current_username, + currentUserFullname: gon.current_user_fullname, + })); + } + + // Clear the form textarea + if ($notesContainer.length) { + if (isMainForm) { + this.resetMainTargetForm(e); + } else if (isDiscussionForm) { + this.removeDiscussionNoteForm($form); + } + } + + /* eslint-disable promise/catch-or-return */ + // Make request to submit comment on server + gl.utils.ajaxPost(formAction, formData) + .then((note) => { + // Submission successful! remove placeholder + $notesContainer.find(`#${uniqueId}`).remove(); + // Clear previous form errors + this.clearFlashWrapper(); + + // Check if this was discussion comment + if (isDiscussionForm) { + // Remove flash-container + $notesContainer.find('.flash-container').remove(); + + // If comment intends to resolve discussion, do the same. + if (isDiscussionResolve) { + $form + .attr('data-discussion-id', $submitBtn.data('discussion-id')) + .attr('data-resolve-all', 'true') + .attr('data-project-path', $submitBtn.data('project-path')); + } + + // Show final note element on UI + this.addDiscussionNote($form, note, $notesContainer.length === 0); + + // append flash-container to the Notes list + if ($notesContainer.length) { + $notesContainer.append('<div class="flash-container" style="display: none;"></div>'); + } + } else if (isMainForm) { // Check if this was main thread comment + // Show final note element on UI and perform form and action buttons cleanup + this.addNote($form, note); + this.reenableTargetFormSubmitButton(e); + } + + if (note.commands_changes) { + this.handleSlashCommands(note); + } + + $form.trigger('ajax:success', [note]); + }).fail(() => { + // Submission failed, remove placeholder note and show Flash error message + $notesContainer.find(`#${uniqueId}`).remove(); + + // Show form again on UI on failure + if (isDiscussionForm && $notesContainer.length) { + const replyButton = $notesContainer.parent().find('.js-discussion-reply-button'); + this.replyToDiscussionNote(replyButton[0]); + $form = $notesContainer.parent().find('form'); + } + + $form.find('.js-note-text').val(formContent); + this.reenableTargetFormSubmitButton(e); + this.addNoteError($form); + }); + + return $closeBtn.text($closeBtn.data('original-text')); + }; + + /** + * This method does following tasks step-by-step whenever an existing comment + * is updated by user (both main thread comments as well as discussion comments). + * + * 1) Get Form metadata + * 2) Update note element with new content + * 3) Perform network request to submit the updated note using `gl.utils.ajaxPost` + * a) If request is successfully completed + * 1. Show submitted Note element + * b) If request failed + * 1. Revert Note element to original content + * 2. Show error Flash message about failure + */ + Notes.prototype.updateComment = function(e) { + e.preventDefault(); + + // Get Form metadata + const $submitBtn = $(e.target); + const $form = $submitBtn.parents('form'); + const $closeBtn = $form.find('.js-note-target-close'); + const $editingNote = $form.parents('.note.is-editing'); + const $noteBody = $editingNote.find('.js-task-list-container'); + const $noteBodyText = $noteBody.find('.note-text'); + const { formData, formContent, formAction } = this.getFormData($form); + + // Cache original comment content + const cachedNoteBodyText = $noteBodyText.html(); + + // Show updated comment content temporarily + $noteBodyText.html(formContent); + $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); + $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); + + /* eslint-disable promise/catch-or-return */ + // Make request to update comment on server + gl.utils.ajaxPost(formAction, formData) + .then((note) => { + // Submission successful! render final note element + this.updateNote(note, $editingNote); + }) + .fail(() => { + // Submission failed, revert back to original note + $noteBodyText.html(cachedNoteBodyText); + $editingNote.removeClass('being-posted fade-in'); + $editingNote.find('.fa.fa-spinner').remove(); + + // Show Flash message about failure + this.updateNoteError(); + }); + + return $closeBtn.text($closeBtn.data('original-text')); + }; + return Notes; })(); }).call(window); diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index 5005af90d48..2ab9c4fed2c 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -1,10 +1,8 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.NotificationsForm = (function() { function NotificationsForm() { - this.toggleCheckbox = bind(this.toggleCheckbox, this); + this.toggleCheckbox = this.toggleCheckbox.bind(this); this.removeEventListeners(); this.initEventListeners(); } diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index 5f6bc902cf8..0ef20af9260 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -1,5 +1,5 @@ -require('~/lib/utils/common_utils'); -require('~/lib/utils/url_utility'); +import '~/lib/utils/common_utils'; +import '~/lib/utils/url_utility'; (() => { const ENDLESS_SCROLL_BOTTOM_PX = 400; diff --git a/app/assets/javascripts/pdf/assets/img/bg.gif b/app/assets/javascripts/pdf/assets/img/bg.gif Binary files differnew file mode 100644 index 00000000000..c7e98e044f5 --- /dev/null +++ b/app/assets/javascripts/pdf/assets/img/bg.gif diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue new file mode 100644 index 00000000000..4603859d7b0 --- /dev/null +++ b/app/assets/javascripts/pdf/index.vue @@ -0,0 +1,73 @@ +<template> + <div class="pdf-viewer" v-if="hasPDF"> + <page v-for="(page, index) in pages" + :key="index" + :v-if="!loading" + :page="page" + :number="index + 1" /> + </div> +</template> + +<script> + import pdfjsLib from 'pdfjs-dist'; + import workerSrc from 'vendor/pdf.worker'; + + import page from './page/index.vue'; + + export default { + props: { + pdf: { + type: [String, Uint8Array], + required: true, + }, + }, + data() { + return { + loading: false, + pages: [], + }; + }, + components: { page }, + watch: { pdf: 'load' }, + computed: { + document() { + return typeof this.pdf === 'string' ? this.pdf : { data: this.pdf }; + }, + hasPDF() { + return this.pdf && this.pdf.length > 0; + }, + }, + methods: { + load() { + this.pages = []; + return pdfjsLib.getDocument(this.document) + .then(this.renderPages) + .then(() => this.$emit('pdflabload')) + .catch(error => this.$emit('pdflaberror', error)) + .then(() => { this.loading = false; }); + }, + renderPages(pdf) { + const pagePromises = []; + this.loading = true; + for (let num = 1; num <= pdf.numPages; num += 1) { + pagePromises.push( + pdf.getPage(num).then(p => this.pages.push(p)), + ); + } + return Promise.all(pagePromises); + }, + }, + mounted() { + pdfjsLib.PDFJS.workerSrc = workerSrc; + if (this.hasPDF) this.load(); + }, + }; +</script> + +<style> + .pdf-viewer { + background: url('./assets/img/bg.gif'); + display: flex; + flex-flow: column nowrap; + } +</style> diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue new file mode 100644 index 00000000000..7b74ee4eb2e --- /dev/null +++ b/app/assets/javascripts/pdf/page/index.vue @@ -0,0 +1,68 @@ +<template> + <canvas + class="pdf-page" + ref="canvas" + :data-page="number" /> +</template> + +<script> + export default { + props: { + page: { + type: Object, + required: true, + }, + number: { + type: Number, + required: true, + }, + }, + data() { + return { + scale: 4, + rendering: false, + }; + }, + computed: { + viewport() { + return this.page.getViewport(this.scale); + }, + context() { + return this.$refs.canvas.getContext('2d'); + }, + renderContext() { + return { + canvasContext: this.context, + viewport: this.viewport, + }; + }, + }, + mounted() { + this.$refs.canvas.height = this.viewport.height; + this.$refs.canvas.width = this.viewport.width; + this.rendering = true; + this.page.render(this.renderContext) + .then(() => { this.rendering = false; }) + .catch(error => this.$emit('pdflaberror', error)); + }, + }; +</script> + +<style> +.pdf-page { + margin: 8px auto 0 auto; + border-top: 1px #ddd solid; + border-bottom: 1px #ddd solid; + width: 100%; +} + +.pdf-page:first-child { + margin-top: 0px; + border-top: 0px; +} + +.pdf-page:last-child { + margin-bottom: 0px; + border-bottom: 0px; +} +</style> diff --git a/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js new file mode 100644 index 00000000000..4d623763ca7 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/interval_pattern_input.js @@ -0,0 +1,145 @@ +import Vue from 'vue'; + +const inputNameAttribute = 'schedule[cron]'; + +export default { + props: { + initialCronInterval: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + inputNameAttribute, + cronInterval: this.initialCronInterval, + cronIntervalPresets: { + everyDay: '0 4 * * *', + everyWeek: '0 4 * * 0', + everyMonth: '0 4 1 * *', + }, + cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron', + customInputEnabled: false, + }; + }, + computed: { + intervalIsPreset() { + return _.contains(this.cronIntervalPresets, this.cronInterval); + }, + // The text input is editable when there's a custom interval, or when it's + // a preset interval and the user clicks the 'custom' radio button + isEditable() { + return !!(this.customInputEnabled || !this.intervalIsPreset); + }, + }, + methods: { + toggleCustomInput(shouldEnable) { + this.customInputEnabled = shouldEnable; + + if (shouldEnable) { + // We need to change the value so other radios don't remain selected + // because the model (cronInterval) hasn't changed. The server trims it. + this.cronInterval = `${this.cronInterval} `; + } + }, + }, + created() { + if (this.intervalIsPreset) { + this.enableCustomInput = false; + } + }, + watch: { + cronInterval() { + // updates field validation state when model changes, as + // glFieldError only updates on input. + Vue.nextTick(() => { + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + }); + }, + }, + template: ` + <div class="interval-pattern-form-group"> + <div class="cron-preset-radio-input"> + <input + id="custom" + class="label-light" + type="radio" + :name="inputNameAttribute" + :value="cronInterval" + :checked="isEditable" + @click="toggleCustomInput(true)" + /> + + <label for="custom"> + Custom + </label> + + <span class="cron-syntax-link-wrap"> + (<a :href="cronSyntaxUrl" target="_blank">Cron syntax</a>) + </span> + </div> + + <div class="cron-preset-radio-input"> + <input + id="every-day" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyDay" + @click="toggleCustomInput(false)" + /> + + <label class="label-light" for="every-day"> + Every day (at 4:00am) + </label> + </div> + + <div class="cron-preset-radio-input"> + <input + id="every-week" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyWeek" + @click="toggleCustomInput(false)" + /> + + <label class="label-light" for="every-week"> + Every week (Sundays at 4:00am) + </label> + </div> + + <div class="cron-preset-radio-input"> + <input + id="every-month" + class="label-light" + type="radio" + v-model="cronInterval" + :name="inputNameAttribute" + :value="cronIntervalPresets.everyMonth" + @click="toggleCustomInput(false)" + /> + + <label class="label-light" for="every-month"> + Every month (on the 1st at 4:00am) + </label> + </div> + + <div class="cron-interval-input-wrapper"> + <input + id="schedule_cron" + class="form-control inline cron-interval-input" + type="text" + placeholder="Define a custom pattern with cron syntax" + required="true" + v-model="cronInterval" + :name="inputNameAttribute" + :disabled="!isEditable" + /> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js new file mode 100644 index 00000000000..5109b110b31 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/pipeline_schedules_callout.js @@ -0,0 +1,48 @@ +import Cookies from 'js-cookie'; +import illustrationSvg from '../icons/intro_illustration.svg'; + +const cookieKey = 'pipeline_schedules_callout_dismissed'; + +export default { + name: 'PipelineSchedulesCallout', + data() { + return { + docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl, + illustrationSvg, + calloutDismissed: Cookies.get(cookieKey) === 'true', + }; + }, + methods: { + dismissCallout() { + this.calloutDismissed = true; + Cookies.set(cookieKey, this.calloutDismissed, { expires: 365 }); + }, + }, + template: ` + <div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout"> + <div class="bordered-box landing content-block"> + <button + id="dismiss-callout-btn" + class="btn btn-default close" + @click="dismissCallout"> + <i class="fa fa-times"></i> + </button> + <div class="svg-container" v-html="illustrationSvg"></div> + <div class="user-callout-copy"> + <h4>Scheduling Pipelines</h4> + <p> + The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. + Those scheduled pipelines will inherit limited project access based on their associated user. + </p> + <p> Learn more in the + <a + :href="docsUrl" + target="_blank" + rel="nofollow">pipeline schedules documentation</a>. <!-- oneline to prevent extra space before period --> + </p> + </div> + </div> + </div> + `, +}; + diff --git a/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js new file mode 100644 index 00000000000..0c3926d76b5 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/target_branch_dropdown.js @@ -0,0 +1,52 @@ +export default class TargetBranchDropdown { + constructor() { + this.$dropdown = $('.js-target-branch-dropdown'); + this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); + this.$input = $('#schedule_ref'); + this.initDefaultBranch(); + this.initDropdown(); + } + + initDropdown() { + this.$dropdown.glDropdown({ + data: this.formatBranchesList(), + filterable: true, + selectable: true, + toggleLabel: item => item.name, + search: { + fields: ['name'], + }, + clicked: cfg => this.updateInputValue(cfg), + text: item => item.name, + }); + + this.setDropdownToggle(); + } + + formatBranchesList() { + return this.$dropdown.data('data') + .map(val => ({ name: val })); + } + + setDropdownToggle() { + const initialValue = this.$input.val(); + + this.$dropdownToggle.text(initialValue); + } + + initDefaultBranch() { + const initialValue = this.$input.val(); + const defaultBranch = this.$dropdown.data('defaultBranch'); + + if (!initialValue) { + this.$input.val(defaultBranch); + } + } + + updateInputValue({ selectedObj, e }) { + e.preventDefault(); + + this.$input.val(selectedObj.name); + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + } +} diff --git a/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js new file mode 100644 index 00000000000..95ed9c7dc21 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/components/timezone_dropdown.js @@ -0,0 +1,66 @@ +/* eslint-disable class-methods-use-this */ + +const defaultTimezone = 'UTC'; + +export default class TimezoneDropdown { + constructor() { + this.$dropdown = $('.js-timezone-dropdown'); + this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); + this.$input = $('#schedule_cron_timezone'); + this.timezoneData = this.$dropdown.data('data'); + this.initDefaultTimezone(); + this.initDropdown(); + } + + initDropdown() { + this.$dropdown.glDropdown({ + data: this.timezoneData, + filterable: true, + selectable: true, + toggleLabel: item => item.name, + search: { + fields: ['name'], + }, + clicked: cfg => this.updateInputValue(cfg), + text: item => this.formatTimezone(item), + }); + + this.setDropdownToggle(); + } + + formatUtcOffset(offset) { + let prefix = ''; + + if (offset > 0) { + prefix = '+'; + } else if (offset < 0) { + prefix = '-'; + } + + return `${prefix} ${Math.abs(offset / 3600)}`; + } + + formatTimezone(item) { + return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`; + } + + initDefaultTimezone() { + const initialValue = this.$input.val(); + + if (!initialValue) { + this.$input.val(defaultTimezone); + } + } + + setDropdownToggle() { + const initialValue = this.$input.val(); + + this.$dropdownToggle.text(initialValue); + } + + updateInputValue({ selectedObj, e }) { + e.preventDefault(); + this.$input.val(selectedObj.identifier); + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + } +} diff --git a/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg new file mode 100644 index 00000000000..26d1ff97b3e --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/icons/intro_illustration.svg @@ -0,0 +1 @@ +<svg width="140" height="102" viewBox="0 0 140 102" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="12.033" height="40.197" rx="3"/><rect id="b" width="12.033" height="40.197" rx="3"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(-.446)"><path d="M91.747 35.675v-6.039a2.996 2.996 0 0 0-2.999-3.005H54.635a2.997 2.997 0 0 0-2.999 3.005v6.039H40.092a3.007 3.007 0 0 0-2.996 3.005v34.187a2.995 2.995 0 0 0 2.996 3.005h11.544V79.9a2.996 2.996 0 0 0 2.999 3.005h34.113a2.997 2.997 0 0 0 2.999-3.005v-4.03h11.544a3.007 3.007 0 0 0 2.996-3.004V38.68a2.995 2.995 0 0 0-2.996-3.005H91.747z" stroke="#B5A7DD" stroke-width="2"/><rect stroke="#E5E5E5" stroke-width="2" fill="#FFF" x="21.556" y="38.69" width="98.27" height="34.167" rx="3"/><path d="M121.325 38.19c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zM121.325 71.854a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038z" fill="#E5E5E5"/><g transform="translate(110.3 35.675)"><use fill="#FFF" xlink:href="#a"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="9.547" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.099" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="30.65" rx="1.504" ry="1.507"/></g><path d="M6.008 38.19c.55 0 .996.444.996 1.002 0 .554-.454 1.003-.996 1.003H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0c.55 0 .996.444.996 1.002 0 .554-.453 1.003-.996 1.003h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0c.55 0 .995.444.995 1.002 0 .554-.453 1.003-.995 1.003h-4.039a1.004 1.004 0 0 1 0-2.006h4.039zM6.008 71.854a1.004 1.004 0 0 1 0 2.006H1.97a1.004 1.004 0 0 1 0-2.006h4.038zm9.044 0a1.004 1.004 0 0 1 0 2.006h-4.038a1.004 1.004 0 0 1 0-2.006h4.038zm9.045 0a1.004 1.004 0 0 1 0 2.006h-4.039a1.004 1.004 0 0 1 0-2.006h4.039z" fill="#E5E5E5"/><g transform="translate(19.05 35.675)"><use fill="#FFF" xlink:href="#b"/><rect stroke="#FDE5D8" stroke-width="2" x="1" y="1" width="10.033" height="38.197" rx="3"/><ellipse fill="#FC8A51" cx="6.017" cy="10.049" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="20.601" rx="1.504" ry="1.507"/><ellipse fill="#FC8A51" cx="6.017" cy="31.153" rx="1.504" ry="1.507"/></g><g transform="translate(47.096)"><g transform="translate(7.05)"><ellipse fill="#FC8A51" cx="17.548" cy="5.025" rx="4.512" ry="4.522"/><rect stroke="#B5A7DD" stroke-width="2" fill="#FFF" x="13.036" y="4.02" width="9.025" height="20.099" rx="1.5"/><rect stroke="#FDE5D8" stroke-width="2" fill="#FFF" y="4.02" width="35.096" height="4.02" rx="2.01"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.512" y="18.089" width="26.072" height="17.084" rx="1.5"/></g><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(-45 43.117 35.117)" x="38.168" y="31.416" width="9.899" height="7.403" rx="3.702"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="25" ry="25"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25" cy="55" rx="21" ry="21"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="43.05" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" x="4.305" y="53.281" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 74.422)" x="23.677" y="73.653" width="2.95" height="1.538" rx=".769"/><rect stroke="#6B4FBB" stroke-width="2" fill="#FFF" transform="rotate(90 25.153 35.51)" x="23.844" y="34.742" width="2.616" height="1.538" rx=".769"/><path d="M13.362 42.502c-.124-.543.198-.854.74-.69l2.321.704c.533.161.643.592.235.972l-.22.206 7.06 7.572a1.002 1.002 0 1 1-1.467 1.368l-7.06-7.573-.118.11c-.402.375-.826.248-.952-.304l-.54-2.365zM21.606 67.576c-.408.38-.84.255-.968-.295l-.551-2.363c-.127-.542.191-.852.725-.69l.288.089 3.027-9.901a1.002 1.002 0 1 1 1.918.586l-3.027 9.901.154.047c.525.16.627.592.213.977l-1.779 1.65z" fill="#FC8A51"/><ellipse stroke="#6B4FBB" stroke-width="2" fill="#FFF" cx="25.099" cy="54.768" rx="2.507" ry="2.512"/></g></g><path d="M52.697 96.966a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044a1.004 1.004 0 0 1 2.006 0v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zM86.29 96.966c0-.55.444-.996 1.002-.996.554 0 1.003.454 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038zm0-9.044c0-.55.444-.996 1.002-.996.554 0 1.003.453 1.003.996v4.038a1.004 1.004 0 0 1-2.006 0v-4.038z" fill="#E5E5E5"/></g></svg>
\ No newline at end of file diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js new file mode 100644 index 00000000000..c60e77decce --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedule_form_bundle.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import IntervalPatternInput from './components/interval_pattern_input'; +import TimezoneDropdown from './components/timezone_dropdown'; +import TargetBranchDropdown from './components/target_branch_dropdown'; + +document.addEventListener('DOMContentLoaded', () => { + const IntervalPatternInputComponent = Vue.extend(IntervalPatternInput); + const intervalPatternMount = document.getElementById('interval-pattern-input'); + const initialCronInterval = intervalPatternMount ? intervalPatternMount.dataset.initialInterval : ''; + + new IntervalPatternInputComponent({ + propsData: { + initialCronInterval, + }, + }).$mount(intervalPatternMount); + + const formElement = document.getElementById('new-pipeline-schedule-form'); + gl.timezoneDropdown = new TimezoneDropdown(); + gl.targetBranchDropdown = new TargetBranchDropdown(); + gl.pipelineScheduleFieldErrors = new gl.GlFieldErrors(formElement); +}); diff --git a/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js new file mode 100644 index 00000000000..6584549ad06 --- /dev/null +++ b/app/assets/javascripts/pipeline_schedules/pipeline_schedules_index_bundle.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import PipelineSchedulesCallout from './components/pipeline_schedules_callout'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#pipeline-schedules-callout', + components: { + 'pipeline-schedules-callout': PipelineSchedulesCallout, + }, + render(createElement) { + return createElement('pipeline-schedules-callout'); + }, +})); diff --git a/app/assets/javascripts/pipelines.js b/app/assets/javascripts/pipelines.js index 9203abefbbc..26a36ad54d1 100644 --- a/app/assets/javascripts/pipelines.js +++ b/app/assets/javascripts/pipelines.js @@ -1,38 +1,14 @@ -/* eslint-disable no-new, guard-for-in, no-restricted-syntax, no-continue, no-param-reassign, max-len */ +import LinkedTabs from './lib/utils/bootstrap_linked_tabs'; -require('./lib/utils/bootstrap_linked_tabs'); - -((global) => { - class Pipelines { - constructor(options = {}) { - if (options.initTabs && options.tabsOptions) { - new global.LinkedTabs(options.tabsOptions); - } - - this.addMarginToBuildColumns(); +export default class Pipelines { + constructor(options = {}) { + if (options.initTabs && options.tabsOptions) { + // eslint-disable-next-line no-new + new LinkedTabs(options.tabsOptions); } - addMarginToBuildColumns() { - 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; - const multiBuildColumn = buildNode.closest('.stage-column'); - const previousColumn = multiBuildColumn.previousElementSibling; - if (!previousColumn || !previousColumn.matches('.stage-column')) continue; - multiBuildColumn.classList.add('left-margin'); - firstChildBuildNode.classList.add('left-connector'); - const columnBuilds = previousColumn.querySelectorAll('.build'); - if (columnBuilds.length === 1) previousColumn.classList.add('no-margin'); - } - - this.pipelineGraph.classList.remove('hidden'); + if (options.pipelineStatusUrl) { + gl.utils.setCiStatusFavicon(options.pipelineStatusUrl); } } - - global.Pipelines = Pipelines; -})(window.gl || (window.gl = {})); +} diff --git a/app/assets/javascripts/vue_pipelines_index/components/async_button.js b/app/assets/javascripts/pipelines/components/async_button.vue index 58b8db4d519..37a6f02d8fd 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/async_button.js +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -1,7 +1,9 @@ +<script> /* eslint-disable no-new, no-alert */ /* global Flash */ import '~/flash'; import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -36,6 +38,10 @@ export default { }, }, + components: { + loadingIcon, + }, + data() { return { isLoading: false, @@ -64,30 +70,35 @@ export default { makeRequest() { this.isLoading = true; + $(this.$el).tooltip('destroy'); + this.service.postAction(this.endpoint) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshPipelines'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.'); - }); + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshPipelines'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); }, }, - - template: ` - <button - type="button" - @click="onClick" - :class="buttonClass" - :title="title" - :aria-label="title" - data-container="body" - data-placement="top" - :disabled="isLoading"> - <i :class="iconClass" aria-hidden="true"/> - <i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading" /> - </button> - `, }; +</script> + +<template> + <button + type="button" + @click="onClick" + :class="buttonClass" + :title="title" + :aria-label="title" + data-container="body" + data-placement="top" + :disabled="isLoading"> + <i + :class="iconClass" + aria-hidden="true" /> + <loading-icon v-if="isLoading" /> + </button> +</template> diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/empty_state.vue new file mode 100644 index 00000000000..3db64339a62 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/empty_state.vue @@ -0,0 +1,34 @@ +<script> +import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg'; + +export default { + props: { + helpPagePath: { + type: String, + required: true, + }, + }, + data: () => ({ pipelinesEmptyStateSVG }), +}; +</script> + +<template> + <div class="row empty-state js-empty-state"> + <div class="col-xs-12"> + <div class="svg-content" v-html="pipelinesEmptyStateSVG" /> + </div> + + <div class="col-xs-12 text-center"> + <div class="text-content"> + <h4>Build with confidence</h4> + <p> + Continous Integration can help catch bugs by running your tests automatically, + while Continuous Deployment can help you deliver code to your product environment. + </p> + <a :href="helpPagePath" class="btn btn-info"> + Get started with Pipelines + </a> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/error_state.vue b/app/assets/javascripts/pipelines/components/error_state.vue new file mode 100644 index 00000000000..90cee68163e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/error_state.vue @@ -0,0 +1,21 @@ +<script> +import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg'; + +export default { + data: () => ({ pipelinesErrorStateSVG }), +}; +</script> + +<template> + <div class="row empty-state js-pipelines-error-state"> + <div class="col-xs-12"> + <div class="svg-content" v-html="pipelinesErrorStateSVG" /> + </div> + + <div class="col-xs-12 text-center"> + <div class="text-content"> + <h4>The API failed to fetch the pipelines.</h4> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue new file mode 100644 index 00000000000..1f9e3d39779 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -0,0 +1,64 @@ +<script> + import getActionIcon from '../../../vue_shared/ci_action_icons'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders either a cancel, retry or play icon pointing to the given path. + * TODO: Remove UJS from here and use an async request instead. + */ + export default { + props: { + tooltipText: { + type: String, + required: true, + }, + + link: { + type: String, + required: true, + }, + + actionMethod: { + type: String, + required: true, + }, + + actionIcon: { + type: String, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + actionIconSvg() { + return getActionIcon(this.actionIcon); + }, + + cssClass() { + return `js-${gl.text.dasherize(this.actionIcon)}`; + }, + }, + }; +</script> +<template> + <a + :data-method="actionMethod" + :title="tooltipText" + :href="link" + ref="tooltip" + class="ci-action-icon-container" + data-toggle="tooltip" + data-container="body"> + + <i + class="ci-action-icon-wrapper" + :class="cssClass" + v-html="actionIconSvg" + aria-hidden="true" + /> + </a> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue new file mode 100644 index 00000000000..19cafff4e1c --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue @@ -0,0 +1,56 @@ +<script> + import getActionIcon from '../../../vue_shared/ci_action_icons'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders either a cancel, retry or play icon pointing to the given path. + * TODO: Remove UJS from here and use an async request instead. + */ + export default { + props: { + tooltipText: { + type: String, + required: true, + }, + + link: { + type: String, + required: true, + }, + + actionMethod: { + type: String, + required: true, + }, + + actionIcon: { + type: String, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + actionIconSvg() { + return getActionIcon(this.actionIcon); + }, + }, + }; +</script> +<template> + <a + :data-method="actionMethod" + :title="tooltipText" + :href="link" + ref="tooltip" + rel="nofollow" + class="ci-action-icon-wrapper js-ci-status-icon" + data-toggle="tooltip" + data-container="body" + v-html="actionIconSvg" + aria-label="Job's action"> + </a> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue new file mode 100644 index 00000000000..d597af8dfb5 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -0,0 +1,86 @@ +<script> + import jobNameComponent from './job_name_component.vue'; + import jobComponent from './job_component.vue'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders the dropdown for the pipeline graph. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "icon_action_retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + export default { + props: { + job: { + type: Object, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + components: { + jobComponent, + jobNameComponent, + }, + + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; + }, + }, + }; +</script> +<template> + <div> + <button + type="button" + data-toggle="dropdown" + data-container="body" + class="dropdown-menu-toggle build-content" + :title="tooltipText" + ref="tooltip"> + + <job-name-component + :name="job.name" + :status="job.status" /> + + <span class="dropdown-counter-badge"> + {{job.size}} + </span> + </button> + + <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"> + <li class="scrollable-menu"> + <ul> + <li v-for="item in job.jobs"> + <job-component + :job="item" + :is-dropdown="true" + css-class-job-name="mini-pipeline-graph-dropdown-item" + /> + </li> + </ul> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue new file mode 100644 index 00000000000..14c98847d93 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -0,0 +1,113 @@ +<script> + /* global Flash */ + import Visibility from 'visibilityjs'; + import Poll from '../../../lib/utils/poll'; + import PipelineService from '../../services/pipeline_service'; + import PipelineStore from '../../stores/pipeline_store'; + import stageColumnComponent from './stage_column_component.vue'; + import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; + import '../../../flash'; + + export default { + components: { + stageColumnComponent, + loadingIcon, + }, + + data() { + const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset; + const store = new PipelineStore(); + + return { + isLoading: false, + endpoint: DOMdata.endpoint, + store, + state: store.state, + }; + }, + + created() { + this.service = new PipelineService(this.endpoint); + + const poll = new Poll({ + resource: this.service, + method: 'getPipeline', + successCallback: this.successCallback, + errorCallback: this.errorCallback, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + }, + + methods: { + successCallback(response) { + const data = response.json(); + + this.isLoading = false; + this.store.storeGraph(data.details.stages); + }, + + errorCallback() { + this.isLoading = false; + return new Flash('An error occurred while fetching the pipeline.'); + }, + + capitalizeStageName(name) { + return name.charAt(0).toUpperCase() + name.slice(1); + }, + + isFirstColumn(index) { + return index === 0; + }, + + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, + }, + }; +</script> +<template> + <div class="build-content middle-block js-pipeline-graph"> + <div class="pipeline-visualization pipeline-graph"> + <div class="text-center"> + <loading-icon + v-if="isLoading" + size="3" + /> + </div> + + <ul + v-if="!isLoading" + class="stage-column-list"> + <stage-column-component + v-for="(stage, index) in state.graph" + :title="capitalizeStageName(stage.name)" + :jobs="stage.groups" + :key="stage.name" + :stage-connector-class="stageConnectorClass(index, stage)" + :is-first-column="isFirstColumn(index)"/> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue new file mode 100644 index 00000000000..b39c936101e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -0,0 +1,124 @@ +<script> + import actionComponent from './action_component.vue'; + import dropdownActionComponent from './dropdown_action_component.vue'; + import jobNameComponent from './job_name_component.vue'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "icon_action_retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + + export default { + props: { + job: { + type: Object, + required: true, + }, + + cssClassJobName: { + type: String, + required: false, + default: '', + }, + + isDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + components: { + actionComponent, + dropdownActionComponent, + jobNameComponent, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; + }, + + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; + }, + }, + }; +</script> +<template> + <div> + <a + v-if="job.status.details_path" + :href="job.status.details_path" + :title="tooltipText" + :class="cssClassJobName" + ref="tooltip" + data-toggle="tooltip" + data-container="body"> + + <job-name-component + :name="job.name" + :status="job.status" + /> + </a> + + <div + v-else + :title="tooltipText" + :class="cssClassJobName" + ref="tooltip" + data-toggle="tooltip" + data-container="body"> + + <job-name-component + :name="job.name" + :status="job.status" + /> + </div> + + <action-component + v-if="hasAction && !isDropdown" + :tooltip-text="job.status.action.title" + :link="job.status.action.path" + :action-icon="job.status.action.icon" + :action-method="job.status.action.method" + /> + + <dropdown-action-component + v-if="hasAction && isDropdown" + :tooltip-text="job.status.action.title" + :link="job.status.action.path" + :action-icon="job.status.action.icon" + :action-method="job.status.action.method" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue new file mode 100644 index 00000000000..d8856e10668 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -0,0 +1,37 @@ +<script> + import ciIcon from '../../../vue_shared/components/ci_icon.vue'; + + /** + * Component that renders both the CI icon status and the job name. + * Used in + * - Badge component + * - Dropdown badge components + */ + export default { + props: { + name: { + type: String, + required: true, + }, + + status: { + type: Object, + required: true, + }, + }, + + components: { + ciIcon, + }, + }; +</script> +<template> + <span> + <ci-icon + :status="status" /> + + <span class="ci-status-text"> + {{name}} + </span> + </span> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue new file mode 100644 index 00000000000..9b1bbb0906f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -0,0 +1,83 @@ +<script> +import jobComponent from './job_component.vue'; +import dropdownJobComponent from './dropdown_job_component.vue'; + +export default { + props: { + title: { + type: String, + required: true, + }, + + jobs: { + type: Array, + required: true, + }, + + isFirstColumn: { + type: Boolean, + required: false, + default: false, + }, + + stageConnectorClass: { + type: String, + required: false, + default: '', + }, + }, + + components: { + jobComponent, + dropdownJobComponent, + }, + + methods: { + firstJob(list) { + return list[0]; + }, + + jobId(job) { + return `ci-badge-${job.name}`; + }, + + buildConnnectorClass(index) { + return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; + }, + }, +}; +</script> +<template> + <li + class="stage-column" + :class="stageConnectorClass"> + <div class="stage-name"> + {{title}} + </div> + <div class="builds-container"> + <ul> + <li + v-for="(job, index) in jobs" + :key="job.id" + class="build" + :class="buildConnnectorClass(index)" + :id="jobId(job)"> + + <div class="curve"></div> + + <job-component + v-if="job.size === 1" + :job="job" + css-class-job-name="build-content" + /> + + <dropdown-job-component + v-if="job.size > 1" + :job="job" + /> + + </li> + </ul> + </div> + </li> +</template> diff --git a/app/assets/javascripts/vue_pipelines_index/components/nav_controls.js b/app/assets/javascripts/pipelines/components/nav_controls.js index 6aa10531034..6aa10531034 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/nav_controls.js +++ b/app/assets/javascripts/pipelines/components/nav_controls.js diff --git a/app/assets/javascripts/vue_pipelines_index/components/navigation_tabs.js b/app/assets/javascripts/pipelines/components/navigation_tabs.js index 1626ae17a30..1626ae17a30 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/navigation_tabs.js +++ b/app/assets/javascripts/pipelines/components/navigation_tabs.js diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js index 4e183d5c8ec..7cd2e0f9366 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/pipeline_url.js +++ b/app/assets/javascripts/pipelines/components/pipeline_url.js @@ -1,3 +1,5 @@ +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + export default { props: [ 'pipeline', @@ -7,6 +9,9 @@ export default { return !!this.pipeline.user; }, }, + components: { + userAvatarLink, + }, template: ` <td> <a @@ -15,21 +20,16 @@ export default { <span class="pipeline-id">#{{pipeline.id}}</span> </a> <span>by</span> - <a - class="js-pipeline-url-user" + <user-avatar-link v-if="user" - :href="pipeline.user.web_url"> - <img - v-if="user" - class="avatar has-tooltip s20 " - :title="pipeline.user.name" - data-container="body" - :src="pipeline.user.avatar_url" - > - </a> + class="js-pipeline-url-user" + :link-href="pipeline.user.web_url" + :img-src="pipeline.user.avatar_url" + :tooltip-text="pipeline.user.name" + /> <span v-if="!user" - class="js-pipeline-url-api api monospace"> + class="js-pipeline-url-api api"> API </span> <span diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js index 4bb2b048884..b9e066c5db1 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/pipelines_actions.js +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.js @@ -3,6 +3,7 @@ import '~/flash'; import playIconSvg from 'icons/_icon_play.svg'; import eventHub from '../event_hub'; +import loadingIconComponent from '../../vue_shared/components/loading_icon.vue'; export default { props: { @@ -17,6 +18,10 @@ export default { }, }, + components: { + loadingIconComponent, + }, + data() { return { playIconSvg, @@ -28,6 +33,8 @@ export default { onClickAction(endpoint) { this.isLoading = true; + $(this.$refs.tooltip).tooltip('destroy'); + this.service.postAction(endpoint) .then(() => { this.isLoading = false; @@ -38,6 +45,14 @@ export default { new Flash('An error occured while making the request.'); }); }, + + isActionDisabled(action) { + if (action.playable === undefined) { + return false; + } + + return !action.playable; + }, }, template: ` @@ -49,18 +64,23 @@ export default { data-toggle="dropdown" data-placement="top" aria-label="Manual job" + ref="tooltip" :disabled="isLoading"> ${playIconSvg} - <i class="fa fa-caret-down" aria-hidden="true"></i> - <i v-if="isLoading" class="fa fa-spinner fa-spin" aria-hidden="true"></i> + <i + class="fa fa-caret-down" + aria-hidden="true" /> + <loading-icon v-if="isLoading" /> </button> <ul class="dropdown-menu dropdown-menu-align-right"> <li v-for="action in actions"> <button type="button" - class="js-pipeline-action-link no-btn" - @click="onClickAction(action.path)"> + class="js-pipeline-action-link no-btn btn" + @click="onClickAction(action.path)" + :class="{ 'disabled': isActionDisabled(action) }" + :disabled="isActionDisabled(action)"> ${playIconSvg} <span>{{action.name}}</span> </button> diff --git a/app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js b/app/assets/javascripts/pipelines/components/pipelines_artifacts.js index 3555040d60f..f18e2dfadaf 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/pipelines_artifacts.js +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.js @@ -21,6 +21,7 @@ export default { <li v-for="artifact in artifacts"> <a rel="nofollow" + download :href="artifact.path"> <i class="fa fa-download" aria-hidden="true"></i> <span>Download {{artifact.name}} artifacts</span> diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue new file mode 100644 index 00000000000..7fc19fce1ff --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -0,0 +1,170 @@ +<script> + +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +/* global Flash */ +import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + stage: { + type: Object, + required: true, + }, + + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + isLoading: false, + dropdownContent: '', + endpoint: this.stage.dropdown_path, + }; + }, + + components: { + loadingIcon, + }, + + updated() { + if (this.dropdownContent.length > 0) { + this.stopDropdownClickPropagation(); + } + }, + + watch: { + updateDropdown() { + if (this.updateDropdown && + this.isDropdownOpen() && + !this.isLoading) { + this.fetchJobs(); + } + }, + }, + + methods: { + onClickStage() { + if (!this.isDropdownOpen()) { + this.isLoading = true; + this.fetchJobs(); + } + }, + + fetchJobs() { + this.$http.get(this.endpoint) + .then((response) => { + this.dropdownContent = response.json().html; + this.isLoading = false; + }) + .catch(() => { + this.closeDropdown(); + this.isLoading = false; + + const flash = new Flash('Something went wrong on our end.'); + return flash; + }); + }, + + /** + * When the user right clicks or cmd/ctrl + click in the job name + * the dropdown should not be closed and the link should open in another tab, + * so we stop propagation of the click event inside the dropdown. + * + * Since this component is rendered multiple times per page we need to guarantee we only + * target the click event of this component. + */ + stopDropdownClickPropagation() { + $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')) + .on('click', (e) => { + e.stopPropagation(); + }); + }, + + closeDropdown() { + if (this.isDropdownOpen()) { + $(this.$refs.dropdown).dropdown('toggle'); + } + }, + + isDropdownOpen() { + return this.$el.classList.contains('open'); + }, + }, + + computed: { + dropdownClass() { + return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading'; + }, + + triggerButtonClass() { + return `ci-status-icon-${this.stage.status.group}`; + }, + + svgIcon() { + return borderlessStatusIconEntityMap[this.stage.status.icon]; + }, + }, +}; +</script> + +<template> + <div class="dropdown"> + <button + :class="triggerButtonClass" + @click="onClickStage" + class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button" + :title="stage.title" + data-placement="top" + data-toggle="dropdown" + type="button" + id="stageDropdown" + aria-haspopup="true" + aria-expanded="false"> + + <span + v-html="svgIcon" + aria-hidden="true" + :aria-label="stage.title"> + </span> + + <i + class="fa fa-caret-down" + aria-hidden="true"> + </i> + </button> + + <ul + class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" + aria-labelledby="stageDropdown"> + + <li + :class="dropdownClass" + class="js-builds-dropdown-list scrollable-menu"> + + <loading-icon v-if="isLoading"/> + + <ul + v-else + v-html="dropdownContent"> + </ul> + </li> + </ul> + </div> +</script> diff --git a/app/assets/javascripts/pipelines/components/time_ago.js b/app/assets/javascripts/pipelines/components/time_ago.js new file mode 100644 index 00000000000..188f74cc705 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/time_ago.js @@ -0,0 +1,98 @@ +import iconTimerSvg from 'icons/_icon_timer.svg'; +import '../../lib/utils/datetime_utility'; + +export default { + props: { + finishedTime: { + type: String, + required: true, + }, + + duration: { + type: Number, + required: true, + }, + }, + + data() { + return { + iconTimerSvg, + }; + }, + + updated() { + $(this.$refs.tooltip).tooltip('fixTitle'); + }, + + computed: { + hasDuration() { + return this.duration > 0; + }, + + hasFinishedTime() { + return this.finishedTime !== ''; + }, + + localTimeFinished() { + return gl.utils.formatDate(this.finishedTime); + }, + + durationFormated() { + const date = new Date(this.duration * 1000); + + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); + + // left pad + if (hh < 10) { + hh = `0${hh}`; + } + if (mm < 10) { + mm = `0${mm}`; + } + if (ss < 10) { + ss = `0${ss}`; + } + + return `${hh}:${mm}:${ss}`; + }, + + finishedTimeFormated() { + const timeAgo = gl.utils.getTimeago(); + + return timeAgo.format(this.finishedTime); + }, + }, + + template: ` + <td class="pipelines-time-ago"> + <p + class="duration" + v-if="hasDuration"> + <span + v-html="iconTimerSvg"> + </span> + {{durationFormated}} + </p> + + <p + class="finished-at" + v-if="hasFinishedTime"> + + <i + class="fa fa-calendar" + aria-hidden="true" /> + + <time + ref="tooltip" + data-toggle="tooltip" + data-placement="top" + data-container="body" + :title="localTimeFinished"> + {{finishedTimeFormated}} + </time> + </p> + </td> + `, +}; diff --git a/app/assets/javascripts/pipelines/event_hub.js b/app/assets/javascripts/pipelines/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/pipelines/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/pipelines/graph_bundle.js b/app/assets/javascripts/pipelines/graph_bundle.js new file mode 100644 index 00000000000..b7a6b5d8479 --- /dev/null +++ b/app/assets/javascripts/pipelines/graph_bundle.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import pipelineGraph from './components/graph/graph_component.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-pipeline-graph-vue', + components: { + pipelineGraph, + }, + render: createElement => createElement('pipeline-graph'), +})); diff --git a/app/assets/javascripts/vue_pipelines_index/index.js b/app/assets/javascripts/pipelines/index.js index 48f9181a8d9..48f9181a8d9 100644 --- a/app/assets/javascripts/vue_pipelines_index/index.js +++ b/app/assets/javascripts/pipelines/index.js diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 9bdc232b7da..d6952d1ee5f 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -1,12 +1,14 @@ -import Vue from 'vue'; +import Visibility from 'visibilityjs'; import PipelinesService from './services/pipelines_service'; import eventHub from './event_hub'; -import PipelinesTableComponent from '../vue_shared/components/pipelines_table'; -import TablePaginationComponent from '../vue_shared/components/table_pagination'; -import EmptyState from './components/empty_state'; -import ErrorState from './components/error_state'; -import NavigationTabs from './components/navigation_tabs'; -import NavigationControls from './components/nav_controls'; +import pipelinesTableComponent from '../vue_shared/components/pipelines_table'; +import tablePagination from '../vue_shared/components/table_pagination.vue'; +import emptyState from './components/empty_state.vue'; +import errorState from './components/error_state.vue'; +import navigationTabs from './components/navigation_tabs'; +import navigationControls from './components/nav_controls'; +import loadingIcon from '../vue_shared/components/loading_icon.vue'; +import Poll from '../lib/utils/poll'; export default { props: { @@ -17,12 +19,13 @@ export default { }, components: { - 'gl-pagination': TablePaginationComponent, - 'pipelines-table-component': PipelinesTableComponent, - 'empty-state': EmptyState, - 'error-state': ErrorState, - 'navigation-tabs': NavigationTabs, - 'navigation-controls': NavigationControls, + tablePagination, + pipelinesTableComponent, + emptyState, + errorState, + navigationTabs, + navigationControls, + loadingIcon, }, data() { @@ -47,6 +50,9 @@ export default { pagenum: 1, isLoading: false, hasError: false, + isMakingRequest: false, + updateGraphDropdown: false, + hasMadeRequest: false, }; }, @@ -73,6 +79,7 @@ export default { shouldRenderEmptyState() { return !this.isLoading && !this.hasError && + this.hasMadeRequest && !this.state.pipelines.length && (this.scope === 'all' || this.scope === null); }, @@ -120,20 +127,46 @@ export default { tagsPath: this.tagsPath, }; }, + + pageParameter() { + return gl.utils.getParameterByName('page') || this.pagenum; + }, + + scopeParameter() { + return gl.utils.getParameterByName('scope') || this.apiScope; + }, }, created() { this.service = new PipelinesService(this.endpoint); - this.fetchPipelines(); - - eventHub.$on('refreshPipelines', this.fetchPipelines); - }, + const poll = new Poll({ + resource: this.service, + method: 'getPipelines', + data: { page: this.pageParameter, scope: this.scopeParameter }, + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: this.setIsMakingRequest, + }); - beforeUpdate() { - if (this.state.pipelines.length && this.$children) { - this.store.startTimeAgoLoops.call(this, Vue); + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } else { + // If tab is not visible we need to make the first request so we don't show the empty + // state without knowing if there are any pipelines + this.fetchPipelines(); } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + + eventHub.$on('refreshPipelines', this.fetchPipelines); }, beforeDestroyed() { @@ -154,27 +187,42 @@ export default { }, fetchPipelines() { - const pageNumber = gl.utils.getParameterByName('page') || this.pagenum; - const scope = gl.utils.getParameterByName('scope') || this.apiScope; + if (!this.isMakingRequest) { + this.isLoading = true; - this.isLoading = true; - return this.service.getPipelines(scope, pageNumber) - .then(resp => ({ - headers: resp.headers, - body: resp.json(), - })) - .then((response) => { - this.store.storeCount(response.body.count); - this.store.storePipelines(response.body.pipelines); - this.store.storePagination(response.headers); - }) - .then(() => { - this.isLoading = false; - }) - .catch(() => { - this.hasError = true; - this.isLoading = false; - }); + this.service.getPipelines({ scope: this.scopeParameter, page: this.pageParameter }) + .then(response => this.successCallback(response)) + .catch(() => this.errorCallback()); + } + }, + + successCallback(resp) { + const response = { + headers: resp.headers, + body: resp.json(), + }; + + this.store.storeCount(response.body.count); + this.store.storePipelines(response.body.pipelines); + this.store.storePagination(response.headers); + + this.isLoading = false; + this.updateGraphDropdown = true; + this.hasMadeRequest = true; + }, + + errorCallback() { + this.hasError = true; + this.isLoading = false; + this.updateGraphDropdown = false; + }, + + setIsMakingRequest(isMakingRequest) { + this.isMakingRequest = isMakingRequest; + + if (isMakingRequest) { + this.updateGraphDropdown = false; + } }, }, @@ -205,13 +253,11 @@ export default { <div class="content-list pipelines"> - <div - class="realtime-loading" - v-if="isLoading"> - <i - class="fa fa-spinner fa-spin" - aria-hidden="true" /> - </div> + <loading-icon + label="Loading Pipelines" + size="3" + v-if="isLoading" + /> <empty-state v-if="shouldRenderEmptyState" @@ -231,15 +277,18 @@ export default { <pipelines-table-component :pipelines="state.pipelines" - :service="service"/> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </div> - <gl-pagination + <table-pagination v-if="shouldRenderPagination" :pagenum="pagenum" :change="change" :count="state.count.all" - :pageInfo="state.pageInfo"/> + :pageInfo="state.pageInfo" + /> </div> </div> `, diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js new file mode 100644 index 00000000000..f1cc60c1ee0 --- /dev/null +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class PipelineService { + constructor(endpoint) { + this.pipeline = Vue.resource(endpoint); + } + + getPipeline() { + return this.pipeline.get(); + } +} diff --git a/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index 708f5068dd3..b21f84b4545 100644 --- a/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -26,7 +26,8 @@ export default class PipelinesService { this.pipelines = Vue.resource(endpoint); } - getPipelines(scope, page) { + getPipelines(data = {}) { + const { scope, page } = data; return this.pipelines.get({ scope, page }); } @@ -39,6 +40,6 @@ export default class PipelinesService { * @return {Promise} */ postAction(endpoint) { - return Vue.http.post(endpoint, {}, { emulateJSON: true }); + return Vue.http.post(`${endpoint}.json`); } } diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js new file mode 100644 index 00000000000..86ab50d8f1e --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -0,0 +1,11 @@ +export default class PipelineStore { + constructor() { + this.state = {}; + + this.state.graph = []; + } + + storeGraph(graph = []) { + this.state.graph = graph; + } +} diff --git a/app/assets/javascripts/pipelines/stores/pipelines_store.js b/app/assets/javascripts/pipelines/stores/pipelines_store.js new file mode 100644 index 00000000000..ffefe0192f2 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/pipelines_store.js @@ -0,0 +1,30 @@ +export default class PipelinesStore { + constructor() { + this.state = {}; + + this.state.pipelines = []; + this.state.count = {}; + this.state.pageInfo = {}; + } + + storePipelines(pipelines = []) { + this.state.pipelines = pipelines; + } + + storeCount(count = {}) { + this.state.count = count; + } + + storePagination(pagination = {}) { + let paginationInfo; + + if (Object.keys(pagination).length) { + const normalizedHeaders = gl.utils.normalizeHeaders(pagination); + paginationInfo = gl.utils.parseIntPagination(normalizedHeaders); + } else { + paginationInfo = pagination; + } + + this.state.pageInfo = paginationInfo; + } +} diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 07eea98e737..4a3df2fd465 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -2,8 +2,9 @@ // MarkdownPreview // -// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview, -// and showing a warning when more than `x` users are referenced. +// Handles toggling the "Write" and "Preview" tab clicks, rendering the preview +// (including the explanation of slash commands), and showing a warning when +// more than `x` users are referenced. // (function () { var lastTextareaPreviewed; @@ -17,32 +18,45 @@ // Minimum number of users referenced before triggering a warning MarkdownPreview.prototype.referenceThreshold = 10; + MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.'; MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function ($form) { var mdText; var preview = $form.find('.js-md-preview'); + var url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } mdText = $form.find('textarea.markdown-area').val(); if (mdText.trim().length === 0) { - preview.text('Nothing to preview.'); + preview.text(this.emptyMessage); this.hideReferencedUsers($form); } else { preview.addClass('md-preview-loading').text('Loading...'); - this.fetchMarkdownPreview(mdText, (function (response) { - preview.removeClass('md-preview-loading').html(response.body); + this.fetchMarkdownPreview(mdText, url, (function (response) { + var body; + if (response.body.length > 0) { + body = response.body; + } else { + body = this.emptyMessage; + } + + preview.removeClass('md-preview-loading').html(body); preview.renderGFM(); this.renderReferencedUsers(response.references.users, $form); + + if (response.references.commands) { + this.renderReferencedCommands(response.references.commands, $form); + } }).bind(this)); } }; - MarkdownPreview.prototype.fetchMarkdownPreview = function (text, success) { - if (!window.preview_markdown_path) { + MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { + if (!url) { return; } if (text === this.ajaxCache.text) { @@ -51,7 +65,7 @@ } $.ajax({ type: 'POST', - url: window.preview_markdown_path, + url: url, data: { text: text }, @@ -83,6 +97,22 @@ } }; + MarkdownPreview.prototype.hideReferencedCommands = function ($form) { + $form.find('.referenced-commands').hide(); + }; + + MarkdownPreview.prototype.renderReferencedCommands = function (commands, $form) { + var referencedCommands; + referencedCommands = $form.find('.referenced-commands'); + if (commands.length > 0) { + referencedCommands.html(commands); + referencedCommands.show(); + } else { + referencedCommands.html(''); + referencedCommands.hide(); + } + }; + return MarkdownPreview; }()); @@ -137,6 +167,8 @@ $form.find('.md-write-holder').show(); $form.find('textarea.markdown-area').focus(); $form.find('.md-preview-holder').hide(); + + markdownPreview.hideReferencedCommands($form); }); $(document).on('markdown-preview:toggle', function (e, keyboardEvent) { diff --git a/app/assets/javascripts/profile/profile_bundle.js b/app/assets/javascripts/profile/profile_bundle.js index 15d32825583..ff35a9bcb83 100644 --- a/app/assets/javascripts/profile/profile_bundle.js +++ b/app/assets/javascripts/profile/profile_bundle.js @@ -1,2 +1,2 @@ -require('./gl_crop'); -require('./profile'); +import './gl_crop'; +import './profile'; diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index f944fcc5a58..738e710deb9 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -112,7 +112,8 @@ import Cookies from 'js-cookie'; toggleLabel: function(obj, $el) { return $el.text().trim(); }, - clicked: function(selected, $el, e) { + clicked: function(options) { + const { e } = options; e.preventDefault(); if ($('input[name="ref"]').length) { var $form = $dropdown.closest('form'); diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index e01668eabef..11f9754780d 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,18 +2,16 @@ /* global fuzzaldrinPlus */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.ProjectFindFile = (function() { var highlighter; function ProjectFindFile(element1, options) { this.element = element1; this.options = options; - this.goToBlob = bind(this.goToBlob, this); - this.goToTree = bind(this.goToTree, this); - this.selectRowDown = bind(this.selectRowDown, this); - this.selectRowUp = bind(this.selectRowUp, this); + this.goToBlob = this.goToBlob.bind(this); + this.goToTree = this.goToTree.bind(this); + this.selectRowDown = this.selectRowDown.bind(this); + this.selectRowUp = this.selectRowUp.bind(this); this.filePaths = {}; this.inputElement = this.element.find(".file-finder-input"); // init event diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js index e9927c1bf51..04b381fe0e0 100644 --- a/app/assets/javascripts/project_new.js +++ b/app/assets/javascripts/project_new.js @@ -1,11 +1,9 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.ProjectNew = (function() { function ProjectNew() { - this.toggleSettings = bind(this.toggleSettings, this); + this.toggleSettings = this.toggleSettings.bind(this); this.$selects = $('.features select'); this.$repoSelects = this.$selects.filter('.js-repo-select'); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 3c1c1e7dceb..0ff0a3b6cc4 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -1,5 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-var, comma-dangle, object-shorthand, one-var, one-var-declaration-per-line, no-else-return, quotes, max-len */ -/* global Api */ +import Api from './api'; (function() { this.ProjectSelect = (function() { diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js index e7fff57ff45..42993a252c3 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js @@ -19,7 +19,9 @@ return 'Select'; } }, - clicked(item, $el, e) { + clicked(opts) { + const { e } = opts; + e.preventDefault(); onSelect(); } diff --git a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js index 1d4bb8a13d6..bc6110fcd4e 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_dropdown.js +++ b/app/assets/javascripts/protected_branches/protected_branch_dropdown.js @@ -35,7 +35,8 @@ class ProtectedBranchDropdown { return _.escape(protectedBranch.id); }, onFilter: this.toggleCreateNewButton.bind(this), - clicked: (item, $el, e) => { + clicked: (options) => { + const { $el, e } = options; e.preventDefault(); this.onSelect(); } diff --git a/app/assets/javascripts/protected_branches/protected_branches_bundle.js b/app/assets/javascripts/protected_branches/protected_branches_bundle.js index 849c1e31623..874d70a1431 100644 --- a/app/assets/javascripts/protected_branches/protected_branches_bundle.js +++ b/app/assets/javascripts/protected_branches/protected_branches_bundle.js @@ -1,5 +1,5 @@ -require('./protected_branch_access_dropdown'); -require('./protected_branch_create'); -require('./protected_branch_dropdown'); -require('./protected_branch_edit'); -require('./protected_branch_edit_list'); +import './protected_branch_access_dropdown'; +import './protected_branch_create'; +import './protected_branch_dropdown'; +import './protected_branch_edit'; +import './protected_branch_edit_list'; diff --git a/app/assets/javascripts/protected_tags/index.js b/app/assets/javascripts/protected_tags/index.js new file mode 100644 index 00000000000..61e7ba53862 --- /dev/null +++ b/app/assets/javascripts/protected_tags/index.js @@ -0,0 +1,2 @@ +export { default as ProtectedTagCreate } from './protected_tag_create'; +export { default as ProtectedTagEditList } from './protected_tag_edit_list'; diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js new file mode 100644 index 00000000000..d4c9a91a74a --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js @@ -0,0 +1,26 @@ +export default class ProtectedTagAccessDropdown { + constructor(options) { + this.options = options; + this.initDropdown(); + } + + initDropdown() { + const { onSelect } = this.options; + this.options.$dropdown.glDropdown({ + data: this.options.data, + selectable: true, + inputId: this.options.$dropdown.data('input-id'), + fieldName: this.options.$dropdown.data('field-name'), + toggleLabel(item, $el) { + if ($el.is('.is-active')) { + return item.text; + } + return 'Select'; + }, + clicked(options) { + options.e.preventDefault(); + onSelect(); + }, + }); + } +} diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js new file mode 100644 index 00000000000..91bd140bd12 --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -0,0 +1,41 @@ +import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; +import ProtectedTagDropdown from './protected_tag_dropdown'; + +export default class ProtectedTagCreate { + constructor() { + this.$form = $('.js-new-protected-tag'); + this.buildDropdowns(); + } + + buildDropdowns() { + const $allowedToCreateDropdown = this.$form.find('.js-allowed-to-create'); + + // Cache callback + this.onSelectCallback = this.onSelect.bind(this); + + // Allowed to Create dropdown + this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({ + $dropdown: $allowedToCreateDropdown, + data: gon.create_access_levels, + onSelect: this.onSelectCallback, + }); + + // Select default + $allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0); + + // Protected tag dropdown + this.protectedTagDropdown = new ProtectedTagDropdown({ + $dropdown: this.$form.find('.js-protected-tag-select'), + onSelect: this.onSelectCallback, + }); + } + + // This will run after clicked callback + onSelect() { + // Enable submit button + const $tagInput = this.$form.find('input[name="protected_tag[name]"]'); + const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes'); + + this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length)); + } +} diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js new file mode 100644 index 00000000000..068e9698e1d --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -0,0 +1,86 @@ +export default class ProtectedTagDropdown { + /** + * @param {Object} options containing + * `$dropdown` target element + * `onSelect` event callback + * $dropdown must be an element created using `dropdown_tag()` rails helper + */ + constructor(options) { + this.onSelect = options.onSelect; + this.$dropdown = options.$dropdown; + this.$dropdownContainer = this.$dropdown.parent(); + this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); + this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag'); + + this.buildDropdown(); + this.bindEvents(); + + // Hide footer + this.toggleFooter(true); + } + + buildDropdown() { + this.$dropdown.glDropdown({ + data: this.getProtectedTags.bind(this), + filterable: true, + remote: false, + search: { + fields: ['title'], + }, + selectable: true, + toggleLabel(selected) { + return (selected && 'id' in selected) ? selected.title : 'Protected Tag'; + }, + fieldName: 'protected_tag[name]', + text(protectedTag) { + return _.escape(protectedTag.title); + }, + id(protectedTag) { + return _.escape(protectedTag.id); + }, + onFilter: this.toggleCreateNewButton.bind(this), + clicked: (options) => { + options.e.preventDefault(); + this.onSelect(); + }, + }); + } + + bindEvents() { + this.$protectedTag.on('click', this.onClickCreateWildcard.bind(this)); + } + + onClickCreateWildcard(e) { + this.$dropdown.data('glDropdown').remote.execute(); + this.$dropdown.data('glDropdown').selectRowAtIndex(); + e.preventDefault(); + } + + getProtectedTags(term, callback) { + if (this.selectedTag) { + callback(gon.open_tags.concat(this.selectedTag)); + } else { + callback(gon.open_tags); + } + } + + toggleCreateNewButton(tagName) { + if (tagName) { + this.selectedTag = { + title: tagName, + id: tagName, + text: tagName, + }; + + this.$dropdownContainer + .find('.create-new-protected-tag code') + .text(tagName); + } + + this.toggleFooter(!tagName); + } + + toggleFooter(toggleState) { + this.$dropdownFooter.toggleClass('hidden', toggleState); + } +} diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js new file mode 100644 index 00000000000..09a387c0f9e --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js @@ -0,0 +1,52 @@ +/* eslint-disable no-new */ +/* global Flash */ + +import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; + +export default class ProtectedTagEdit { + constructor(options) { + this.$wrap = options.$wrap; + this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create'); + this.onSelectCallback = this.onSelect.bind(this); + + this.buildDropdowns(); + } + + buildDropdowns() { + // Allowed to create dropdown + this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({ + $dropdown: this.$allowedToCreateDropdownButton, + data: gon.create_access_levels, + onSelect: this.onSelectCallback, + }); + } + + onSelect() { + const $allowedToCreateInput = this.$wrap.find(`input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`); + + // Do not update if one dropdown has not selected any option + if (!$allowedToCreateInput.length) return; + + this.$allowedToCreateDropdownButton.disable(); + + $.ajax({ + type: 'POST', + url: this.$wrap.data('url'), + dataType: 'json', + data: { + _method: 'PATCH', + protected_tag: { + create_access_levels_attributes: [{ + id: this.$allowedToCreateDropdownButton.data('access-level-id'), + access_level: $allowedToCreateInput.val(), + }], + }, + }, + error() { + new Flash('Failed to update tag!', null, $('.js-protected-tags-list')); + }, + }).always(() => { + this.$allowedToCreateDropdownButton.enable(); + }); + } +} diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js new file mode 100644 index 00000000000..bd9fc872266 --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js @@ -0,0 +1,18 @@ +/* eslint-disable no-new */ + +import ProtectedTagEdit from './protected_tag_edit'; + +export default class ProtectedTagEditList { + constructor() { + this.$wrap = $('.protected-tags-list'); + this.initEditForm(); + } + + initEditForm() { + this.$wrap.find('.js-protected-tag-edit-form').each((i, el) => { + new ProtectedTagEdit({ + $wrap: $(el), + }); + }); + } +} diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js new file mode 100644 index 00000000000..5325e495815 --- /dev/null +++ b/app/assets/javascripts/raven/index.js @@ -0,0 +1,16 @@ +import RavenConfig from './raven_config'; + +const index = function index() { + RavenConfig.init({ + sentryDsn: gon.sentry_dsn, + currentUserId: gon.current_user_id, + whitelistUrls: [gon.gitlab_url], + isProduction: process.env.NODE_ENV, + }); + + return RavenConfig; +}; + +index(); + +export default index; diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js new file mode 100644 index 00000000000..c7fe1cacf49 --- /dev/null +++ b/app/assets/javascripts/raven/raven_config.js @@ -0,0 +1,100 @@ +import Raven from 'raven-js'; + +const IGNORE_ERRORS = [ + // Random plugins/extensions + 'top.GLOBALS', + // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html + 'originalCreateNotification', + 'canvas.contentDocument', + 'MyApp_RemoveAllHighlights', + 'http://tt.epicplay.com', + 'Can\'t find variable: ZiteReader', + 'jigsaw is not defined', + 'ComboSearch is not defined', + 'http://loading.retry.widdit.com/', + 'atomicFindClose', + // Facebook borked + 'fb_xd_fragment', + // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to + // reduce this. (thanks @acdha) + // See http://stackoverflow.com/questions/4113268 + 'bmi_SafeAddOnload', + 'EBCallBackMessageReceived', + // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx + 'conduitPage', +]; + +const IGNORE_URLS = [ + // Facebook flakiness + /graph\.facebook\.com/i, + // Facebook blocked + /connect\.facebook\.net\/en_US\/all\.js/i, + // Woopra flakiness + /eatdifferent\.com\.woopra-ns\.com/i, + /static\.woopra\.com\/js\/woopra\.js/i, + // Chrome extensions + /extensions\//i, + /^chrome:\/\//i, + // Other plugins + /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb + /webappstoolbarba\.texthelp\.com\//i, + /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, +]; + +const SAMPLE_RATE = 95; + +const RavenConfig = { + IGNORE_ERRORS, + IGNORE_URLS, + SAMPLE_RATE, + init(options = {}) { + this.options = options; + + this.configure(); + this.bindRavenErrors(); + if (this.options.currentUserId) this.setUser(); + }, + + configure() { + Raven.config(this.options.sentryDsn, { + whitelistUrls: this.options.whitelistUrls, + environment: this.options.isProduction ? 'production' : 'development', + ignoreErrors: this.IGNORE_ERRORS, + ignoreUrls: this.IGNORE_URLS, + shouldSendCallback: this.shouldSendSample.bind(this), + }).install(); + }, + + setUser() { + Raven.setUserContext({ + id: this.options.currentUserId, + }); + }, + + bindRavenErrors() { + window.$(document).on('ajaxError.raven', this.handleRavenErrors); + }, + + handleRavenErrors(event, req, config, err) { + const error = err || req.statusText; + const responseText = req.responseText || 'Unknown response text'; + + Raven.captureMessage(error, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: responseText, + error, + event, + }, + }); + }, + + shouldSendSample() { + return Math.random() * 100 <= this.SAMPLE_RATE; + }, +}; + +export default RavenConfig; diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js new file mode 100644 index 00000000000..215cd6fbdfd --- /dev/null +++ b/app/assets/javascripts/ref_select_dropdown.js @@ -0,0 +1,46 @@ +class RefSelectDropdown { + constructor($dropdownButton, availableRefs) { + $dropdownButton.glDropdown({ + data: availableRefs, + filterable: true, + filterByText: true, + remote: false, + fieldName: $dropdownButton.data('field-name'), + filterInput: 'input[type="search"]', + selectable: true, + isSelectable(branch, $el) { + return !$el.hasClass('is-active'); + }, + text(branch) { + return branch; + }, + id(branch) { + return branch; + }, + toggleLabel(branch) { + return branch; + }, + }); + + const $dropdownContainer = $dropdownButton.closest('.dropdown'); + const $fieldInput = $(`input[name="${$dropdownButton.data('field-name')}"]`, $dropdownContainer); + const $filterInput = $('input[type="search"]', $dropdownContainer); + + $filterInput.on('keyup', (e) => { + const keyCode = e.keyCode || e.which; + if (keyCode !== 13) return; + + const ref = $filterInput.val().trim(); + if (ref === '') { + return; + } + + $fieldInput.val(ref); + $('.dropdown-toggle-text', $dropdownButton).text(ref); + + $dropdownContainer.removeClass('open'); + }); + } +} + +export default RefSelectDropdown; diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/render_gfm.js index ea91aaa10a6..2c3a9cacd38 100644 --- a/app/assets/javascripts/render_gfm.js +++ b/app/assets/javascripts/render_gfm.js @@ -8,6 +8,7 @@ $.fn.renderGFM = function() { this.find('.js-syntax-highlight').syntaxHighlight(); this.find('.js-render-math').renderMath(); + return this; }; $(document).on('ready load', function() { diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index a9b3de281e1..b71c3097706 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,11 +3,9 @@ import Cookies from 'js-cookie'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Sidebar = (function() { function Sidebar(currentUser) { - this.toggleTodo = bind(this.toggleTodo, this); + this.toggleTodo = this.toggleTodo.bind(this); this.sidebar = $('aside'); this.removeListeners(); this.addEventListeners(); diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js index 15f5963353a..05caf177aec 100644 --- a/app/assets/javascripts/search.js +++ b/app/assets/javascripts/search.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, object-shorthand, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-else-return, max-len */ -/* global Api */ +/* global Flash */ +import Api from './api'; (function() { this.Search = (function() { @@ -7,6 +8,7 @@ var $groupDropdown, $projectDropdown; $groupDropdown = $('.js-search-group-dropdown'); $projectDropdown = $('.js-search-project-dropdown'); + this.groupId = $groupDropdown.data('group-id'); this.eventListeners(); $groupDropdown.glDropdown({ selectable: true, @@ -46,14 +48,18 @@ search: { fields: ['name'] }, - data: function(term, callback) { - return Api.projects(term, { order_by: 'id' }, function(data) { - data.unshift({ - name_with_namespace: 'Any' - }); - data.splice(1, 0, 'divider'); - return callback(data); - }); + data: (term, callback) => { + this.getProjectsData(term) + .then((data) => { + data.unshift({ + name_with_namespace: 'Any' + }); + data.splice(1, 0, 'divider'); + + return data; + }) + .then(data => callback(data)) + .catch(() => new Flash('Error fetching projects')); }, id: function(obj) { return obj.id; @@ -95,6 +101,18 @@ return $('.js-search-input').val('').trigger('keyup').focus(); }; + Search.prototype.getProjectsData = function(term) { + return new Promise((resolve) => { + if (this.groupId) { + Api.groupProjects(this.groupId, term, resolve); + } else { + Api.projects(term, { + order_by: 'id', + }, resolve); + } + }); + }; + return Search; })(); }).call(window); diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index fd5097696ad..8ac71797c14 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -1,24 +1,45 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, prefer-arrow-callback, consistent-return, object-shorthand, no-unused-vars, one-var, one-var-declaration-per-line, no-else-return, comma-dangle, max-len */ /* global Mousetrap */ /* global findFileURL */ +import findAndFollowLink from './shortcuts_dashboard_navigation'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Shortcuts = (function() { function Shortcuts(skipResetBindings) { - this.onToggleHelp = bind(this.onToggleHelp, this); + this.onToggleHelp = this.onToggleHelp.bind(this); this.enabledHelp = []; if (!skipResetBindings) { Mousetrap.reset(); } Mousetrap.bind('?', this.onToggleHelp); Mousetrap.bind('s', Shortcuts.focusSearch); - Mousetrap.bind('f', (function(_this) { - return function(e) { - return _this.focusFilter(e); - }; - })(this)); + Mousetrap.bind('f', (e => this.focusFilter(e))); + + const $globalDropdownMenu = $('.global-dropdown-menu'); + const $globalDropdownToggle = $('.global-dropdown-toggle'); + + $('.global-dropdown').on('hide.bs.dropdown', () => { + $globalDropdownMenu.removeClass('shortcuts'); + }); + + Mousetrap.bind('n', () => { + $globalDropdownMenu.toggleClass('shortcuts'); + $globalDropdownToggle.trigger('click'); + + if (!$globalDropdownMenu.is(':visible')) { + $globalDropdownToggle.blur(); + } + }); + + Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos')); + Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity')); + Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues')); + Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests')); + Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects')); + Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups')); + Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones')); + Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets')); + Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], this.toggleMarkdownPreview); if (typeof findFileURL !== "undefined" && findFileURL !== null) { Mousetrap.bind('t', function() { @@ -34,8 +55,11 @@ Shortcuts.prototype.toggleMarkdownPreview = function(e) { // Check if short-cut was triggered while in Write Mode - if ($(e.target).hasClass('js-note-text')) { - $('.js-md-preview-button').focus(); + const $target = $(e.target); + const $form = $target.closest('form'); + + if ($target.hasClass('js-note-text')) { + $('.js-md-preview-button', $form).focus(); } return $(document).triggerHandler('markdown-preview:toggle', [e]); }; diff --git a/app/assets/javascripts/shortcuts_blob.js b/app/assets/javascripts/shortcuts_blob.js index bfe90aef71e..ccbf7c59165 100644 --- a/app/assets/javascripts/shortcuts_blob.js +++ b/app/assets/javascripts/shortcuts_blob.js @@ -1,14 +1,14 @@ /* global Mousetrap */ /* global Shortcuts */ -require('./shortcuts'); +import './shortcuts'; const defaults = { skipResetBindings: false, fileBlobPermalinkUrl: null, }; -class ShortcutsBlob extends Shortcuts { +export default class ShortcutsBlob extends Shortcuts { constructor(opts) { const options = Object.assign({}, defaults, opts); super(options.skipResetBindings); @@ -25,5 +25,3 @@ class ShortcutsBlob extends Shortcuts { } } } - -module.exports = ShortcutsBlob; diff --git a/app/assets/javascripts/shortcuts_dashboard_navigation.js b/app/assets/javascripts/shortcuts_dashboard_navigation.js index 4f1a19924a4..25f39e4fdb6 100644 --- a/app/assets/javascripts/shortcuts_dashboard_navigation.js +++ b/app/assets/javascripts/shortcuts_dashboard_navigation.js @@ -1,43 +1,12 @@ -/* eslint-disable func-names, space-before-function-paren, max-len, one-var, no-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, prefer-arrow-callback, consistent-return, no-return-assign */ -/* global Mousetrap */ -/* global Shortcuts */ - -require('./shortcuts'); - -(function() { - var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, - hasProp = {}.hasOwnProperty; - - this.ShortcutsDashboardNavigation = (function(superClass) { - extend(ShortcutsDashboardNavigation, superClass); - - function ShortcutsDashboardNavigation() { - ShortcutsDashboardNavigation.__super__.constructor.call(this); - Mousetrap.bind('g a', function() { - return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-activity'); - }); - Mousetrap.bind('g i', function() { - return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-issues'); - }); - Mousetrap.bind('g m', function() { - return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-merge_requests'); - }); - Mousetrap.bind('g t', function() { - return ShortcutsDashboardNavigation.findAndFollowLink('.shortcuts-todos'); - }); - Mousetrap.bind('g p', function() { - return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-projects'); - }); - } - - ShortcutsDashboardNavigation.findAndFollowLink = function(selector) { - var link; - link = $(selector).attr('href'); - if (link) { - return window.location = link; - } - }; - - return ShortcutsDashboardNavigation; - })(Shortcuts); -}).call(window); +/** + * Helper function that finds the href of the fiven selector and updates the location. + * + * @param {String} selector + */ +export default (selector) => { + const link = document.querySelector(selector).getAttribute('href'); + + if (link) { + window.location = link; + } +}; diff --git a/app/assets/javascripts/shortcuts_find_file.js b/app/assets/javascripts/shortcuts_find_file.js index a27ac264a5c..b18b6139b35 100644 --- a/app/assets/javascripts/shortcuts_find_file.js +++ b/app/assets/javascripts/shortcuts_find_file.js @@ -2,7 +2,7 @@ /* global Mousetrap */ /* global ShortcutsNavigation */ -require('./shortcuts_navigation'); +import './shortcuts_navigation'; (function() { var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index fe58e98cee5..b07b3a4d3a5 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -3,8 +3,8 @@ /* global ShortcutsNavigation */ /* global sidebar */ -require('mousetrap'); -require('./shortcuts_navigation'); +import 'mousetrap'; +import './shortcuts_navigation'; (function() { var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js index 3f5d6724417..55bae0c08a1 100644 --- a/app/assets/javascripts/shortcuts_navigation.js +++ b/app/assets/javascripts/shortcuts_navigation.js @@ -2,7 +2,8 @@ /* global Mousetrap */ /* global Shortcuts */ -require('./shortcuts'); +import findAndFollowLink from './shortcuts_dashboard_navigation'; +import './shortcuts'; (function() { var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, @@ -13,59 +14,23 @@ require('./shortcuts'); function ShortcutsNavigation() { ShortcutsNavigation.__super__.constructor.call(this); - Mousetrap.bind('g p', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-project'); - }); - Mousetrap.bind('g e', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-project-activity'); - }); - Mousetrap.bind('g f', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-tree'); - }); - Mousetrap.bind('g c', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-commits'); - }); - Mousetrap.bind('g b', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-builds'); - }); - Mousetrap.bind('g n', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-network'); - }); - Mousetrap.bind('g g', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-repository-charts'); - }); - Mousetrap.bind('g i', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-issues'); - }); - Mousetrap.bind('g l', function() { - ShortcutsNavigation.findAndFollowLink('.shortcuts-issue-boards'); - }); - Mousetrap.bind('g m', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-merge_requests'); - }); - Mousetrap.bind('g t', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-todos'); - }); - Mousetrap.bind('g w', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-wiki'); - }); - Mousetrap.bind('g s', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-snippets'); - }); - Mousetrap.bind('i', function() { - return ShortcutsNavigation.findAndFollowLink('.shortcuts-new-issue'); - }); + Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project')); + Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-project-activity')); + Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree')); + Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits')); + Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds')); + Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network')); + Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts')); + Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues')); + Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards')); + Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests')); + Mousetrap.bind('g t', () => findAndFollowLink('.shortcuts-todos')); + Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki')); + Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets')); + Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue')); this.enabledHelp.push('.hidden-shortcut.project'); } - ShortcutsNavigation.findAndFollowLink = function(selector) { - var link; - link = $(selector).attr('href'); - if (link) { - return window.location = link; - } - }; - return ShortcutsNavigation; })(Shortcuts); }).call(window); diff --git a/app/assets/javascripts/shortcuts_network.js b/app/assets/javascripts/shortcuts_network.js index 4c2bf8bf001..cc44082efa9 100644 --- a/app/assets/javascripts/shortcuts_network.js +++ b/app/assets/javascripts/shortcuts_network.js @@ -2,7 +2,7 @@ /* global Mousetrap */ /* global ShortcutsNavigation */ -require('./shortcuts_navigation'); +import './shortcuts_navigation'; (function() { var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, diff --git a/app/assets/javascripts/shortcuts_wiki.js b/app/assets/javascripts/shortcuts_wiki.js new file mode 100644 index 00000000000..8a075062a48 --- /dev/null +++ b/app/assets/javascripts/shortcuts_wiki.js @@ -0,0 +1,16 @@ +/* eslint-disable class-methods-use-this */ +/* global Mousetrap */ +/* global ShortcutsNavigation */ + +import findAndFollowLink from './shortcuts_dashboard_navigation'; + +export default class ShortcutsWiki extends ShortcutsNavigation { + constructor() { + super(); + Mousetrap.bind('e', this.editWiki); + } + + editWiki() { + findAndFollowLink('.js-wiki-edit'); + } +} diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js new file mode 100644 index 00000000000..a9ad3708514 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js @@ -0,0 +1,41 @@ +export default { + name: 'AssigneeTitle', + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + numberOfAssignees: { + type: Number, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + assigneeTitle() { + const assignees = this.numberOfAssignees; + return assignees > 1 ? `${assignees} Assignees` : 'Assignee'; + }, + }, + template: ` + <div class="title hide-collapsed"> + {{assigneeTitle}} + <i + v-if="loading" + aria-hidden="true" + class="fa fa-spinner fa-spin block-loading" + /> + <a + v-if="editable" + class="edit-link pull-right" + href="#" + > + Edit + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js new file mode 100644 index 00000000000..7e5feac622c --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js @@ -0,0 +1,224 @@ +export default { + name: 'Assignees', + data() { + return { + defaultRenderCount: 5, + defaultMaxCounter: 99, + showLess: true, + }; + }, + props: { + rootPath: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + editable: { + type: Boolean, + required: true, + }, + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasAssignees() { + return this.users.length > 0; + }, + hasNoUsers() { + return !this.users.length; + }, + hasOneUser() { + return this.users.length === 1; + }, + renderShowMoreSection() { + return this.users.length > this.defaultRenderCount; + }, + numberOfHiddenAssignees() { + return this.users.length - this.defaultRenderCount; + }, + isHiddenAssignees() { + return this.numberOfHiddenAssignees > 0; + }, + hiddenAssigneesLabel() { + return `+ ${this.numberOfHiddenAssignees} more`; + }, + collapsedTooltipTitle() { + const maxRender = Math.min(this.defaultRenderCount, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (this.users.length > maxRender) { + names.push(`+ ${this.users.length - maxRender} more`); + } + + return names.join(', '); + }, + sidebarAvatarCounter() { + let counter = `+${this.users.length - 1}`; + + if (this.users.length > this.defaultMaxCounter) { + counter = `${this.defaultMaxCounter}+`; + } + + return counter; + }, + }, + methods: { + assignSelf() { + this.$emit('assign-self'); + }, + toggleShowLess() { + this.showLess = !this.showLess; + }, + renderAssignee(index) { + return !this.showLess || (index < this.defaultRenderCount && this.showLess); + }, + avatarUrl(user) { + return user.avatar || user.avatar_url; + }, + assigneeUrl(user) { + return `${this.rootPath}${user.username}`; + }, + assigneeAlt(user) { + return `${user.name}'s avatar`; + }, + assigneeUsername(user) { + return `@${user.username}`; + }, + shouldRenderCollapsedAssignee(index) { + const firstTwo = this.users.length <= 2 && index <= 2; + + return index === 0 || firstTwo; + }, + }, + template: ` + <div> + <div + class="sidebar-collapsed-icon sidebar-collapsed-user" + :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + data-container="body" + data-placement="left" + :title="collapsedTooltipTitle" + > + <i + v-if="hasNoUsers" + aria-label="No Assignee" + class="fa fa-user" + /> + <button + type="button" + class="btn-link" + v-for="(user, index) in users" + v-if="shouldRenderCollapsedAssignee(index)" + > + <img + width="24" + class="avatar avatar-inline s24" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + <span class="author"> + {{ user.name }} + </span> + </button> + <button + v-if="hasMoreThanTwoAssignees" + class="btn-link" + type="button" + > + <span + class="avatar-counter sidebar-avatar-counter" + > + {{ sidebarAvatarCounter }} + </span> + </button> + </div> + <div class="value hide-collapsed"> + <template v-if="hasNoUsers"> + <span class="assign-yourself no-value"> + No assignee + <template v-if="editable"> + - + <button + type="button" + class="btn-link" + @click="assignSelf" + > + assign yourself + </button> + </template> + </span> + </template> + <template v-else-if="hasOneUser"> + <a + class="author_link bold" + :href="assigneeUrl(firstUser)" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(firstUser)" + :src="avatarUrl(firstUser)" + /> + <span class="author"> + {{ firstUser.name }} + </span> + <span class="username"> + {{ assigneeUsername(firstUser) }} + </span> + </a> + </template> + <template v-else> + <div class="user-list"> + <div + class="user-item" + v-for="(user, index) in users" + v-if="renderAssignee(index)" + > + <a + class="user-link has-tooltip" + data-placement="bottom" + :href="assigneeUrl(user)" + :data-title="user.name" + > + <img + width="32" + class="avatar avatar-inline s32" + :alt="assigneeAlt(user)" + :src="avatarUrl(user)" + /> + </a> + </div> + </div> + <div + v-if="renderShowMoreSection" + class="user-list-more" + > + <button + type="button" + class="btn-link" + @click="toggleShowLess" + > + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else> + - show less + </template> + </button> + </div> + </template> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js new file mode 100644 index 00000000000..1488a66c695 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -0,0 +1,84 @@ +/* global Flash */ + +import AssigneeTitle from './assignee_title'; +import Assignees from './assignees'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +import eventHub from '../../event_hub'; + +export default { + name: 'SidebarAssignees', + data() { + return { + mediator: new Mediator(), + store: new Store(), + loading: false, + field: '', + }; + }, + components: { + 'assignee-title': AssigneeTitle, + assignees: Assignees, + }, + methods: { + assignSelf() { + // Notify gl dropdown that we are now assigning to current user + this.$el.parentElement.dispatchEvent(new Event('assignYourself')); + + this.mediator.assignYourself(); + this.saveAssignees(); + }, + saveAssignees() { + this.loading = true; + + function setLoadingFalse() { + this.loading = false; + } + + this.mediator.saveAssignees(this.field) + .then(setLoadingFalse.bind(this)) + .catch(() => { + setLoadingFalse(); + return new Flash('Error occurred when saving assignees'); + }); + }, + }, + created() { + this.removeAssignee = this.store.removeAssignee.bind(this.store); + this.addAssignee = this.store.addAssignee.bind(this.store); + this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); + + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); + }, + beforeMount() { + this.field = this.$el.dataset.field; + }, + template: ` + <div> + <assignee-title + :number-of-assignees="store.assignees.length" + :loading="loading" + :editable="store.editable" + /> + <assignees + class="value" + :root-path="store.rootPath" + :users="store.assignees" + :editable="store.editable" + @assign-self="assignSelf" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js new file mode 100644 index 00000000000..0da265053bd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js @@ -0,0 +1,97 @@ +import stopwatchSvg from 'icons/_icon_stopwatch.svg'; + +import '../../../lib/utils/pretty_time'; + +export default { + name: 'time-tracking-collapsed-state', + props: { + showComparisonState: { + type: Boolean, + required: true, + }, + showSpentOnlyState: { + type: Boolean, + required: true, + }, + showEstimateOnlyState: { + type: Boolean, + required: true, + }, + showNoTimeTrackingState: { + type: Boolean, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: false, + default: '', + }, + timeEstimateHumanReadable: { + type: String, + required: false, + default: '', + }, + }, + computed: { + timeSpent() { + return this.abbreviateTime(this.timeSpentHumanReadable); + }, + timeEstimate() { + return this.abbreviateTime(this.timeEstimateHumanReadable); + }, + divClass() { + if (this.showComparisonState) { + return 'compare'; + } else if (this.showEstimateOnlyState) { + return 'estimate-only'; + } else if (this.showSpentOnlyState) { + return 'spend-only'; + } else if (this.showNoTimeTrackingState) { + return 'no-tracking'; + } + + return ''; + }, + spanClass() { + if (this.showComparisonState) { + return ''; + } else if (this.showEstimateOnlyState || this.showSpentOnlyState) { + return 'bold'; + } else if (this.showNoTimeTrackingState) { + return 'no-value'; + } + + return ''; + }, + text() { + if (this.showComparisonState) { + return `${this.timeSpent} / ${this.timeEstimate}`; + } else if (this.showEstimateOnlyState) { + return `-- / ${this.timeEstimate}`; + } else if (this.showSpentOnlyState) { + return `${this.timeSpent} / --`; + } else if (this.showNoTimeTrackingState) { + return 'None'; + } + + return ''; + }, + }, + methods: { + abbreviateTime(timeStr) { + return gl.utils.prettyTime.abbreviateTime(timeStr); + }, + }, + template: ` + <div class="sidebar-collapsed-icon"> + ${stopwatchSvg} + <div class="time-tracking-collapsed-summary"> + <div :class="divClass"> + <span :class="spanClass"> + {{ text }} + </span> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js new file mode 100644 index 00000000000..40f5c89c5bb --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js @@ -0,0 +1,98 @@ +import '../../../lib/utils/pretty_time'; + +const prettyTime = gl.utils.prettyTime; + +export default { + name: 'time-tracking-comparison-pane', + props: { + timeSpent: { + type: Number, + required: true, + }, + timeEstimate: { + type: Number, + required: true, + }, + timeSpentHumanReadable: { + type: String, + required: true, + }, + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + computed: { + parsedRemaining() { + const diffSeconds = this.timeEstimate - this.timeSpent; + return prettyTime.parseSeconds(diffSeconds); + }, + timeRemainingHumanReadable() { + return prettyTime.stringifyTime(this.parsedRemaining); + }, + timeRemainingTooltip() { + const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; + return `${prefix} ${this.timeRemainingHumanReadable}`; + }, + /* Diff values for comparison meter */ + timeRemainingMinutes() { + return this.timeEstimate - this.timeSpent; + }, + timeRemainingPercent() { + return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; + }, + timeRemainingStatusClass() { + return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; + }, + /* Parsed time values */ + parsedEstimate() { + return prettyTime.parseSeconds(this.timeEstimate); + }, + parsedSpent() { + return prettyTime.parseSeconds(this.timeSpent); + }, + }, + template: ` + <div class="time-tracking-comparison-pane"> + <div + class="compare-meter" + data-toggle="tooltip" + data-placement="top" + role="timeRemainingDisplay" + :aria-valuenow="timeRemainingTooltip" + :title="timeRemainingTooltip" + :data-original-title="timeRemainingTooltip" + :class="timeRemainingStatusClass" + > + <div + class="meter-container" + role="timeSpentPercent" + :aria-valuenow="timeRemainingPercent" + > + <div + :style="{ width: timeRemainingPercent }" + class="meter-fill" + /> + </div> + <div class="compare-display-container"> + <div class="compare-display pull-left"> + <span class="compare-label"> + Spent + </span> + <span class="compare-value spent"> + {{ timeSpentHumanReadable }} + </span> + </div> + <div class="compare-display estimated pull-right"> + <span class="compare-label"> + Est + </span> + <span class="compare-value"> + {{ timeEstimateHumanReadable }} + </span> + </div> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js new file mode 100644 index 00000000000..ad1b9179db0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js @@ -0,0 +1,17 @@ +export default { + name: 'time-tracking-estimate-only-pane', + props: { + timeEstimateHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-estimate-only-pane"> + <span class="bold"> + Estimated: + </span> + {{ timeEstimateHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js new file mode 100644 index 00000000000..b2a77462fe0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js @@ -0,0 +1,44 @@ +export default { + name: 'time-tracking-help-state', + props: { + rootPath: { + type: String, + required: true, + }, + }, + computed: { + href() { + return `${this.rootPath}help/workflow/time_tracking.md`; + }, + }, + template: ` + <div class="time-tracking-help-state"> + <div class="time-tracking-info"> + <h4> + Track time with slash commands + </h4> + <p> + Slash commands can be used in the issues description and comment boxes. + </p> + <p> + <code> + /estimate + </code> + will update the estimated time with the latest command. + </p> + <p> + <code> + /spend + </code> + will update the sum of the time spent. + </p> + <a + class="btn btn-default learn-more-button" + :href="href" + > + Learn more + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js new file mode 100644 index 00000000000..d1dd1dcdd27 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js @@ -0,0 +1,10 @@ +export default { + name: 'time-tracking-no-tracking-pane', + template: ` + <div class="time-tracking-no-tracking-pane"> + <span class="no-value"> + No estimate or time spent + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js new file mode 100644 index 00000000000..244b67b3ad9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -0,0 +1,51 @@ +import '~/smart_interval'; + +import timeTracker from './time_tracker'; + +import Store from '../../stores/sidebar_store'; +import Mediator from '../../sidebar_mediator'; + +export default { + data() { + return { + mediator: new Mediator(), + store: new Store(), + }; + }, + components: { + 'issuable-time-tracker': timeTracker, + }, + methods: { + listenForSlashCommands() { + $(document).on('ajax:success', '.gfm-form', this.slashCommandListened); + }, + slashCommandListened(e, data) { + const subscribedCommands = ['spend_time', 'time_estimate']; + let changedCommands; + if (data !== undefined) { + changedCommands = data.commands_changes + ? Object.keys(data.commands_changes) + : []; + } else { + changedCommands = []; + } + if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { + this.mediator.fetch(); + } + }, + }, + mounted() { + this.listenForSlashCommands(); + }, + template: ` + <div class="block"> + <issuable-time-tracker + :time_estimate="store.timeEstimate" + :time_spent="store.totalTimeSpent" + :human_time_estimate="store.humanTimeEstimate" + :human_time_spent="store.humanTotalTimeSpent" + :rootPath="store.rootPath" + /> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js new file mode 100644 index 00000000000..bf987562647 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js @@ -0,0 +1,15 @@ +export default { + name: 'time-tracking-spent-only-pane', + props: { + timeSpentHumanReadable: { + type: String, + required: true, + }, + }, + template: ` + <div class="time-tracking-spend-only-pane"> + <span class="bold">Spent:</span> + {{ timeSpentHumanReadable }} + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js new file mode 100644 index 00000000000..ed0d71a4f79 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js @@ -0,0 +1,163 @@ +import timeTrackingHelpState from './help_state'; +import timeTrackingCollapsedState from './collapsed_state'; +import timeTrackingSpentOnlyPane from './spent_only_pane'; +import timeTrackingNoTrackingPane from './no_tracking_pane'; +import timeTrackingEstimateOnlyPane from './estimate_only_pane'; +import timeTrackingComparisonPane from './comparison_pane'; + +import eventHub from '../../event_hub'; + +export default { + name: 'issuable-time-tracker', + props: { + time_estimate: { + type: Number, + required: true, + }, + time_spent: { + type: Number, + required: true, + }, + human_time_estimate: { + type: String, + required: false, + default: '', + }, + human_time_spent: { + type: String, + required: false, + default: '', + }, + rootPath: { + type: String, + required: true, + }, + }, + data() { + return { + showHelp: false, + }; + }, + components: { + 'time-tracking-collapsed-state': timeTrackingCollapsedState, + 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane, + 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane, + 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane, + 'time-tracking-comparison-pane': timeTrackingComparisonPane, + 'time-tracking-help-state': timeTrackingHelpState, + }, + computed: { + timeSpent() { + return this.time_spent; + }, + timeEstimate() { + return this.time_estimate; + }, + timeEstimateHumanReadable() { + return this.human_time_estimate; + }, + timeSpentHumanReadable() { + return this.human_time_spent; + }, + hasTimeSpent() { + return !!this.timeSpent; + }, + hasTimeEstimate() { + return !!this.timeEstimate; + }, + showComparisonState() { + return this.hasTimeEstimate && this.hasTimeSpent; + }, + showEstimateOnlyState() { + return this.hasTimeEstimate && !this.hasTimeSpent; + }, + showSpentOnlyState() { + return this.hasTimeSpent && !this.hasTimeEstimate; + }, + showNoTimeTrackingState() { + return !this.hasTimeEstimate && !this.hasTimeSpent; + }, + showHelpState() { + return !!this.showHelp; + }, + }, + methods: { + toggleHelpState(show) { + this.showHelp = show; + }, + update(data) { + this.time_estimate = data.time_estimate; + this.time_spent = data.time_spent; + this.human_time_estimate = data.human_time_estimate; + this.human_time_spent = data.human_time_spent; + }, + }, + created() { + eventHub.$on('timeTracker:updateData', this.update); + }, + template: ` + <div + class="time_tracker time-tracking-component-wrap" + v-cloak + > + <time-tracking-collapsed-state + :show-comparison-state="showComparisonState" + :show-no-time-tracking-state="showNoTimeTrackingState" + :show-help-state="showHelpState" + :show-spent-only-state="showSpentOnlyState" + :show-estimate-only-state="showEstimateOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <div class="title hide-collapsed"> + Time tracking + <div + class="help-button pull-right" + v-if="!showHelpState" + @click="toggleHelpState(true)" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + /> + </div> + <div + class="close-help-button pull-right" + v-if="showHelpState" + @click="toggleHelpState(false)" + > + <i + class="fa fa-close" + aria-hidden="true" + /> + </div> + </div> + <div class="time-tracking-content hide-collapsed"> + <time-tracking-estimate-only-pane + v-if="showEstimateOnlyState" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <time-tracking-spent-only-pane + v-if="showSpentOnlyState" + :time-spent-human-readable="timeSpentHumanReadable" + /> + <time-tracking-no-tracking-pane + v-if="showNoTimeTrackingState" + /> + <time-tracking-comparison-pane + v-if="showComparisonState" + :time-estimate="timeEstimate" + :time-spent="timeSpent" + :time-spent-human-readable="timeSpentHumanReadable" + :time-estimate-human-readable="timeEstimateHumanReadable" + /> + <transition name="help-state-toggle"> + <time-tracking-help-state + v-if="showHelpState" + :rootPath="rootPath" + /> + </transition> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js new file mode 100644 index 00000000000..f35506fd5de --- /dev/null +++ b/app/assets/javascripts/sidebar/event_hub.js @@ -0,0 +1,8 @@ +import Vue from 'vue'; + +const eventHub = new Vue(); + +// TODO: remove eventHub hack after code splitting refactor +window.emitSidebarEvent = (...args) => eventHub.$emit(...args); + +export default eventHub; diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js new file mode 100644 index 00000000000..5a82d01dc41 --- /dev/null +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class SidebarService { + constructor(endpoint) { + if (!SidebarService.singleton) { + this.endpoint = endpoint; + + SidebarService.singleton = this; + } + + return SidebarService.singleton; + } + + get() { + return Vue.http.get(this.endpoint); + } + + update(key, data) { + return Vue.http.put(this.endpoint, { + [key]: data, + }, { + emulateJSON: true, + }); + } +} diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js new file mode 100644 index 00000000000..2b02af87d8a --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; +import sidebarAssignees from './components/assignees/sidebar_assignees'; + +import Mediator from './sidebar_mediator'; + +function domContentLoaded() { + const mediator = new Mediator(gl.sidebarOptions); + mediator.fetch(); + + const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + + // Only create the sidebarAssignees vue app if it is found in the DOM + // We currently do not use sidebarAssignees for the MR page + if (sidebarAssigneesEl) { + new Vue(sidebarAssignees).$mount(sidebarAssigneesEl); + } + + new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); +} + +document.addEventListener('DOMContentLoaded', domContentLoaded); + +export default domContentLoaded; diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js new file mode 100644 index 00000000000..5ccfb4ee9c1 --- /dev/null +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -0,0 +1,38 @@ +/* global Flash */ + +import Service from './services/sidebar_service'; +import Store from './stores/sidebar_store'; + +export default class SidebarMediator { + constructor(options) { + if (!SidebarMediator.singleton) { + this.store = new Store(options); + this.service = new Service(options.endpoint); + SidebarMediator.singleton = this; + } + + return SidebarMediator.singleton; + } + + assignYourself() { + this.store.addAssignee(this.store.currentUser); + } + + saveAssignees(field) { + const selected = this.store.assignees.map(u => u.id); + + // If there are no ids, that means we have to unassign (which is id = 0) + // And it only accepts an array, hence [0] + return this.service.update(field, selected.length === 0 ? [0] : selected); + } + + fetch() { + this.service.get() + .then((response) => { + const data = response.json(); + this.store.setAssigneeData(data); + this.store.setTimeTrackingData(data); + }) + .catch(() => new Flash('Error occured when fetching sidebar data')); + } +} diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js new file mode 100644 index 00000000000..2d44c05bb8d --- /dev/null +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -0,0 +1,52 @@ +export default class SidebarStore { + constructor(store) { + if (!SidebarStore.singleton) { + const { currentUser, rootPath, editable } = store; + this.currentUser = currentUser; + this.rootPath = rootPath; + this.editable = editable; + this.timeEstimate = 0; + this.totalTimeSpent = 0; + this.humanTimeEstimate = ''; + this.humanTimeSpent = ''; + this.assignees = []; + + SidebarStore.singleton = this; + } + + return SidebarStore.singleton; + } + + setAssigneeData(data) { + if (data.assignees) { + this.assignees = data.assignees; + } + } + + setTimeTrackingData(data) { + this.timeEstimate = data.time_estimate; + this.totalTimeSpent = data.total_time_spent; + this.humanTimeEstimate = data.human_time_estimate; + this.humanTotalTimeSpent = data.human_total_time_spent; + } + + addAssignee(assignee) { + if (!this.findAssignee(assignee)) { + this.assignees.push(assignee); + } + } + + findAssignee(findAssignee) { + return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0]; + } + + removeAssignee(removeAssignee) { + if (removeAssignee) { + this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id); + } + } + + removeAllAssignees() { + this.assignees = []; + } +} diff --git a/app/assets/javascripts/signin_tabs_memoizer.js b/app/assets/javascripts/signin_tabs_memoizer.js index d811d1cd53a..2587facc582 100644 --- a/app/assets/javascripts/signin_tabs_memoizer.js +++ b/app/assets/javascripts/signin_tabs_memoizer.js @@ -1,5 +1,7 @@ /* eslint no-param-reassign: ["error", { "props": false }]*/ /* eslint no-new: "off" */ +import AccessorUtilities from './lib/utils/accessor'; + ((global) => { /** * Memorize the last selected tab after reloading a page. @@ -9,6 +11,8 @@ constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.nav-tabs' } = {}) { this.currentTabKey = currentTabKey; this.tabSelector = tabSelector; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + this.bootstrap(); } @@ -37,11 +41,15 @@ } saveData(val) { - localStorage.setItem(this.currentTabKey, val); + if (!this.isLocalStorageAvailable) return undefined; + + return window.localStorage.setItem(this.currentTabKey, val); } readData() { - return localStorage.getItem(this.currentTabKey); + if (!this.isLocalStorageAvailable) return null; + + return window.localStorage.getItem(this.currentTabKey); } } diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 294d087554e..bacb26734c9 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -1,8 +1,6 @@ /* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - window.SingleFileDiff = (function() { var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER; @@ -16,7 +14,7 @@ function SingleFileDiff(file) { this.file = file; - this.toggleDiff = bind(this.toggleDiff, this); + this.toggleDiff = this.toggleDiff.bind(this); this.content = $('.diff-content', this.file); this.$toggleIcon = $('.diff-toggle-caret', this.file); this.diffForPath = this.content.find('[data-diff-for-path]').data('diff-for-path'); diff --git a/app/assets/javascripts/subbable_resource.js b/app/assets/javascripts/subbable_resource.js deleted file mode 100644 index d8191605128..00000000000 --- a/app/assets/javascripts/subbable_resource.js +++ /dev/null @@ -1,51 +0,0 @@ -(() => { -/* -* SubbableResource can be extended to provide a pubsub-style service for one-off REST -* calls. Subscribe by passing a callback or render method you will use to handle responses. - * -* */ - - class SubbableResource { - constructor(resourcePath) { - this.endpoint = resourcePath; - - // TODO: Switch to axios.create - this.resource = $.ajax; - this.subscribers = []; - } - - subscribe(callback) { - this.subscribers.push(callback); - } - - publish(newResponse) { - const responseCopy = _.extend({}, newResponse); - this.subscribers.forEach((fn) => { - fn(responseCopy); - }); - return newResponse; - } - - get(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - post(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - put(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - - delete(payload) { - return this.resource(payload) - .then(data => this.publish(data)); - } - } - - gl.SubbableResource = SubbableResource; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/subscription.js b/app/assets/javascripts/subscription.js index 9c307915ec4..5f9a3e00c22 100644 --- a/app/assets/javascripts/subscription.js +++ b/app/assets/javascripts/subscription.js @@ -1,5 +1,3 @@ -import Vue from 'vue'; - (() => { class Subscription { constructor(containerElm) { @@ -29,8 +27,7 @@ import Vue from 'vue'; // hack to allow this to work with the issue boards Vue object if (document.querySelector('html').classList.contains('issue-boards-page')) { - Vue.set( - gl.issueBoards.BoardsStore.detail.issue, + gl.issueBoards.boardStoreIssueSet( 'subscribed', !gl.issueBoards.BoardsStore.detail.issue.subscribed, ); diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js index 8b25f43ffc7..0cd591c7320 100644 --- a/app/assets/javascripts/subscription_select.js +++ b/app/assets/javascripts/subscription_select.js @@ -19,8 +19,8 @@ return label; }; })(this), - clicked: function(item, $el, e) { - return e.preventDefault(); + clicked: function(options) { + return options.e.preventDefault(); }, id: function(obj, el) { return $(el).data("id"); diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index b1402c0a880..3392cb9da29 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,5 +1,6 @@ /* global Flash */ -require('vendor/task_list'); + +import 'vendor/task_list'; class TaskList { constructor(options = {}) { diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js index 32067ed1fee..9dd14488f22 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/templates/issuable_template_selector.js @@ -1,7 +1,7 @@ /* eslint-disable comma-dangle, max-len, no-useless-return, no-param-reassign, max-len */ -/* global Api */ +import Api from '../api'; -import TemplateSelector from '../blob/template_selectors/template_selector'; +import TemplateSelector from '../blob/template_selector'; ((global) => { class IssuableTemplateSelector extends TemplateSelector { diff --git a/app/assets/javascripts/terminal/terminal_bundle.js b/app/assets/javascripts/terminal/terminal_bundle.js index 13cf3a10a38..134522ef961 100644 --- a/app/assets/javascripts/terminal/terminal_bundle.js +++ b/app/assets/javascripts/terminal/terminal_bundle.js @@ -1,7 +1,9 @@ -require('vendor/xterm/encoding-indexes.js'); -require('vendor/xterm/encoding.js'); -window.Terminal = require('vendor/xterm/xterm.js'); -require('vendor/xterm/fit.js'); -require('./terminal.js'); +import 'vendor/xterm/encoding-indexes'; +import 'vendor/xterm/encoding'; +import Terminal from 'vendor/xterm/xterm'; +import 'vendor/xterm/fit'; +import './terminal'; + +window.Terminal = Terminal; $(() => new gl.Terminal({ selector: '#terminal' })); diff --git a/app/assets/javascripts/test.js b/app/assets/javascripts/test.js new file mode 100644 index 00000000000..c4c7918a68f --- /dev/null +++ b/app/assets/javascripts/test.js @@ -0,0 +1 @@ +$.fx.off = true; diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js new file mode 100644 index 00000000000..ef401abce2d --- /dev/null +++ b/app/assets/javascripts/test_utils/index.js @@ -0,0 +1,4 @@ +import simulateDrag from './simulate_drag'; + +// Export to global space for rspec to use +window.simulateDrag = simulateDrag; diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js index d48f2404fa5..e39213cb098 100644 --- a/app/assets/javascripts/test_utils/simulate_drag.js +++ b/app/assets/javascripts/test_utils/simulate_drag.js @@ -1,143 +1,137 @@ -/* eslint-disable wrap-iife, func-names, strict, no-var, vars-on-top, no-param-reassign, object-shorthand, no-shadow, comma-dangle, prefer-template, consistent-return, no-mixed-operators, no-unused-vars, no-unused-expressions, prefer-arrow-callback, max-len */ -(function () { - 'use strict'; - - function simulateEvent(el, type, options) { - var event; - if (!el) return; - var ownerDocument = el.ownerDocument; - - options = options || {}; - - if (/^mouse/.test(type)) { - event = ownerDocument.createEvent('MouseEvents'); - event.initMouseEvent(type, true, true, ownerDocument.defaultView, - options.button, options.screenX, options.screenY, options.clientX, options.clientY, - options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); - } else { - event = ownerDocument.createEvent('CustomEvent'); - - event.initCustomEvent(type, true, true, ownerDocument.defaultView, - options.button, options.screenX, options.screenY, options.clientX, options.clientY, - options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); - - event.dataTransfer = { - data: {}, - - setData: function (type, val) { - this.data[type] = val; - }, - - getData: function (type) { - return this.data[type]; - } - }; - } - - if (el.dispatchEvent) { - el.dispatchEvent(event); - } else if (el.fireEvent) { - el.fireEvent('on' + type, event); - } - - return event; - } - - function isLast(target) { - var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; - var children = el.children; - - return children.length - 1 === target.index; +function simulateEvent(el, type, options = {}) { + let event; + if (!el) return null; + + if (/^mouse/.test(type)) { + event = el.ownerDocument.createEvent('MouseEvents'); + event.initMouseEvent(type, true, true, el.ownerDocument.defaultView, + options.button, options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); + } else { + event = el.ownerDocument.createEvent('CustomEvent'); + + event.initCustomEvent(type, true, true, el.ownerDocument.defaultView, + options.button, options.screenX, options.screenY, options.clientX, options.clientY, + options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el); + + event.dataTransfer = { + data: {}, + + setData(key, val) { + this.data[key] = val; + }, + + getData(key) { + return this.data[key]; + }, + }; } - function getTarget(target) { - var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; - var children = el.children; - - return ( - children[target.index] || - children[target.index === 'first' ? 0 : -1] || - children[target.index === 'last' ? children.length - 1 : -1] || - el - ); + if (el.dispatchEvent) { + el.dispatchEvent(event); + } else if (el.fireEvent) { + el.fireEvent(`on${type}`, event); } - function getRect(el) { - var rect = el.getBoundingClientRect(); - var width = rect.right - rect.left; - var height = rect.bottom - rect.top + 10; - - return { - x: rect.left, - y: rect.top, - cx: rect.left + width / 2, - cy: rect.top + height / 2, - w: width, - h: height, - hw: width / 2, - wh: height / 2 - }; + return event; +} + +function isLast(target) { + const el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; + const children = el.children; + + return children.length - 1 === target.index; +} + +function getTarget(target) { + const el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el; + const children = el.children; + + return ( + children[target.index] || + children[target.index === 'first' ? 0 : -1] || + children[target.index === 'last' ? children.length - 1 : -1] || + el + ); +} + +function getRect(el) { + const rect = el.getBoundingClientRect(); + const width = rect.right - rect.left; + const height = (rect.bottom - rect.top) + 10; + + return { + x: rect.left, + y: rect.top, + cx: rect.left + (width / 2), + cy: rect.top + (height / 2), + w: width, + h: height, + hw: width / 2, + wh: height / 2, + }; +} + +export default function simulateDrag(options) { + const { to, from } = options; + to.el = to.el || from.el; + + const fromEl = getTarget(from); + const toEl = getTarget(to); + const firstEl = getTarget({ + el: to.el, + index: 'first', + }); + const lastEl = getTarget({ + el: options.to.el, + index: 'last', + }); + + const fromRect = getRect(fromEl); + const toRect = getRect(toEl); + const firstRect = getRect(firstEl); + const lastRect = getRect(lastEl); + + const startTime = new Date().getTime(); + const duration = options.duration || 1000; + + simulateEvent(fromEl, 'mousedown', { + button: 0, + clientX: fromRect.cx, + clientY: fromRect.cy, + }); + + if (options.ontap) options.ontap(); + window.SIMULATE_DRAG_ACTIVE = 1; + + if (options.to.index === 0) { + toRect.cy = firstRect.y; + } else if (isLast(options.to)) { + toRect.cy = lastRect.y + lastRect.h + 50; } - function simulateDrag(options, callback) { - options.to.el = options.to.el || options.from.el; + const dragInterval = setInterval(() => { + const progress = (new Date().getTime() - startTime) / duration; + const x = (fromRect.cx + ((toRect.cx - fromRect.cx) * progress)); + const y = (fromRect.cy + ((toRect.cy - fromRect.cy) * progress)); + const overEl = fromEl.ownerDocument.elementFromPoint(x, y); - var fromEl = getTarget(options.from); - var toEl = getTarget(options.to); - var firstEl = getTarget({ - el: options.to.el, - index: 'first' + simulateEvent(overEl, 'mousemove', { + clientX: x, + clientY: y, }); - var lastEl = getTarget({ - el: options.to.el, - index: 'last' - }); - var scrollable = options.scrollable; - - var fromRect = getRect(fromEl); - var toRect = getRect(toEl); - var firstRect = getRect(firstEl); - var lastRect = getRect(lastEl); - - var startTime = new Date().getTime(); - var duration = options.duration || 1000; - simulateEvent(fromEl, 'mousedown', { button: 0 }); - options.ontap && options.ontap(); - window.SIMULATE_DRAG_ACTIVE = 1; - - if (options.to.index === 0) { - toRect.cy = firstRect.y; - } else if (isLast(options.to)) { - toRect.cy = lastRect.y + lastRect.h + 50; - } - var dragInterval = setInterval(function loop() { - var progress = (new Date().getTime() - startTime) / duration; - var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft; - var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop; - var overEl = fromEl.ownerDocument.elementFromPoint(x, y); - - simulateEvent(overEl, 'mousemove', { - clientX: x, - clientY: y - }); - - if (progress >= 1) { - options.ondragend && options.ondragend(); - simulateEvent(toEl, 'mouseup'); - clearInterval(dragInterval); - window.SIMULATE_DRAG_ACTIVE = 0; - } - }, 100); - - return { - target: fromEl, - fromList: fromEl.parentNode, - toList: toEl.parentNode - }; - } - - // Export - window.simulateEvent = simulateEvent; - window.simulateDrag = simulateDrag; -})(); + if (progress >= 1) { + if (options.ondragend) options.ondragend(); + simulateEvent(toEl, 'mouseup'); + clearInterval(dragInterval); + window.SIMULATE_DRAG_ACTIVE = 0; + } + }, 100); + + return { + target: fromEl, + fromList: fromEl.parentNode, + toList: toEl.parentNode, + }; +} diff --git a/app/assets/javascripts/todos.js b/app/assets/javascripts/todos.js index 8be58023c84..7230946b484 100644 --- a/app/assets/javascripts/todos.js +++ b/app/assets/javascripts/todos.js @@ -1,5 +1,6 @@ /* eslint-disable class-methods-use-this, no-unneeded-ternary, quote-props */ -/* global UsersSelect */ + +import UsersSelect from './users_select'; class Todos { constructor() { diff --git a/app/assets/javascripts/u2f/authenticate.js b/app/assets/javascripts/u2f/authenticate.js index 500b78fc5d8..cd5280948fd 100644 --- a/app/assets/javascripts/u2f/authenticate.js +++ b/app/assets/javascripts/u2f/authenticate.js @@ -10,18 +10,16 @@ (function() { const global = window.gl || (window.gl = {}); - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - global.U2FAuthenticate = (function() { function U2FAuthenticate(container, form, u2fParams, fallbackButton, fallbackUI) { this.container = container; - this.renderNotSupported = bind(this.renderNotSupported, this); - this.renderAuthenticated = bind(this.renderAuthenticated, this); - this.renderError = bind(this.renderError, this); - this.renderInProgress = bind(this.renderInProgress, this); - this.renderTemplate = bind(this.renderTemplate, this); - this.authenticate = bind(this.authenticate, this); - this.start = bind(this.start, this); + this.renderNotSupported = this.renderNotSupported.bind(this); + this.renderAuthenticated = this.renderAuthenticated.bind(this); + this.renderError = this.renderError.bind(this); + this.renderInProgress = this.renderInProgress.bind(this); + this.renderTemplate = this.renderTemplate.bind(this); + this.authenticate = this.authenticate.bind(this); + this.start = this.start.bind(this); this.appId = u2fParams.app_id; this.challenge = u2fParams.challenge; this.form = form; diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js index fd1829efe18..3119b3480c3 100644 --- a/app/assets/javascripts/u2f/error.js +++ b/app/assets/javascripts/u2f/error.js @@ -2,12 +2,10 @@ /* global u2f */ (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.U2FError = (function() { function U2FError(errorCode, u2fFlowType) { this.errorCode = errorCode; - this.message = bind(this.message, this); + this.message = this.message.bind(this); this.httpsDisabled = window.location.protocol !== 'https:'; this.u2fFlowType = u2fFlowType; } diff --git a/app/assets/javascripts/u2f/register.js b/app/assets/javascripts/u2f/register.js index 17631f2908d..1234d17b8fd 100644 --- a/app/assets/javascripts/u2f/register.js +++ b/app/assets/javascripts/u2f/register.js @@ -8,19 +8,17 @@ // State Flow #1: setup -> in_progress -> registered -> POST to server // State Flow #2: setup -> in_progress -> error -> setup (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.U2FRegister = (function() { function U2FRegister(container, u2fParams) { this.container = container; - this.renderNotSupported = bind(this.renderNotSupported, this); - this.renderRegistered = bind(this.renderRegistered, this); - this.renderError = bind(this.renderError, this); - this.renderInProgress = bind(this.renderInProgress, this); - this.renderSetup = bind(this.renderSetup, this); - this.renderTemplate = bind(this.renderTemplate, this); - this.register = bind(this.register, this); - this.start = bind(this.start, this); + this.renderNotSupported = this.renderNotSupported.bind(this); + this.renderRegistered = this.renderRegistered.bind(this); + this.renderError = this.renderError.bind(this); + this.renderInProgress = this.renderInProgress.bind(this); + this.renderSetup = this.renderSetup.bind(this); + this.renderTemplate = this.renderTemplate.bind(this); + this.register = this.register.bind(this); + this.start = this.start.bind(this); this.appId = u2fParams.app_id; this.registerRequests = u2fParams.register_requests; this.signRequests = u2fParams.sign_requests; diff --git a/app/assets/javascripts/usage_ping.js b/app/assets/javascripts/usage_ping.js new file mode 100644 index 00000000000..fd3af7d7ab6 --- /dev/null +++ b/app/assets/javascripts/usage_ping.js @@ -0,0 +1,15 @@ +function UsagePing() { + const usageDataUrl = $('.usage-data').data('endpoint'); + + $.ajax({ + type: 'GET', + url: usageDataUrl, + dataType: 'html', + success(html) { + $('.usage-data').html(html); + }, + }); +} + +window.gl = window.gl || {}; +window.gl.UsagePing = UsagePing; diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js index fa078b48bf8..b9d57cbcad4 100644 --- a/app/assets/javascripts/user_callout.js +++ b/app/assets/javascripts/user_callout.js @@ -18,7 +18,7 @@ export default class UserCallout { dismissCallout(e) { const $currentTarget = $(e.currentTarget); - Cookies.set(USER_CALLOUT_COOKIE, 'true'); + Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 }); if ($currentTarget.hasClass('close')) { this.userCalloutBody.remove(); diff --git a/app/assets/javascripts/user_tabs.js b/app/assets/javascripts/user_tabs.js index 5db0d936ad8..ce7eb76dc71 100644 --- a/app/assets/javascripts/user_tabs.js +++ b/app/assets/javascripts/user_tabs.js @@ -94,15 +94,17 @@ content on the Users#show page. e.preventDefault(); $('.tab-pane.active').empty(); - this.loadTab($(e.target).attr('href'), this.getCurrentAction()); + const endpoint = $(e.target).attr('href'); + this.loadTab(this.getCurrentAction(), endpoint); } tabShown(event) { const $target = $(event.target); const action = $target.data('action'); const source = $target.attr('href'); - this.setTab(source, action); - return this.setCurrentAction(source, action); + const endpoint = $target.data('endpoint'); + this.setTab(action, endpoint); + return this.setCurrentAction(source); } activateTab(action) { @@ -110,27 +112,27 @@ content on the Users#show page. .tab('show'); } - setTab(source, action) { + setTab(action, endpoint) { if (this.loaded[action]) { return; } if (action === 'activity') { - this.loadActivities(source); + this.loadActivities(); } const loadableActions = ['groups', 'contributed', 'projects', 'snippets']; if (loadableActions.indexOf(action) > -1) { - return this.loadTab(source, action); + return this.loadTab(action, endpoint); } } - loadTab(source, action) { + loadTab(action, endpoint) { return $.ajax({ beforeSend: () => this.toggleLoading(true), complete: () => this.toggleLoading(false), dataType: 'json', type: 'GET', - url: source, + url: endpoint, success: (data) => { const tabSelector = `div#${action}`; this.$parentEl.find(tabSelector).html(data.html); @@ -140,7 +142,7 @@ content on the Users#show page. }); } - loadActivities(source) { + loadActivities() { if (this.loaded['activity']) { return; } @@ -155,7 +157,7 @@ content on the Users#show page. .toggle(status); } - setCurrentAction(source, action) { + setCurrentAction(source) { let new_state = source; new_state = new_state.replace(/\/+$/, ''); new_state += this._location.search + this._location.hash; diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js index 754d448564f..b11f691e424 100644 --- a/app/assets/javascripts/users/calendar.js +++ b/app/assets/javascripts/users/calendar.js @@ -3,12 +3,10 @@ import d3 from 'd3'; (function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.Calendar = (function() { function Calendar(timestamps, calendar_activities_path) { this.calendar_activities_path = calendar_activities_path; - this.clickDay = bind(this.clickDay, this); + this.clickDay = this.clickDay.bind(this); this.currentSelectedDate = ''; this.daySpace = 1; this.daySize = 15; @@ -168,15 +166,23 @@ import d3 from 'd3'; }; Calendar.prototype.renderKey = function() { - var keyColors; - keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; - return this.svg.append('g').attr('transform', "translate(18, " + (this.daySizeWithSpace * 8 + 16) + ")").selectAll('rect').data(keyColors).enter().append('rect').attr('width', this.daySize).attr('height', this.daySize).attr('x', (function(_this) { - return function(color, i) { - return _this.daySizeWithSpace * i; - }; - })(this)).attr('y', 0).attr('fill', function(color) { - return color; - }); + const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions']; + const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; + + this.svg.append('g') + .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`) + .selectAll('rect') + .data(keyColors) + .enter() + .append('rect') + .attr('width', this.daySize) + .attr('height', this.daySize) + .attr('x', (color, i) => this.daySizeWithSpace * i) + .attr('y', 0) + .attr('fill', color => color) + .attr('class', 'js-tooltip') + .attr('title', (color, i) => keyValues[i]) + .attr('data-container', 'body'); }; Calendar.prototype.initColor = function() { diff --git a/app/assets/javascripts/users/users_bundle.js b/app/assets/javascripts/users/users_bundle.js index 580e2d84be5..a38ce4eb25e 100644 --- a/app/assets/javascripts/users/users_bundle.js +++ b/app/assets/javascripts/users/users_bundle.js @@ -1 +1 @@ -require('./calendar'); +import './calendar'; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 48e20cf501f..aea3592c6ba 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -1,450 +1,673 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, prefer-rest-params, wrap-iife, quotes, max-len, one-var-declaration-per-line, vars-on-top, prefer-arrow-callback, consistent-return, comma-dangle, object-shorthand, no-shadow, no-unused-vars, no-else-return, no-self-compare, prefer-template, no-unused-expressions, no-lonely-if, yoda, prefer-spread, no-void, camelcase, no-param-reassign */ /* global Issuable */ -/* global ListUser */ - -import Vue from 'vue'; - -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - slice = [].slice; - - this.UsersSelect = (function() { - function UsersSelect(currentUser, els) { - var $els; - this.users = bind(this.users, this); - this.user = bind(this.user, this); - this.usersPath = "/autocomplete/users.json"; - this.userPath = "/autocomplete/users/:id.json"; - if (currentUser != null) { - if (typeof currentUser === 'object') { - this.currentUser = currentUser; - } else { - this.currentUser = JSON.parse(currentUser); - } +/* global emitSidebarEvent */ + +// TODO: remove eventHub hack after code splitting refactor +window.emitSidebarEvent = window.emitSidebarEvent || $.noop; + +function UsersSelect(currentUser, els) { + var $els; + this.users = this.users.bind(this); + this.user = this.user.bind(this); + this.usersPath = "/autocomplete/users.json"; + this.userPath = "/autocomplete/users/:id.json"; + if (currentUser != null) { + if (typeof currentUser === 'object') { + this.currentUser = currentUser; + } else { + this.currentUser = JSON.parse(currentUser); + } + } + + $els = $(els); + + if (!els) { + $els = $('.js-user-search'); + } + + $els.each((function(_this) { + return function(i, dropdown) { + var options = {}; + var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, defaultNullUser, firstUser, issueURL, selectedId, selectedIdDefault, showAnyUser, showNullUser, showMenuAbove; + $dropdown = $(dropdown); + options.projectId = $dropdown.data('project-id'); + options.groupId = $dropdown.data('group-id'); + options.showCurrentUser = $dropdown.data('current-user'); + options.todoFilter = $dropdown.data('todo-filter'); + options.todoStateFilter = $dropdown.data('todo-state-filter'); + showNullUser = $dropdown.data('null-user'); + defaultNullUser = $dropdown.data('null-user-default'); + showMenuAbove = $dropdown.data('showMenuAbove'); + showAnyUser = $dropdown.data('any-user'); + firstUser = $dropdown.data('first-user'); + options.authorId = $dropdown.data('author-id'); + defaultLabel = $dropdown.data('default-label'); + issueURL = $dropdown.data('issueUpdate'); + $selectbox = $dropdown.closest('.selectbox'); + $block = $selectbox.closest('.block'); + abilityName = $dropdown.data('ability-name'); + $value = $block.find('.value'); + $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + $loading = $block.find('.block-loading').fadeOut(); + selectedIdDefault = (defaultNullUser && showNullUser) ? 0 : null; + selectedId = $dropdown.data('selected'); + + if (selectedId === undefined) { + selectedId = selectedIdDefault; } - $els = $(els); + const assignYourself = function () { + const unassignedSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=0]`); - if (!els) { - $els = $('.js-user-search'); + if (unassignedSelected) { + unassignedSelected.remove(); + } + + // Save current selected user to the DOM + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = $dropdown.data('field-name'); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + + if (currentUserInfo) { + input.value = currentUserInfo.id; + input.dataset.meta = currentUserInfo.name; + } else if (_this.currentUser) { + input.value = _this.currentUser.id; + } + + if ($selectbox) { + $dropdown.parent().before(input); + } else { + $dropdown.after(input); + } + }; + + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); } - $els.each((function(_this) { - return function(i, dropdown) { - var options = {}; - var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove; - $dropdown = $(dropdown); - options.projectId = $dropdown.data('project-id'); - options.showCurrentUser = $dropdown.data('current-user'); - options.todoFilter = $dropdown.data('todo-filter'); - options.todoStateFilter = $dropdown.data('todo-state-filter'); - showNullUser = $dropdown.data('null-user'); - showMenuAbove = $dropdown.data('showMenuAbove'); - showAnyUser = $dropdown.data('any-user'); - firstUser = $dropdown.data('first-user'); - options.authorId = $dropdown.data('author-id'); - selectedId = $dropdown.data('selected'); - defaultLabel = $dropdown.data('default-label'); - issueURL = $dropdown.data('issueUpdate'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - abilityName = $dropdown.data('ability-name'); - $value = $block.find('.value'); - $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - $loading = $block.find('.block-loading').fadeOut(); - - var updateIssueBoardsIssue = function () { - $loading.removeClass('hidden').fadeIn(); - gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) - .then(function () { - $loading.fadeOut(); - }); - }; + const getSelectedUserInputs = function() { + return $selectbox + .find(`input[name="${$dropdown.data('field-name')}"]`); + }; - $('.assign-to-me-link').on('click', (e) => { - e.preventDefault(); - $(e.currentTarget).hide(); - const $input = $(`input[name="${$dropdown.data('field-name')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); - }); + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; - $block.on('click', '.js-assign-yourself', function(e) { - e.preventDefault(); + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('max-select'); + if (maxSelect) { + const selected = getSelected(); - if ($dropdown.hasClass('js-issue-board-sidebar')) { - Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({ - id: _this.currentUser.id, - username: _this.currentUser.username, - name: _this.currentUser.name, - avatar_url: _this.currentUser.avatar_url - })); + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown.closest('.selectbox') + .find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`); - updateIssueBoardsIssue(); - } else { - return assignTo(_this.currentUser.id); - } - }); - assignTo = function(selected) { - var data; - data = {}; - data[abilityName] = {}; - data[abilityName].assignee_id = selected != null ? selected : null; - $loading.removeClass('hidden').fadeIn(); - $dropdown.trigger('loading.gl.dropdown'); - return $.ajax({ - type: 'PUT', - dataType: 'json', - url: issueURL, - data: data - }).done(function(data) { - var user; - $dropdown.trigger('loaded.gl.dropdown'); - $loading.fadeOut(); - $selectbox.hide(); - if (data.assignee) { - user = { - name: data.assignee.name, - username: data.assignee.username, - avatar: data.assignee.avatar_url - }; - } else { - user = { - name: 'Unassigned', - username: '', - avatar: '' - }; - } - $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); - return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + firstSelected.remove(); + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, }); - }; - collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); - assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); - return $dropdown.glDropdown({ - showMenuAbove: showMenuAbove, - data: function(term, callback) { - var isAuthorFilter; - isAuthorFilter = $('.js-author-search'); - return _this.users(term, options, function(users) { - var anyUser, index, j, len, name, obj, showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = j = 0, len = users.length; j < len; index = (j += 1)) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } - } - } - if (showNullUser) { - showDivider += 1; - users.unshift({ - beforeDivider: true, - name: 'Unassigned', - id: 0 - }); - } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - beforeDivider: true, - name: name, - id: null - }; - users.unshift(anyUser); - } - } - if (showDivider) { - users.splice(showDivider, 0, "divider"); - } + } + } + }; - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - }); - }, - filterable: true, - filterRemote: true, - search: { - fields: ['name', 'username'] - }, - selectable: true, - fieldName: $dropdown.data('field-name'), - toggleLabel: function(selected, el) { - if (selected && 'id' in selected && $(el).hasClass('is-active')) { - if (selected.text) { - return selected.text; - } else { - return selected.name; + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected() + .filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return 'Unassigned'; + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return `${selectedUser.name} + ${otherSelected.length} more`; + } else { + return `${firstUser.name} + ${selectedUsers.length - 1} more`; + } + }; + + $('.assign-to-me-link').on('click', (e) => { + e.preventDefault(); + $(e.currentTarget).hide(); + + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown.find('.dropdown-toggle-text').text(getMultiSelectDropdownTitle(currentUserInfo)).removeClass('is-default'); + } else { + const $input = $(`input[name="${$dropdown.data('field-name')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default'); + } + }); + + $block.on('click', '.js-assign-yourself', (e) => { + e.preventDefault(); + return assignTo(_this.currentUser.id); + }); + + assignTo = function(selected) { + var data; + data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + $loading.removeClass('hidden').fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + + return $.ajax({ + type: 'PUT', + dataType: 'json', + url: issueURL, + data: data + }).done(function(data) { + var user; + $dropdown.trigger('loaded.gl.dropdown'); + $loading.fadeOut(); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url + }; + } else { + user = { + name: 'Unassigned', + username: '', + avatar: '' + }; + } + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', user.name).tooltip('fixTitle'); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + collapsedAssigneeTemplate = _.template('<% if( avatar ) { %> <a class="author_link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>'); + assigneeTemplate = _.template('<% if (username) { %> <a class="author_link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>'); + return $dropdown.glDropdown({ + showMenuAbove: showMenuAbove, + data: function(term, callback) { + var isAuthorFilter; + isAuthorFilter = $('.js-author-search'); + return _this.users(term, options, function(users) { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }.bind(this)); + }, + processData: function(term, users, callback) { + let anyUser; + let index; + let j; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = j = 0, len = users.length; j < len; index = (j += 1)) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; } - } else { - return defaultLabel; } - }, - defaultLabel: defaultLabel, - inputId: 'issue_assignee_id', - hidden: function(e) { - $selectbox.hide(); - // display:block overrides the hide-collapse rule - return $value.css('display', ''); - }, - vue: $dropdown.hasClass('js-issue-board-sidebar'), - clicked: function(user, $el, e) { - var isIssueIndex, isMRIndex, page, selected; - page = $('body').data('page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = (page === page && page === 'projects:merge_requests:index'); - if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { - e.preventDefault(); - selectedId = user.id; - if (selectedId === gon.current_user_id) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - return; + } + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: 'Unassigned', + id: 0 + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = 'Any User'; } - if ($el.closest('.add-issues-modal').length) { - gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - selectedId = user.id; - return Issuable.filterResults($dropdown.closest('form')); - } else if ($dropdown.hasClass('js-filter-submit')) { - return $dropdown.closest('form').submit(); - } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - if (user.id) { - Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({ - id: user.id, - username: user.username, - name: user.name, - avatar_url: user.avatar_url - })); - } else { - Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'assignee'); + anyUser = { + beforeDivider: true, + name: name, + id: null + }; + users.unshift(anyUser); + } + + if (showDivider) { + users.splice(showDivider, 0, 'divider'); + } + + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); + + if (selected.length > 0) { + if ($dropdown.data('dropdown-header')) { + showDivider += 1; + users.splice(showDivider, 0, { + header: $dropdown.data('dropdown-header'), + }); } - updateIssueBoardsIssue(); - } else { - selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); - return assignTo(selected); + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); + + users = users.filter(u => selected.indexOf(u.id) === -1); + + selectedUsers.forEach((selectedUser) => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); + + users.splice(showDivider + 1, 0, 'divider'); } - }, - id: function (user) { - return user.id; - }, - opened: function(e) { - const $el = $(e.currentTarget); - $el.find('.is-active').removeClass('is-active'); - $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active'); - }, - renderRow: function(user) { - var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username; - username = user.username ? "@" + user.username : ""; - avatar = user.avatar_url ? user.avatar_url : false; - selected = user.id === parseInt(selectedId, 10) ? "is-active" : ""; - img = ""; - if (user.beforeDivider != null) { - "<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>"; - } else { - if (avatar) { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />"; - } + } + } + + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'] + }, + selectable: true, + fieldName: $dropdown.data('field-name'), + toggleLabel: function(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } + + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } + + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); + if (selected.text) { + return selected.text; + } else { + return selected.name; + } + } else { + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; + } + }, + defaultLabel: defaultLabel, + hidden: function(e) { + if ($dropdown.hasClass('js-multiselect')) { + emitSidebarEvent('sidebar.saveAssignees'); + } + + if (!$dropdown.data('always-show-selectbox')) { + $selectbox.hide(); + + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('input-meta'), + clicked: function(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]"); + + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + const id = parseInt(element.value, 10); + element.remove(); + }); + emitSidebarEvent('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + emitSidebarEvent('sidebar.addAssignee', user); + + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown.closest('.selectbox') + .find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]"); + + if (unassignedSelected) { + unassignedSelected.remove(); } - // split into three parts so we can remove the username section if nessesary - listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>"; - listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>"; - listClosingTags = "</a> </li>"; - if (username === '') { - listWithUserName = ''; + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('field-name'), 0, {}); } - return listWithName + listWithUserName + listClosingTags; + + // User unselected + emitSidebarEvent('sidebar.removeAssignee', user); } - }); - }; - })(this)); - $('.ajax-users-select').each((function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('project-id'); - options.groupId = $(select).data('group-id'); - options.showCurrentUser = $(select).data('current-user'); - options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); - options.authorId = $(select).data('author-id'); - options.skipUsers = $(select).data('skip-users'); - showNullUser = $(select).data('null-user'); - showAnyUser = $(select).data('any-user'); - showEmailUser = $(select).data('email-user'); - firstUser = $(select).data('first-user'); - return $(select).select2({ - placeholder: "Search for a user", - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; - data = { - results: users - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - ref = data.results; - for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; - } - } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0 - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; - } - anyUser = { - name: name, - id: null - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: "Invite \"" + query.term + "\"", - username: trimmed, - id: trimmed - }; - data.results.unshift(emailUser); - } - return query.callback(data); - }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: "ajax-users-dropdown", - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; + + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); } - }); - }; - })(this)); - } + } - UsersSelect.prototype.initSelection = function(element, callback) { - var id, nullUser; - id = $(element).val(); - if (id === "0") { - nullUser = { - name: 'Unassigned' - }; - return callback(nullUser); - } else if (id !== "") { - return this.user(id, callback); - } - }; + var isIssueIndex, isMRIndex, page, selected; + page = $('body').data('page'); + isIssueIndex = page === 'projects:issues:index'; + isMRIndex = (page === page && page === 'projects:merge_requests:index'); + if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { + e.preventDefault(); - UsersSelect.prototype.formatResult = function(user) { - var avatar; - if (user.avatar_url) { - avatar = user.avatar_url; - } else { - avatar = gon.default_avatar_url; - } - return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; - }; + const isSelecting = (user.id !== selectedId); + selectedId = isSelecting ? user.id : selectedIdDefault; - UsersSelect.prototype.formatSelection = function(user) { - return user.name; - }; + if (selectedId === gon.current_user_id) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + return; + } + if ($el.closest('.add-issues-modal').length) { + gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else if (!$dropdown.hasClass('js-multiselect')) { + selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); + return assignTo(selected); + } - UsersSelect.prototype.user = function(user_id, callback) { - if (!/^\d+$/.test(user_id)) { - return false; - } + // Automatically close dropdown after assignee is selected + // since CE has no multiple assignees + // EE does not have a max-select + if ($dropdown.data('max-select') && + getSelected().length === $dropdown.data('max-select')) { + // Close the dropdown + $dropdown.dropdown('toggle'); + } + }, + id: function (user) { + return user.id; + }, + opened: function(e) { + const $el = $(e.currentTarget); + const selected = getSelected(); + if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) { + this.addInput($dropdown.data('field-name'), 0, {}); + } + $el.find('.is-active').removeClass('is-active'); + + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } + + if (selected.length > 0) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else if ($dropdown.hasClass('js-issue-board-sidebar')) { + highlightSelected(0); + } else { + highlightSelected(selectedId); + } + }, + updateLabel: $dropdown.data('dropdown-title'), + renderRow: function(user) { + var avatar, img, listClosingTags, listWithName, listWithUserName, username; + username = user.username ? "@" + user.username : ""; + avatar = user.avatar_url ? user.avatar_url : false; + + let selected = false; + + if (this.multiSelect) { + selected = getSelected().find(u => user.id === u); + + const fieldName = this.fieldName; + const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']"); + + if (field.length) { + selected = true; + } + } else { + selected = user.id === selectedId; + } - var url; - url = this.buildUrl(this.userPath); - url = url.replace(':id', user_id); - return $.ajax({ - url: url, - dataType: "json" - }).done(function(user) { - return callback(user); + img = ""; + if (user.beforeDivider != null) { + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`; + } else { + if (avatar) { + img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + } + } + + return ` + <li data-user-id=${user.id}> + <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> + ${img} + <strong class='dropdown-menu-user-full-name'> + ${user.name} + </strong> + ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} + </a> + </li> + `; + } }); }; - - // Return users list. Filtered by query - // Only active users retrieved - UsersSelect.prototype.users = function(query, options, callback) { - var url; - url = this.buildUrl(this.usersPath); - return $.ajax({ - url: url, - data: { - search: query, - per_page: 20, - active: true, - project_id: options.projectId || null, - group_id: options.groupId || null, - skip_ldap: options.skipLdap || null, - todo_filter: options.todoFilter || null, - todo_state_filter: options.todoStateFilter || null, - current_user: options.showCurrentUser || null, - push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, - author_id: options.authorId || null, - skip_users: options.skipUsers || null + })(this)); + $('.ajax-users-select').each((function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('project-id'); + options.groupId = $(select).data('group-id'); + options.showCurrentUser = $(select).data('current-user'); + options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches'); + options.authorId = $(select).data('author-id'); + options.skipUsers = $(select).data('skip-users'); + showNullUser = $(select).data('null-user'); + showAnyUser = $(select).data('any-user'); + showEmailUser = $(select).data('email-user'); + firstUser = $(select).data('first-user'); + return $(select).select2({ + placeholder: "Search for a user", + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref; + data = { + results: users + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + ref = data.results; + for (index = j = 0, len = ref.length; j < len; index = (j += 1)) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } + } + } + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0 + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + name: name, + id: null + }; + data.results.unshift(anyUser); + } + } + if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + var trimmed = query.term.trim(); + emailUser = { + name: "Invite \"" + query.term + "\"", + username: trimmed, + id: trimmed + }; + data.results.unshift(emailUser); + } + return query.callback(data); + }); + }, + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); }, - dataType: "json" - }).done(function(users) { - return callback(users); + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: "ajax-users-dropdown", + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + } }); }; + })(this)); +} - UsersSelect.prototype.buildUrl = function(url) { - if (gon.relative_url_root != null) { - url = gon.relative_url_root.replace(/\/$/, '') + url; - } - return url; +UsersSelect.prototype.initSelection = function(element, callback) { + var id, nullUser; + id = $(element).val(); + if (id === "0") { + nullUser = { + name: 'Unassigned' }; + return callback(nullUser); + } else if (id !== "") { + return this.user(id, callback); + } +}; + +UsersSelect.prototype.formatResult = function(user) { + var avatar; + if (user.avatar_url) { + avatar = user.avatar_url; + } else { + avatar = gon.default_avatar_url; + } + return "<div class='user-result " + (!user.username ? 'no-username' : void 0) + "'> <div class='user-image'><img class='avatar s24' src='" + avatar + "'></div> <div class='user-name'>" + user.name + "</div> <div class='user-username'>" + (user.username || "") + "</div> </div>"; +}; + +UsersSelect.prototype.formatSelection = function(user) { + return user.name; +}; + +UsersSelect.prototype.user = function(user_id, callback) { + if (!/^\d+$/.test(user_id)) { + return false; + } + + var url; + url = this.buildUrl(this.userPath); + url = url.replace(':id', user_id); + return $.ajax({ + url: url, + dataType: "json" + }).done(function(user) { + return callback(user); + }); +}; + +// Return users list. Filtered by query +// Only active users retrieved +UsersSelect.prototype.users = function(query, options, callback) { + var url; + url = this.buildUrl(this.usersPath); + return $.ajax({ + url: url, + data: { + search: query, + per_page: 20, + active: true, + project_id: options.projectId || null, + group_id: options.groupId || null, + skip_ldap: options.skipLdap || null, + todo_filter: options.todoFilter || null, + todo_state_filter: options.todoStateFilter || null, + current_user: options.showCurrentUser || null, + push_code_to_protected_branches: options.pushCodeToProtectedBranches || null, + author_id: options.authorId || null, + skip_users: options.skipUsers || null + }, + dataType: "json" + }).done(function(users) { + return callback(users); + }); +}; + +UsersSelect.prototype.buildUrl = function(url) { + if (gon.relative_url_root != null) { + url = gon.relative_url_root.replace(/\/$/, '') + url; + } + return url; +}; - return UsersSelect; - })(); -}).call(window); +export default UsersSelect; diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js index d4f716acb72..88ba991af47 100644 --- a/app/assets/javascripts/version_check_image.js +++ b/app/assets/javascripts/version_check_image.js @@ -1,4 +1,4 @@ -class VersionCheckImage { +export default class VersionCheckImage { static bindErrorEvent(imageElement) { imageElement.off('error').on('error', () => imageElement.hide()); } @@ -6,5 +6,3 @@ class VersionCheckImage { window.gl = window.gl || {}; gl.VersionCheckImage = VersionCheckImage; - -module.exports = VersionCheckImage; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js new file mode 100644 index 00000000000..a01cb8cc202 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.js @@ -0,0 +1,23 @@ +export default { + name: 'MRWidgetAuthor', + props: { + author: { type: Object, required: true }, + showAuthorName: { type: Boolean, required: false, default: true }, + showAuthorTooltip: { type: Boolean, required: false, default: false }, + }, + template: ` + <a + :href="author.webUrl || author.web_url" + class="author-link" + :class="{ 'has-tooltip': showAuthorTooltip }" + :title="author.name"> + <img + :src="author.avatarUrl || author.avatar_url" + class="avatar avatar-inline s16" /> + <span + v-if="showAuthorName" + class="author">{{author.name}} + </span> + </a> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js new file mode 100644 index 00000000000..6d2ed5fda64 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author_time.js @@ -0,0 +1,27 @@ +import MRWidgetAuthor from './mr_widget_author'; + +export default { + name: 'MRWidgetAuthorTime', + props: { + actionText: { type: String, required: true }, + author: { type: Object, required: true }, + dateTitle: { type: String, required: true }, + dateReadable: { type: String, required: true }, + }, + components: { + 'mr-widget-author': MRWidgetAuthor, + }, + template: ` + <h4 class="js-mr-widget-author"> + {{actionText}} + <mr-widget-author :author="author" /> + <time + :title="dateTitle" + data-toggle="tooltip" + data-placement="top" + data-container="body"> + {{dateReadable}} + </time> + </h4> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js new file mode 100644 index 00000000000..e8e22ad93a5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_deployment.js @@ -0,0 +1,116 @@ +/* global Flash */ + +import '~/lib/utils/datetime_utility'; +import { statusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import MemoryUsage from './mr_widget_memory_usage'; +import MRWidgetService from '../services/mr_widget_service'; + +export default { + name: 'MRWidgetDeployment', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-memory-usage': MemoryUsage, + }, + computed: { + svg() { + return statusIconEntityMap.icon_status_success; + }, + }, + methods: { + formatDate(date) { + return gl.utils.getTimeago().format(date); + }, + hasExternalUrls(deployment = {}) { + return deployment.external_url && deployment.external_url_formatted; + }, + hasDeploymentTime(deployment = {}) { + return deployment.deployed_at && deployment.deployed_at_formatted; + }, + hasDeploymentMeta(deployment = {}) { + return deployment.url && deployment.name; + }, + stopEnvironment(deployment) { + const msg = 'Are you sure you want to stop this environment?'; + const isConfirmed = confirm(msg); // eslint-disable-line + + if (isConfirmed) { + MRWidgetService.stopEnvironment(deployment.stop_url) + .then(res => res.json()) + .then((res) => { + if (res.redirect_url) { + gl.utils.visitUrl(res.redirect_url); + } + }) + .catch(() => { + new Flash('Something went wrong while stopping this environment. Please try again.'); // eslint-disable-line + }); + } + }, + }, + template: ` + <div class="mr-widget-heading"> + <div v-for="deployment in mr.deployments"> + <div class="ci-widget"> + <div class="ci-status-icon ci-status-icon-success"> + <span class="js-icon-link icon-link"> + <span class="ci-status-icon" + v-html="svg" + aria-hidden="true"></span> + </span> + </div> + <span> + <span + v-if="hasDeploymentMeta(deployment)"> + Deployed to + </span> + <a + v-if="hasDeploymentMeta(deployment)" + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-meta"> + {{deployment.name}} + </a> + <span + v-if="hasExternalUrls(deployment)"> + on + </span> + <a + v-if="hasExternalUrls(deployment)" + :href="deployment.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-url"> + <i + class="fa fa-external-link" + aria-hidden="true" /> + {{deployment.external_url_formatted}} + </a> + <span + v-if="hasDeploymentTime(deployment)" + :data-title="deployment.deployed_at_formatted" + class="js-deploy-time" + data-toggle="tooltip" + data-placement="top"> + {{formatDate(deployment.deployed_at)}} + </span> + <button + type="button" + v-if="deployment.stop_url" + @click="stopEnvironment(deployment)" + class="btn btn-default btn-xs"> + Stop environment + </button> + </span> + </div> + <mr-widget-memory-usage + v-if="deployment.metrics_url" + :metricsUrl="deployment.metrics_url" + /> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js new file mode 100644 index 00000000000..f8b3fb748ae --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.js @@ -0,0 +1,106 @@ +import '../../lib/utils/text_utility'; + +export default { + name: 'MRWidgetHeader', + props: { + mr: { type: Object, required: true }, + }, + computed: { + shouldShowCommitsBehindText() { + return this.mr.divergedCommitsCount > 0; + }, + commitsText() { + return gl.text.pluralize('commit', this.mr.divergedCommitsCount); + }, + branchNameClipboardData() { + // This supports code in app/assets/javascripts/copy_to_clipboard.js that + // works around ClipboardJS limitations to allow the context-specific + // copy/pasting of plain text or GFM. + return JSON.stringify({ + text: this.mr.sourceBranch, + gfm: `\`${this.mr.sourceBranch}\``, + }); + }, + }, + methods: { + isBranchTitleLong(branchTitle) { + return branchTitle.length > 32; + }, + }, + template: ` + <div class="mr-source-target"> + <div + v-if="mr.isOpen" + class="pull-right"> + <a + href="#modal_merge_info" + data-toggle="modal" + class="btn inline btn-grouped btn-sm"> + Check out branch + </a> + <span class="dropdown inline prepend-left-5"> + <a + class="btn btn-sm dropdown-toggle" + data-toggle="dropdown" + aria-label="Download as" + role="button"> + <i + class="fa fa-download" + aria-hidden="true" /> + <i + class="fa fa-caret-down" + aria-hidden="true" /> + </a> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li> + <a + :href="mr.emailPatchesPath" + download> + Email patches + </a> + </li> + <li> + <a + :href="mr.plainDiffPath" + download> + Plain diff + </a> + </li> + </ul> + </span> + </div> + <div class="normal"> + <strong> + Request to merge + <span + class="label-branch" + :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.sourceBranch)}" + :title="isBranchTitleLong(mr.sourceBranch) ? mr.sourceBranch : ''" + data-placement="bottom" + v-html="mr.sourceBranchLink"></span> + <button + class="btn btn-transparent btn-clipboard has-tooltip" + data-title="Copy branch name to clipboard" + :data-clipboard-text="branchNameClipboardData"> + <i + aria-hidden="true" + class="fa fa-clipboard"></i> + </button> + into + <span + class="label-branch" + :class="{'label-truncated has-tooltip': isBranchTitleLong(mr.targetBranch)}" + :title="isBranchTitleLong(mr.targetBranch) ? mr.targetBranch : ''" + data-placement="bottom"> + <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a> + </span> + </strong> + <span + v-if="shouldShowCommitsBehindText" + class="diverged-commits-count"> + ({{mr.divergedCommitsCount}} {{commitsText}} behind) + </span> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js new file mode 100644 index 00000000000..486b13e60af --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js @@ -0,0 +1,125 @@ +import statusCodes from '~/lib/utils/http_status'; +import MemoryGraph from '../../vue_shared/components/memory_graph'; +import MRWidgetService from '../services/mr_widget_service'; + +export default { + name: 'MemoryUsage', + props: { + metricsUrl: { type: String, required: true }, + }, + data() { + return { + // memoryFrom: 0, + // memoryTo: 0, + memoryMetrics: [], + deploymentTime: 0, + hasMetrics: false, + loadFailed: false, + loadingMetrics: true, + backOffRequestCounter: 0, + }; + }, + components: { + 'mr-memory-graph': MemoryGraph, + }, + computed: { + shouldShowLoading() { + return this.loadingMetrics && !this.hasMetrics && !this.loadFailed; + }, + shouldShowMemoryGraph() { + return !this.loadingMetrics && this.hasMetrics && !this.loadFailed; + }, + shouldShowLoadFailure() { + return !this.loadingMetrics && !this.hasMetrics && this.loadFailed; + }, + shouldShowMetricsUnavailable() { + return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed; + }, + }, + methods: { + computeGraphData(metrics, deploymentTime) { + this.loadingMetrics = false; + const { memory_values } = metrics; + // if (memory_previous.length > 0) { + // this.memoryFrom = Number(memory_previous[0].value[1]).toFixed(2); + // } + // + // if (memory_current.length > 0) { + // this.memoryTo = Number(memory_current[0].value[1]).toFixed(2); + // } + + if (memory_values.length > 0) { + this.hasMetrics = true; + this.memoryMetrics = memory_values[0].values; + this.deploymentTime = deploymentTime; + } + }, + loadMetrics() { + gl.utils.backOff((next, stop) => { + MRWidgetService.fetchMetrics(this.metricsUrl) + .then((res) => { + if (res.status === statusCodes.NO_CONTENT) { + this.backOffRequestCounter = this.backOffRequestCounter += 1; + /* eslint-disable no-unused-expressions */ + this.backOffRequestCounter < 3 ? next() : stop(res); + } else { + stop(res); + } + }) + .catch(stop); + }) + .then((res) => { + if (res.status === statusCodes.NO_CONTENT) { + return res; + } + + return res.json(); + }) + .then((res) => { + this.computeGraphData(res.metrics, res.deployment_time); + return res; + }) + .catch(() => { + this.loadFailed = true; + this.loadingMetrics = false; + }); + }, + }, + mounted() { + this.loadingMetrics = true; + this.loadMetrics(); + }, + template: ` + <div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage"> + <div class="legend"></div> + <p + v-if="shouldShowLoading" + class="usage-info js-usage-info usage-info-loading"> + <i + class="fa fa-spinner fa-spin usage-info-load-spinner" + aria-hidden="true" />Loading deployment statistics. + </p> + <p + v-if="shouldShowMemoryGraph" + class="usage-info js-usage-info"> + Deployment memory usage: + </p> + <p + v-if="shouldShowLoadFailure" + class="usage-info js-usage-info usage-info-failed"> + Failed to load deployment statistics. + </p> + <p + v-if="shouldShowMetricsUnavailable" + class="usage-info js-usage-info usage-info-unavailable"> + Deployment statistics are not available currently. + </p> + <mr-memory-graph + v-if="shouldShowMemoryGraph" + :metrics="memoryMetrics" + :deploymentTime="deploymentTime" + height="25" + width="100" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js new file mode 100644 index 00000000000..2fecebce7a0 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.js @@ -0,0 +1,23 @@ +export default { + name: 'MRWidgetMergeHelp', + props: { + missingBranch: { type: String, required: false, default: '' }, + }, + template: ` + <section class="mr-widget-help"> + <template + v-if="missingBranch"> + If the {{missingBranch}} branch exists in your local repository, you + </template> + <template v-else> + You + </template> + can merge this merge request manually using the + <a + data-toggle="modal" + href="#modal_merge_info"> + command line. + </a> + </section> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js new file mode 100644 index 00000000000..c02e10128e2 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.js @@ -0,0 +1,88 @@ +import PipelineStage from '../../pipelines/components/stage.vue'; +import ciIcon from '../../vue_shared/components/ci_icon.vue'; +import { statusIconEntityMap } from '../../vue_shared/ci_status_icons'; + +export default { + name: 'MRWidgetPipeline', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'pipeline-stage': PipelineStage, + ciIcon, + }, + computed: { + hasCIError() { + const { hasCI, ciStatus } = this.mr; + + return hasCI && !ciStatus; + }, + svg() { + return statusIconEntityMap.icon_status_failed; + }, + stageText() { + return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage'; + }, + status() { + return this.mr.pipeline.details.status || {}; + }, + }, + template: ` + <div class="mr-widget-heading"> + <div class="ci-widget"> + <template v-if="hasCIError"> + <div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error"> + <span class="js-icon-link icon-link"> + <span + v-html="svg" + aria-hidden="true"></span> + </span> + </div> + <span>Could not connect to the CI server. Please check your settings and try again.</span> + </template> + <template v-else> + <div> + <a + class="icon-link" + :href="this.status.details_path"> + <ci-icon :status="status" /> + </a> + </div> + <span> + Pipeline + <a + :href="mr.pipeline.path" + class="pipeline-id">#{{mr.pipeline.id}}</a> + {{mr.pipeline.details.status.label}} + </span> + <span + v-if="mr.pipeline.details.stages.length > 0"> + with {{stageText}} + </span> + <div class="mr-widget-pipeline-graph"> + <div class="stage-cell"> + <div + v-if="mr.pipeline.details.stages.length > 0" + v-for="stage in mr.pipeline.details.stages" + class="stage-container dropdown js-mini-pipeline-graph"> + <pipeline-stage :stage="stage" /> + </div> + </div> + </div> + <span> + for + <a + :href="mr.pipeline.commit.commit_path" + class="commit-sha js-commit-link"> + {{mr.pipeline.commit.short_id}}</a>. + </span> + <span + v-if="mr.pipeline.coverage" + class="js-mr-coverage"> + Coverage {{mr.pipeline.coverage}}%. + </span> + </template> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js new file mode 100644 index 00000000000..205804670fa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.js @@ -0,0 +1,42 @@ +export default { + name: 'MRWidgetRelatedLinks', + props: { + relatedLinks: { type: Object, required: true }, + }, + computed: { + hasLinks() { + const { closing, mentioned, assignToMe } = this.relatedLinks; + return closing || mentioned || assignToMe; + }, + }, + methods: { + hasMultipleIssues(text) { + return !text ? false : text.match(/<\/a> and <a/); + }, + issueLabel(field) { + return this.hasMultipleIssues(this.relatedLinks[field]) ? 'issues' : 'issue'; + }, + verbLabel(field) { + return this.hasMultipleIssues(this.relatedLinks[field]) ? 'are' : 'is'; + }, + }, + template: ` + <section + v-if="hasLinks" + class="mr-info-list mr-links"> + <div class="legend"></div> + <p v-if="relatedLinks.closing"> + Closes {{issueLabel('closing')}} + <span v-html="relatedLinks.closing"></span>. + </p> + <p v-if="relatedLinks.mentioned"> + <span class="capitalize">{{issueLabel('mentioned')}}</span> + <span v-html="relatedLinks.mentioned"></span> + {{verbLabel('mentioned')}} mentioned but will not be closed. + </p> + <p v-if="relatedLinks.assignToMe"> + <span v-html="relatedLinks.assignToMe"></span> + </p> + </section> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js new file mode 100644 index 00000000000..c7f25a1697c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetArchived', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + This project is archived, write access has been disabled. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js new file mode 100644 index 00000000000..4063859d5d0 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.js @@ -0,0 +1,48 @@ +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetAutoMergeFailed', + props: { + mr: { type: Object, required: true }, + }, + data() { + return { + isRefreshing: false, + }; + }, + methods: { + refreshWidget() { + this.isRefreshing = true; + eventHub.$emit('MRWidgetUpdateRequested', () => { + this.isRefreshing = false; + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span class="bold danger"> + This merge request failed to be merged automatically. + <button + @click="refreshWidget" + :class="{ disabled: isRefreshing }" + type="button" + class="btn btn-xs btn-default"> + <i + v-if="isRefreshing" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Refresh + </button> + </span> + <div class="merge-error-text danger bold"> + {{mr.mergeError}} + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js new file mode 100644 index 00000000000..8515b54e62d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.js @@ -0,0 +1,19 @@ +export default { + name: 'MRWidgetChecking', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Checking ability to merge automatically. + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js new file mode 100644 index 00000000000..fc2e42c6821 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.js @@ -0,0 +1,30 @@ +import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; + +export default { + name: 'MRWidgetClosed', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'mr-widget-author-and-time': mrWidgetAuthorTime, + }, + template: ` + <div class="mr-widget-body"> + <mr-widget-author-and-time + actionText="Closed by" + :author="mr.closedBy" + :dateTitle="mr.updatedAt" + :dateReadable="mr.closedAt" + /> + <section> + <p> + The changes were not merged into + <a + :href="mr.targetBranchPath" + class="label-branch"> + {{mr.targetBranch}}</a>. + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js new file mode 100644 index 00000000000..36596c6f37e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.js @@ -0,0 +1,39 @@ +export default { + name: 'MRWidgetConflicts', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + There are merge conflicts. + <span v-if="!mr.canMerge"> + Resolve these conflicts or ask someone with write access to this repository to merge it locally. + </span> + </span> + <div + v-if="mr.canMerge" + class="btn-group"> + <a + v-if="mr.conflictResolutionPath" + :href="mr.conflictResolutionPath" + class="btn btn-default btn-xs js-resolve-conflicts-button"> + Resolve conflicts + </a> + <a + v-if="mr.canMerge" + class="btn btn-default btn-xs js-merge-locally-button" + data-toggle="modal" + href="#modal_merge_info"> + Merge locally + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js new file mode 100644 index 00000000000..600b4d42e3d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.js @@ -0,0 +1,76 @@ +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetFailedToMerge', + props: { + mr: { type: Object, required: true }, + }, + data() { + return { + timer: 10, + isRefreshing: false, + }; + }, + mounted() { + setInterval(() => { + this.updateTimer(); + }, 1000); + }, + created() { + eventHub.$emit('DisablePolling'); + }, + computed: { + timerText() { + return this.timer > 1 ? `${this.timer} seconds` : 'a second'; + }, + }, + methods: { + refresh() { + this.isRefreshing = true; + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('EnablePolling'); + }, + updateTimer() { + this.timer = this.timer - 1; + + if (this.timer === 0) { + this.refresh(); + } + }, + }, + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span + v-if="!isRefreshing" + class="bold danger"> + <span + class="has-error-message" + v-if="mr.mergeError"> + {{mr.mergeError}} + </span> + <span v-else>Merge failed.</span> + <span + :class="{ 'has-custom-error': mr.mergeError }"> + Refreshing in {{timerText}} to show the updated status... + </span> + <button + @click="refresh" + class="btn btn-default btn-xs js-refresh-button" + type="button"> + Refresh now + </button> + </span> + <span + v-if="isRefreshing" + class="bold js-refresh-label"> + Refreshing now... + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js new file mode 100644 index 00000000000..0bd31731a0b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js @@ -0,0 +1,24 @@ +export default { + name: 'MRWidgetLocked', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body mr-state-locked"> + <span class="state-label">Locked</span> + This merge request is in the process of being merged, during which time it is locked and cannot be closed. + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + <section class="mr-info-list mr-links"> + <div class="legend"></div> + <p> + The changes will be merged into + <span class="label-branch"> + <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a> + </span>. + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js new file mode 100644 index 00000000000..419d174f3ff --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.js @@ -0,0 +1,116 @@ +/* global Flash */ + +import MRWidgetAuthor from '../../components/mr_widget_author'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetMergeWhenPipelineSucceeds', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-author': MRWidgetAuthor, + }, + data() { + return { + isCancellingAutoMerge: false, + isRemovingSourceBranch: false, + }; + }, + computed: { + canRemoveSourceBranch() { + const { shouldRemoveSourceBranch, canRemoveSourceBranch, + mergeUserId, currentUserId } = this.mr; + + return !shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId; + }, + }, + methods: { + cancelAutomaticMerge() { + this.isCancellingAutoMerge = true; + this.service.cancelAutomaticMerge() + .then(res => res.json()) + .then((res) => { + eventHub.$emit('UpdateWidgetData', res); + }) + .catch(() => { + this.isCancellingAutoMerge = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + removeSourceBranch() { + const options = { + sha: this.mr.sha, + merge_when_pipeline_succeeds: true, + should_remove_source_branch: true, + }; + + this.isRemovingSourceBranch = true; + this.service.mergeResource.save(options) + .then(res => res.json()) + .then((res) => { + if (res.status === 'merge_when_pipeline_succeeds') { + eventHub.$emit('MRWidgetUpdateRequested'); + } + }) + .catch(() => { + this.isRemovingSourceBranch = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <h4> + Set by + <mr-widget-author :author="mr.setToMWPSBy" /> + to be merged automatically when the pipeline succeeds. + <a + v-if="mr.canCancelAutomaticMerge" + @click.prevent="cancelAutomaticMerge" + :disabled="isCancellingAutoMerge" + role="button" + href="#" + class="btn btn-xs btn-default js-cancel-auto-merge"> + <i + v-if="isCancellingAutoMerge" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Cancel automatic merge + </a> + </h4> + <section class="mr-info-list"> + <div class="legend"></div> + <p>The changes will be merged into + <a + :href="mr.targetBranchPath" + class="label-branch"> + {{mr.targetBranch}} + </a>. + </p> + <p v-if="mr.shouldRemoveSourceBranch"> + The source branch will be removed. + </p> + <p + v-else + class="with-button"> + The source branch will not be removed. + <a + v-if="canRemoveSourceBranch" + :disabled="isRemovingSourceBranch" + @click.prevent="removeSourceBranch" + role="button" + class="btn btn-xs btn-default js-remove-source-branch" + href="#"> + <i + v-if="isRemovingSourceBranch" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Remove source branch + </a> + </p> + </section> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js new file mode 100644 index 00000000000..c7d32d18141 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.js @@ -0,0 +1,130 @@ +/* global Flash */ + +import mrWidgetAuthorTime from '../../components/mr_widget_author_time'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetMerged', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + components: { + 'mr-widget-author-and-time': mrWidgetAuthorTime, + }, + data() { + return { + isMakingRequest: false, + }; + }, + computed: { + shouldShowRemoveSourceBranch() { + const { sourceBranchRemoved, isRemovingSourceBranch, canRemoveSourceBranch } = this.mr; + + return !sourceBranchRemoved && canRemoveSourceBranch && + !this.isMakingRequest && !isRemovingSourceBranch; + }, + shouldShowSourceBranchRemoving() { + const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr; + return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest); + }, + shouldShowMergedButtons() { + const { canRevertInCurrentMR, canCherryPickInCurrentMR, revertInForkPath, + cherryPickInForkPath } = this.mr; + + return canRevertInCurrentMR || canCherryPickInCurrentMR || + revertInForkPath || cherryPickInForkPath; + }, + }, + methods: { + removeSourceBranch() { + this.isMakingRequest = true; + this.service.removeSourceBranch() + .then(res => res.json()) + .then((res) => { + if (res.message === 'Branch was removed') { + eventHub.$emit('MRWidgetUpdateRequested', () => { + this.isMakingRequest = false; + }); + } + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <mr-widget-author-and-time + actionText="Merged by" + :author="mr.mergedBy" + :dateTitle="mr.updatedAt" + :dateReadable="mr.mergedAt" /> + <section class="mr-info-list"> + <div class="legend"></div> + <p> + The changes were merged into + <span class="label-branch"> + <a :href="mr.targetBranchPath">{{mr.targetBranch}}</a> + </span> + </p> + <p v-if="mr.sourceBranchRemoved">The source branch has been removed.</p> + <p v-if="shouldShowRemoveSourceBranch"> + You can remove source branch now. + <button + @click="removeSourceBranch" + :class="{ disabled: isMakingRequest }" + type="button" + class="btn btn-xs btn-default js-remove-branch-button"> + Remove Source Branch + </button> + </p> + <p v-if="shouldShowSourceBranchRemoving"> + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + The source branch is being removed. + </p> + </section> + <div + v-if="shouldShowMergedButtons" + class="merged-buttons clearfix"> + <a + v-if="mr.canRevertInCurrentMR" + class="btn btn-close btn-sm has-tooltip" + href="#modal-revert-commit" + data-toggle="modal" + data-container="body" + title="Revert this merge request in a new merge request"> + Revert + </a> + <a + v-else-if="mr.revertInForkPath" + class="btn btn-close btn-sm has-tooltip" + data-method="post" + :href="mr.revertInForkPath" + title="Revert this merge request in a new merge request"> + Revert + </a> + <a + v-if="mr.canCherryPickInCurrentMR" + class="btn btn-default btn-sm has-tooltip" + href="#modal-cherry-pick-commit" + data-toggle="modal" + data-container="body" + title="Cherry-pick this merge request in a new merge request"> + Cherry-pick + </a> + <a + v-else-if="mr.cherryPickInForkPath" + class="btn btn-default btn-sm has-tooltip" + data-method="post" + :href="mr.cherryPickInForkPath" + title="Cherry-pick this merge request in a new merge request"> + Cherry-pick + </a> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js new file mode 100644 index 00000000000..328382485f6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.js @@ -0,0 +1,34 @@ +import mrWidgetMergeHelp from '../../components/mr_widget_merge_help'; + +export default { + name: 'MRWidgetMissingBranch', + props: { + mr: { type: Object, required: true }, + }, + components: { + 'mr-widget-merge-help': mrWidgetMergeHelp, + }, + computed: { + missingBranchName() { + return this.mr.sourceBranchRemoved ? 'source' : 'target'; + }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold js-branch-text"> + <span class="capitalize"> + {{missingBranchName}} + </span> branch does not exist. + Please restore the {{missingBranchName}} branch or use a different {{missingBranchName}} branch. + </span> + <mr-widget-merge-help + :missing-branch="missingBranchName" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js new file mode 100644 index 00000000000..07169b349be --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.js @@ -0,0 +1,17 @@ +export default { + name: 'MRWidgetNotAllowed', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Ready to be merged automatically. + Ask someone with write access to this repository to merge this request. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js new file mode 100644 index 00000000000..375a382615a --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge.js @@ -0,0 +1,42 @@ +import emptyStateSVG from 'icons/_mr_widget_empty_state.svg'; + +export default { + name: 'MRWidgetNothingToMerge', + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { emptyStateSVG }; + }, + template: ` + <div class="mr-widget-body empty-state"> + <div class="row"> + <div class="artwork col-sm-5 col-sm-push-7 col-xs-12 text-center"> + <span v-html="emptyStateSVG"></span> + </div> + <div class="text col-sm-7 col-sm-pull-5 col-xs-12"> + <span> + Merge requests are a place to propose changes you have made to a project + and discuss those changes with others. + </span> + <p> + Interested parties can even contribute by pushing commits if they want to. + </p> + <p> + Currently there are no changes in this merge request's source branch. + Please push new commits or use a different branch. + </p> + <a + v-if="mr.newBlobPath" + :href="mr.newBlobPath" + class="btn btn-inverted btn-save"> + Create file + </a> + </div> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js new file mode 100644 index 00000000000..31c53b679ed --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetPipelineBlocked', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + Pipeline blocked. The pipeline for this merge request requires a manual action to proceed. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js new file mode 100644 index 00000000000..002820123ca --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_failed.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetPipelineBlocked', + template: ` + <div class="mr-widget-body"> + <button + class="btn btn-success btn-small" + disabled="true" + type="button"> + Merge + </button> + <span class="bold"> + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js new file mode 100644 index 00000000000..74613a1089e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -0,0 +1,309 @@ +/* global Flash */ + +import successSvg from 'icons/_icon_status_success.svg'; +import warningSvg from 'icons/_icon_status_warning.svg'; +import simplePoll from '~/lib/utils/simple_poll'; +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetReadyToMerge', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + data() { + return { + removeSourceBranch: true, + mergeWhenBuildSucceeds: false, + useCommitMessageWithDescription: false, + setToMergeWhenPipelineSucceeds: false, + showCommitMessageEditor: false, + isMakingRequest: false, + isMergingImmediately: false, + commitMessage: this.mr.commitMessage, + successSvg, + warningSvg, + }; + }, + computed: { + commitMessageLinkTitle() { + const withDesc = 'Include description in commit message'; + const withoutDesc = "Don't include description in commit message"; + + return this.useCommitMessageWithDescription ? withoutDesc : withDesc; + }, + mergeButtonClass() { + const defaultClass = 'btn btn-small btn-success accept-merge-request'; + const failedClass = `${defaultClass} btn-danger`; + const inActionClass = `${defaultClass} btn-info`; + const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; + + if (hasCI && !ciStatus) { + return failedClass; + } else if (!pipeline) { + return defaultClass; + } else if (isPipelineActive) { + return inActionClass; + } else if (isPipelineFailed) { + return failedClass; + } + + return defaultClass; + }, + mergeButtonText() { + if (this.isMergingImmediately) { + return 'Merge in progress'; + } else if (this.mr.isPipelineActive) { + return 'Merge when pipeline succeeds'; + } + + return 'Merge'; + }, + shouldShowMergeOptionsDropdown() { + return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; + }, + isMergeButtonDisabled() { + const { commitMessage } = this; + return Boolean(!commitMessage.length + || !this.isMergeAllowed() + || this.isMakingRequest + || this.mr.preventMerge); + }, + shouldShowSquashBeforeMerge() { + const { commitsCount, enableSquashBeforeMerge } = this.mr; + return enableSquashBeforeMerge && commitsCount > 1; + }, + }, + methods: { + isMergeAllowed() { + return !(this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.isPipelineFailed); + }, + updateCommitMessage() { + const cmwd = this.mr.commitMessageWithDescription; + this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; + this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; + }, + toggleCommitMessageEditor() { + this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { + // TODO: Remove no-param-reassign + if (mergeWhenBuildSucceeds === undefined) { + mergeWhenBuildSucceeds = this.mr.isPipelineActive; // eslint-disable-line no-param-reassign + } else if (mergeImmediately) { + this.isMergingImmediately = true; + } + + this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true; + + const options = { + sha: this.mr.sha, + commit_message: this.commitMessage, + merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, + should_remove_source_branch: this.removeSourceBranch === true, + }; + + // Only truthy in EE extension of this component + if (this.setAdditionalParams) { + this.setAdditionalParams(options); + } + + this.isMakingRequest = true; + this.service.merge(options) + .then(res => res.json()) + .then((res) => { + const hasError = res.status === 'failed' || res.status === 'hook_validation_error'; + + if (res.status === 'merge_when_pipeline_succeeds') { + eventHub.$emit('MRWidgetUpdateRequested'); + } else if (res.status === 'success') { + this.initiateMergePolling(); + } else if (hasError) { + eventHub.$emit('FailedToMerge', res.merge_error); + } + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + initiateMergePolling() { + simplePoll((continuePolling, stopPolling) => { + this.handleMergePolling(continuePolling, stopPolling); + }); + }, + handleMergePolling(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.json()) + .then((res) => { + if (res.state === 'merged') { + // If state is merged we should update the widget and stop the polling + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('FetchActionsContent'); + if (window.mergeRequest) { + window.mergeRequest.updateStatusText('status-box-open', 'status-box-merged', 'Merged'); + window.mergeRequest.decreaseCounter(); + } + stopPolling(); + + // If user checked remove source branch and we didn't remove the branch yet + // we should start another polling for source branch remove process + if (this.removeSourceBranch && res.source_branch_exists) { + this.initiateRemoveSourceBranchPolling(); + } + } else if (res.merge_error) { + eventHub.$emit('FailedToMerge', res.merge_error); + stopPolling(); + } else { + // MR is not merged yet, continue polling until the state becomes 'merged' + continuePolling(); + } + }) + .catch(() => { + new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + }); + }, + initiateRemoveSourceBranchPolling() { + // We need to show source branch is being removed spinner in another component + eventHub.$emit('SetBranchRemoveFlag', [true]); + + simplePoll((continuePolling, stopPolling) => { + this.handleRemoveBranchPolling(continuePolling, stopPolling); + }); + }, + handleRemoveBranchPolling(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.json()) + .then((res) => { + // If source branch exists then we should continue polling + // because removing a source branch is a background task and takes time + if (res.source_branch_exists) { + continuePolling(); + } else { + // Branch is removed. Update widget, stop polling and hide the spinner + eventHub.$emit('MRWidgetUpdateRequested', () => { + eventHub.$emit('SetBranchRemoveFlag', [false]); + }); + stopPolling(); + } + }) + .catch(() => { + new Flash('Something went wrong while removing the source branch. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <span class="btn-group"> + <button + @click="handleMergeButtonClick()" + :disabled="isMergeButtonDisabled" + :class="mergeButtonClass" + type="button"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + {{mergeButtonText}} + </button> + <button + v-if="shouldShowMergeOptionsDropdown" + :disabled="isMergeButtonDisabled" + type="button" + class="btn btn-info dropdown-toggle" + data-toggle="dropdown"> + <i + class="fa fa-caret-down" + aria-hidden="true" /> + <span class="sr-only"> + Select merge moment + </span> + </button> + <ul + v-if="shouldShowMergeOptionsDropdown" + class="dropdown-menu dropdown-menu-right" + role="menu"> + <li> + <a + @click.prevent="handleMergeButtonClick(true)" + class="merge_when_pipeline_succeeds" + href="#"> + <span + v-html="successSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="merge-opt-title">Merge when pipeline succeeds</span> + </a> + </li> + <li> + <a + @click.prevent="handleMergeButtonClick(false, true)" + class="accept-merge-request" + href="#"> + <span + v-html="warningSvg" + class="merge-opt-icon" + aria-hidden="true"></span> + <span class="merge-opt-title">Merge immediately</span> + </a> + </li> + </ul> + </span> + <template v-if="isMergeAllowed()"> + <label class="spacing"> + <input + v-model="removeSourceBranch" + :disabled="isMergeButtonDisabled" + type="checkbox"/> Remove source branch + </label> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + :mr="mr" + :is-merge-button-disabled="isMergeButtonDisabled" /> + + <button + @click="toggleCommitMessageEditor" + :disabled="isMergeButtonDisabled" + class="btn btn-default btn-xs" + type="button"> + Modify commit message + </button> + <div + v-if="showCommitMessageEditor" + class="prepend-top-default commit-message-editor"> + <div class="form-group clearfix"> + <label + class="control-label" + for="commit-message"> + Commit message + </label> + <div class="col-sm-10"> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + v-model="commitMessage" + class="form-control js-commit-message" + required="required" + rows="14" + name="Commit message"></textarea> + </div> + <p class="hint">Try to keep the first line under 52 characters and the others under 72.</p> + <div class="hint"> + <a + @click.prevent="updateCommitMessage" + href="#">{{commitMessageLinkTitle}}</a> + </div> + </div> + </div> + </div> + </template> + <template v-else> + <span class="bold"> + The pipeline for this merge request failed. Please retry the job or push a new commit to fix the failure. + </span> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js new file mode 100644 index 00000000000..79f8ef408e6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetSHAMismatch', + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + The source branch HEAD has recently changed. Please reload the page and review the changes before merging. + </span> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js new file mode 100644 index 00000000000..bf8628d18a6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_squash_before_merge.js @@ -0,0 +1,15 @@ +/* +The squash-before-merge button is EE only, but it's located right in the middle +of the readyToMerge state component template. + +If we didn't declare this component in CE, we'd need to maintain a separate copy +of the readyToMergeState template in EE, which is pretty big and likely to change. + +Instead, in CE, we declare the component, but it's hidden and is configured to do nothing. +In EE, the configuration extends this object to add a functioning squash-before-merge +button. +*/ + +export default { + template: '', +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js new file mode 100644 index 00000000000..f4ab2d9fa58 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions.js @@ -0,0 +1,27 @@ +export default { + name: 'MRWidgetUnresolvedDiscussions', + props: { + mr: { type: Object, required: true }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge + </button> + <span class="bold"> + There are unresolved discussions. Please resolve these discussions + <span v-if="mr.canCreateIssue">or</span> + <span v-else>.</span> + </span> + <a + v-if="mr.createIssueToResolveDiscussionsPath" + :href="mr.createIssueToResolveDiscussionsPath" + class="btn btn-default btn-xs js-create-issue"> + Create an issue to resolve them later + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js new file mode 100644 index 00000000000..cb02ffe93bd --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_wip.js @@ -0,0 +1,59 @@ +/* global Flash */ +import eventHub from '../../event_hub'; + +export default { + name: 'MRWidgetWIP', + props: { + mr: { type: Object, required: true }, + service: { type: Object, required: true }, + }, + data() { + return { + isMakingRequest: false, + }; + }, + methods: { + removeWIP() { + this.isMakingRequest = true; + this.service.removeWIP() + .then(res => res.json()) + .then((res) => { + eventHub.$emit('UpdateWidgetData', res); + new Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line + $('.merge-request .detail-page-description .title').text(this.mr.title); + }) + .catch(() => { + this.isMakingRequest = false; + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + }, + template: ` + <div class="mr-widget-body"> + <button + type="button" + class="btn btn-success btn-small" + disabled="true"> + Merge</button> + <span class="bold"> + This merge request is currently Work In Progress and therefore unable to merge + </span> + <template v-if="mr.removeWIPPath"> + <i + class="fa fa-question-circle has-tooltip" + title="When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged." /> + <button + @click="removeWIP" + :disabled="isMakingRequest" + type="button" + class="btn btn-default btn-xs js-remove-wip"> + <i + v-if="isMakingRequest" + class="fa fa-spinner fa-spin" + aria-hidden="true" /> + Resolve WIP status + </button> + </template> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js new file mode 100644 index 00000000000..bfe30ee4c08 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -0,0 +1,43 @@ +/** + * This file is the centerpiece of an attempt to reduce potential conflicts + * between the CE and EE versions of the MR widget. EE additions to the MR widget should + * be contained in the ./vue_merge_request_widget/ee directory, and should **extend** + * rather than mutate CE MR Widget code. + * + * This file should be the only source of conflicts between EE and CE. EE-only components should + * imported directly where they are needed, and import paths for EE extensions of CE components + * should overwrite import paths **without** changing the order of dependencies listed here. + */ + +export { default as Vue } from 'vue'; +export { default as SmartInterval } from '~/smart_interval'; +export { default as WidgetHeader } from './components/mr_widget_header'; +export { default as WidgetMergeHelp } from './components/mr_widget_merge_help'; +export { default as WidgetPipeline } from './components/mr_widget_pipeline'; +export { default as WidgetDeployment } from './components/mr_widget_deployment'; +export { default as WidgetRelatedLinks } from './components/mr_widget_related_links'; +export { default as MergedState } from './components/states/mr_widget_merged'; +export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge'; +export { default as ClosedState } from './components/states/mr_widget_closed'; +export { default as LockedState } from './components/states/mr_widget_locked'; +export { default as WipState } from './components/states/mr_widget_wip'; +export { default as ArchivedState } from './components/states/mr_widget_archived'; +export { default as ConflictsState } from './components/states/mr_widget_conflicts'; +export { default as NothingToMergeState } from './components/states/mr_widget_nothing_to_merge'; +export { default as MissingBranchState } from './components/states/mr_widget_missing_branch'; +export { default as NotAllowedState } from './components/states/mr_widget_not_allowed'; +export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; +export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch'; +export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; +export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked'; +export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; +export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds'; +export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed'; +export { default as CheckingState } from './components/states/mr_widget_checking'; +export { default as MRWidgetStore } from './stores/mr_widget_store'; +export { default as MRWidgetService } from './services/mr_widget_service'; +export { default as eventHub } from './event_hub'; +export { default as getStateKey } from './stores/get_state_key'; +export { default as mrWidgetOptions } from './mr_widget_options'; +export { default as stateMaps } from './stores/state_maps'; +export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge'; diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js new file mode 100644 index 00000000000..cd65ac069c5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -0,0 +1,12 @@ +import { + Vue, + mrWidgetOptions, +} from './dependencies'; + +document.addEventListener('DOMContentLoaded', () => { + const vm = new Vue(mrWidgetOptions); + + window.gl.mrWidget = { + checkStatus: vm.checkStatus, + }; +}); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js new file mode 100644 index 00000000000..99600b6664e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -0,0 +1,235 @@ +/* global Flash */ + +import { + WidgetHeader, + WidgetMergeHelp, + WidgetPipeline, + WidgetDeployment, + WidgetRelatedLinks, + MergedState, + ClosedState, + LockedState, + WipState, + ArchivedState, + ConflictsState, + NothingToMergeState, + MissingBranchState, + NotAllowedState, + ReadyToMergeState, + SHAMismatchState, + UnresolvedDiscussionsState, + PipelineBlockedState, + PipelineFailedState, + FailedToMerge, + MergeWhenPipelineSucceedsState, + AutoMergeFailed, + CheckingState, + MRWidgetStore, + MRWidgetService, + eventHub, + stateMaps, + SquashBeforeMerge, +} from './dependencies'; + +export default { + el: '#js-vue-mr-widget', + name: 'MRWidget', + data() { + const store = new MRWidgetStore(gl.mrWidgetData); + const service = this.createService(store); + return { + mr: store, + service, + }; + }, + computed: { + componentName() { + return stateMaps.stateToComponentMap[this.mr.state]; + }, + shouldRenderMergeHelp() { + return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1; + }, + shouldRenderPipelines() { + return Object.keys(this.mr.pipeline).length || this.mr.hasCI; + }, + shouldRenderRelatedLinks() { + return this.mr.relatedLinks; + }, + shouldRenderDeployments() { + return this.mr.deployments.length; + }, + }, + methods: { + createService(store) { + const endpoints = { + mergePath: store.mergePath, + mergeCheckPath: store.mergeCheckPath, + cancelAutoMergePath: store.cancelAutoMergePath, + removeWIPPath: store.removeWIPPath, + sourceBranchPath: store.sourceBranchPath, + ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath, + statusPath: store.statusPath, + mergeActionsContentPath: store.mergeActionsContentPath, + }; + return new MRWidgetService(endpoints); + }, + checkStatus(cb) { + this.service.checkStatus() + .then(res => res.json()) + .then((res) => { + this.mr.setData(res); + this.setFavicon(); + if (cb) { + cb.call(null, res); + } + }) + .catch(() => { + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + initPolling() { + this.pollingInterval = new gl.SmartInterval({ + callback: this.checkStatus, + startingInterval: 10000, + maxInterval: 30000, + hiddenInterval: 120000, + incrementByFactorOf: 5000, + }); + }, + initDeploymentsPolling() { + this.deploymentsInterval = new gl.SmartInterval({ + callback: this.fetchDeployments, + startingInterval: 30000, + maxInterval: 120000, + hiddenInterval: 240000, + incrementByFactorOf: 15000, + immediateExecution: true, + }); + }, + setFavicon() { + if (this.mr.ciStatusFaviconPath) { + gl.utils.setFavicon(this.mr.ciStatusFaviconPath); + } + }, + fetchDeployments() { + this.service.fetchDeployments() + .then(res => res.json()) + .then((res) => { + if (res.length) { + this.mr.deployments = res; + } + }) + .catch(() => { + new Flash('Something went wrong while fetching the environments for this merge request. Please try again.'); // eslint-disable-line + }); + }, + fetchActionsContent() { + this.service.fetchMergeActionsContent() + .then((res) => { + if (res.body) { + const el = document.createElement('div'); + el.innerHTML = res.body; + document.body.appendChild(el); + } + }) + .catch(() => { + new Flash('Something went wrong. Please try again.'); // eslint-disable-line + }); + }, + resumePolling() { + this.pollingInterval.resume(); + }, + stopPolling() { + this.pollingInterval.stopTimer(); + }, + bindEventHubListeners() { + eventHub.$on('MRWidgetUpdateRequested', (cb) => { + this.checkStatus(cb); + }); + + // `params` should be an Array contains a Boolean, like `[true]` + // Passing parameter as Boolean didn't work. + eventHub.$on('SetBranchRemoveFlag', (params) => { + this.mr.isRemovingSourceBranch = params[0]; + }); + + eventHub.$on('FailedToMerge', (mergeError) => { + this.mr.state = 'failedToMerge'; + this.mr.mergeError = mergeError; + }); + + eventHub.$on('UpdateWidgetData', (data) => { + this.mr.setData(data); + }); + + eventHub.$on('FetchActionsContent', () => { + this.fetchActionsContent(); + }); + + eventHub.$on('EnablePolling', () => { + this.resumePolling(); + }); + + eventHub.$on('DisablePolling', () => { + this.stopPolling(); + }); + }, + handleMounted() { + this.setFavicon(); + this.initDeploymentsPolling(); + }, + }, + created() { + this.initPolling(); + this.bindEventHubListeners(); + }, + mounted() { + this.handleMounted(); + }, + components: { + 'mr-widget-header': WidgetHeader, + 'mr-widget-merge-help': WidgetMergeHelp, + 'mr-widget-pipeline': WidgetPipeline, + 'mr-widget-deployment': WidgetDeployment, + 'mr-widget-related-links': WidgetRelatedLinks, + 'mr-widget-merged': MergedState, + 'mr-widget-closed': ClosedState, + 'mr-widget-locked': LockedState, + 'mr-widget-failed-to-merge': FailedToMerge, + 'mr-widget-wip': WipState, + 'mr-widget-archived': ArchivedState, + 'mr-widget-conflicts': ConflictsState, + 'mr-widget-nothing-to-merge': NothingToMergeState, + 'mr-widget-not-allowed': NotAllowedState, + 'mr-widget-missing-branch': MissingBranchState, + 'mr-widget-ready-to-merge': ReadyToMergeState, + 'mr-widget-sha-mismatch': SHAMismatchState, + 'mr-widget-squash-before-merge': SquashBeforeMerge, + 'mr-widget-checking': CheckingState, + 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, + 'mr-widget-pipeline-blocked': PipelineBlockedState, + 'mr-widget-pipeline-failed': PipelineFailedState, + 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState, + 'mr-widget-auto-merge-failed': AutoMergeFailed, + }, + template: ` + <div class="mr-state-widget prepend-top-default"> + <mr-widget-header :mr="mr" /> + <mr-widget-pipeline + v-if="shouldRenderPipelines" + :mr="mr" /> + <mr-widget-deployment + v-if="shouldRenderDeployments" + :mr="mr" + :service="service" /> + <component + :is="componentName" + :mr="mr" + :service="service" /> + <mr-widget-related-links + v-if="shouldRenderRelatedLinks" + :related-links="mr.relatedLinks" /> + <mr-widget-merge-help v-if="shouldRenderMergeHelp" /> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js new file mode 100644 index 00000000000..79c3d335679 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class MRWidgetService { + constructor(endpoints) { + this.mergeResource = Vue.resource(endpoints.mergePath); + this.mergeCheckResource = Vue.resource(endpoints.statusPath); + this.cancelAutoMergeResource = Vue.resource(endpoints.cancelAutoMergePath); + this.removeWIPResource = Vue.resource(endpoints.removeWIPPath); + this.removeSourceBranchResource = Vue.resource(endpoints.sourceBranchPath); + this.deploymentsResource = Vue.resource(endpoints.ciEnvironmentsStatusPath); + this.pollResource = Vue.resource(`${endpoints.statusPath}?basic=true`); + this.mergeActionsContentResource = Vue.resource(endpoints.mergeActionsContentPath); + } + + merge(data) { + return this.mergeResource.save(data); + } + + cancelAutomaticMerge() { + return this.cancelAutoMergeResource.save(); + } + + removeWIP() { + return this.removeWIPResource.save(); + } + + removeSourceBranch() { + return this.removeSourceBranchResource.delete(); + } + + fetchDeployments() { + return this.deploymentsResource.get(); + } + + poll() { + return this.pollResource.get(); + } + + checkStatus() { + return this.mergeCheckResource.get(); + } + + fetchMergeActionsContent() { + return this.mergeActionsContentResource.get(); + } + + static stopEnvironment(url) { + return Vue.http.post(url); + } + + static fetchMetrics(metricsUrl) { + return Vue.http.get(`${metricsUrl}.json`); + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js new file mode 100644 index 00000000000..fb78ea92da1 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -0,0 +1,30 @@ +export default function deviseState(data) { + if (data.project_archived) { + return 'archived'; + } else if (data.branch_missing) { + return 'missingBranch'; + } else if (!data.commits_count) { + return 'nothingToMerge'; + } else if (this.mergeStatus === 'unchecked') { + return 'checking'; + } else if (data.has_conflicts) { + return 'conflicts'; + } else if (data.work_in_progress) { + return 'workInProgress'; + } else if (this.mergeWhenPipelineSucceeds) { + return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds'; + } else if (!this.canMerge) { + return 'notAllowedToMerge'; + } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { + return 'pipelineFailed'; + } else if (this.hasMergeableDiscussionsState) { + return 'unresolvedDiscussions'; + } else if (this.isPipelineBlocked) { + return 'pipelineBlocked'; + } else if (this.hasSHAChanged) { + return 'shaMismatch'; + } else if (this.canBeMerged) { + return 'readyToMerge'; + } + return null; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js new file mode 100644 index 00000000000..06661b930e3 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -0,0 +1,137 @@ +import Timeago from 'timeago.js'; +import { getStateKey } from '../dependencies'; + +export default class MergeRequestStore { + + constructor(data) { + this.startingSha = data.diff_head_sha; + this.setData(data); + } + + setData(data) { + const currentUser = data.current_user; + const pipelineStatus = data.pipeline ? data.pipeline.details.status : null; + + this.title = data.title; + this.targetBranch = data.target_branch; + this.sourceBranch = data.source_branch; + this.mergeStatus = data.merge_status; + this.sha = data.diff_head_sha; + this.commitMessage = data.merge_commit_message; + this.commitMessageWithDescription = data.merge_commit_message_with_description; + this.commitsCount = data.commits_count; + this.divergedCommitsCount = data.diverged_commits_count; + this.pipeline = data.pipeline || {}; + this.deployments = this.deployments || data.deployments || []; + + if (data.issues_links) { + const links = data.issues_links; + const { closing } = links; + const mentioned = links.mentioned_but_not_closing; + const assignToMe = links.assign_to_closing; + + if (closing || mentioned || assignToMe) { + this.relatedLinks = { closing, mentioned, assignToMe }; + } + } + + this.updatedAt = data.updated_at; + this.mergedAt = MergeRequestStore.getEventDate(data.merge_event); + this.closedAt = MergeRequestStore.getEventDate(data.closed_event); + this.mergedBy = MergeRequestStore.getAuthorObject(data.merge_event); + this.closedBy = MergeRequestStore.getAuthorObject(data.closed_event); + this.setToMWPSBy = MergeRequestStore.getAuthorObject({ author: data.merge_user || {} }); + this.mergeUserId = data.merge_user_id; + this.currentUserId = gon.current_user_id; + this.sourceBranchPath = data.source_branch_path; + this.sourceBranchLink = data.source_branch_with_namespace_link; + this.mergeError = data.merge_error; + this.targetBranchPath = data.target_branch_commits_path; + this.conflictResolutionPath = data.conflict_resolution_path; + this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; + this.removeWIPPath = data.remove_wip_path; + this.sourceBranchRemoved = !data.source_branch_exists; + this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false; + this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; + this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; + this.mergePath = data.merge_path; + this.statusPath = data.status_path; + this.emailPatchesPath = data.email_patches_path; + this.plainDiffPath = data.plain_diff_path; + this.newBlobPath = data.new_blob_path; + this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path; + this.mergeCheckPath = data.merge_check_path; + this.mergeActionsContentPath = data.commit_change_content_path; + this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; + this.isOpen = data.state === 'opened' || data.state === 'reopened' || false; + this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; + this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false; + this.canMerge = !!data.merge_path; + this.canCreateIssue = currentUser.can_create_issue || false; + this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; + this.hasSHAChanged = this.sha !== this.startingSha; + this.canBeMerged = data.can_be_merged || false; + + // Cherry-pick and Revert actions related + this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; + this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; + this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; + this.revertInForkPath = currentUser.revert_in_fork_path; + + // CI related + this.ciEnvironmentsStatusPath = data.ci_environments_status_path; + this.hasCI = data.has_ci; + this.ciStatus = data.ci_status; + this.isPipelineFailed = this.ciStatus ? (this.ciStatus === 'failed' || this.ciStatus === 'canceled') : false; + this.pipelineDetailedStatus = pipelineStatus; + this.isPipelineActive = data.pipeline ? data.pipeline.active : false; + this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false; + this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; + + this.setState(data); + } + + setState(data) { + if (this.isOpen) { + this.state = getStateKey.call(this, data); + } else { + switch (data.state) { + case 'merged': + this.state = 'merged'; + break; + case 'closed': + this.state = 'closed'; + break; + case 'locked': + this.state = 'locked'; + break; + default: + this.state = null; + } + } + } + + static getAuthorObject(event) { + if (!event) { + return {}; + } + + return { + name: event.author.name || '', + username: event.author.username || '', + webUrl: event.author.web_url || '', + avatarUrl: event.author.avatar_url || '', + }; + } + + static getEventDate(event) { + const timeagoInstance = new Timeago(); + + if (!event) { + return ''; + } + + return timeagoInstance.format(event.updated_at); + } + +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js new file mode 100644 index 00000000000..605dd3a1ff4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -0,0 +1,37 @@ +const stateToComponentMap = { + merged: 'mr-widget-merged', + closed: 'mr-widget-closed', + locked: 'mr-widget-locked', + conflicts: 'mr-widget-conflicts', + missingBranch: 'mr-widget-missing-branch', + workInProgress: 'mr-widget-wip', + readyToMerge: 'mr-widget-ready-to-merge', + nothingToMerge: 'mr-widget-nothing-to-merge', + notAllowedToMerge: 'mr-widget-not-allowed', + archived: 'mr-widget-archived', + checking: 'mr-widget-checking', + unresolvedDiscussions: 'mr-widget-unresolved-discussions', + pipelineBlocked: 'mr-widget-pipeline-blocked', + pipelineFailed: 'mr-widget-pipeline-failed', + mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', + failedToMerge: 'mr-widget-failed-to-merge', + autoMergeFailed: 'mr-widget-auto-merge-failed', + shaMismatch: 'mr-widget-sha-mismatch', +}; + +const statesToShowHelpWidget = [ + 'locked', + 'conflicts', + 'workInProgress', + 'readyToMerge', + 'checking', + 'unresolvedDiscussions', + 'pipelineFailed', + 'pipelineBlocked', + 'autoMergeFailed', +]; + +export default { + stateToComponentMap, + statesToShowHelpWidget, +}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/empty_state.js b/app/assets/javascripts/vue_pipelines_index/components/empty_state.js deleted file mode 100644 index 56b4858f4b4..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/components/empty_state.js +++ /dev/null @@ -1,33 +0,0 @@ -import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg'; - -export default { - props: { - helpPagePath: { - type: String, - required: true, - }, - }, - - template: ` - <div class="row empty-state"> - <div class="col-xs-12"> - <div class="svg-content"> - ${pipelinesEmptyStateSVG} - </div> - </div> - - <div class="col-xs-12 text-center"> - <div class="text-content"> - <h4>Build with confidence</h4> - <p> - Continous Integration can help catch bugs by running your tests automatically, - while Continuous Deployment can help you deliver code to your product environment. - </p> - <a :href="helpPagePath" class="btn btn-info"> - Get started with Pipelines - </a> - </div> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/error_state.js b/app/assets/javascripts/vue_pipelines_index/components/error_state.js deleted file mode 100644 index e5d228bddf8..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/components/error_state.js +++ /dev/null @@ -1,19 +0,0 @@ -import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg'; - -export default { - template: ` - <div class="row empty-state js-pipelines-error-state"> - <div class="col-xs-12"> - <div class="svg-content"> - ${pipelinesErrorStateSVG} - </div> - </div> - - <div class="col-xs-12 text-center"> - <div class="text-content"> - <h4>The API failed to fetch the pipelines.</h4> - </div> - </div> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/stage.js b/app/assets/javascripts/vue_pipelines_index/components/stage.js deleted file mode 100644 index a2c29002707..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/components/stage.js +++ /dev/null @@ -1,116 +0,0 @@ -/* global Flash */ -import canceledSvg from 'icons/_icon_status_canceled_borderless.svg'; -import createdSvg from 'icons/_icon_status_created_borderless.svg'; -import failedSvg from 'icons/_icon_status_failed_borderless.svg'; -import manualSvg from 'icons/_icon_status_manual_borderless.svg'; -import pendingSvg from 'icons/_icon_status_pending_borderless.svg'; -import runningSvg from 'icons/_icon_status_running_borderless.svg'; -import skippedSvg from 'icons/_icon_status_skipped_borderless.svg'; -import successSvg from 'icons/_icon_status_success_borderless.svg'; -import warningSvg from 'icons/_icon_status_warning_borderless.svg'; - -export default { - data() { - const svgsDictionary = { - icon_status_canceled: canceledSvg, - icon_status_created: createdSvg, - icon_status_failed: failedSvg, - icon_status_manual: manualSvg, - icon_status_pending: pendingSvg, - icon_status_running: runningSvg, - icon_status_skipped: skippedSvg, - icon_status_success: successSvg, - icon_status_warning: warningSvg, - }; - - return { - builds: '', - spinner: '<span class="fa fa-spinner fa-spin"></span>', - svg: svgsDictionary[this.stage.status.icon], - }; - }, - - props: { - stage: { - type: Object, - required: true, - }, - }, - - updated() { - if (this.builds) { - this.stopDropdownClickPropagation(); - } - }, - - methods: { - fetchBuilds(e) { - const ariaExpanded = e.currentTarget.attributes['aria-expanded']; - - if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null; - - return this.$http.get(this.stage.dropdown_path) - .then((response) => { - this.builds = JSON.parse(response.body).html; - }, () => { - const flash = new Flash('Something went wrong on our end.'); - return flash; - }); - }, - - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')).on('click', (e) => { - e.stopPropagation(); - }); - }, - }, - computed: { - buildsOrSpinner() { - return this.builds ? this.builds : this.spinner; - }, - dropdownClass() { - if (this.builds) return 'js-builds-dropdown-container'; - return 'js-builds-dropdown-loading builds-dropdown-loading'; - }, - buildStatus() { - return `Build: ${this.stage.status.label}`; - }, - tooltip() { - return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; - }, - triggerButtonClass() { - return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; - }, - }, - template: ` - <div> - <button - @click="fetchBuilds($event)" - :class="triggerButtonClass" - :title="stage.title" - data-placement="top" - data-toggle="dropdown" - type="button" - :aria-label="stage.title"> - <span v-html="svg" aria-hidden="true"></span> - <i class="fa fa-caret-down" aria-hidden="true"></i> - </button> - <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> - <div class="arrow-up" aria-hidden="true"></div> - <div - :class="dropdownClass" - class="js-builds-dropdown-list scrollable-menu" - v-html="buildsOrSpinner"> - </div> - </ul> - </div> - `, -}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/status.js b/app/assets/javascripts/vue_pipelines_index/components/status.js deleted file mode 100644 index 21a281af438..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/components/status.js +++ /dev/null @@ -1,60 +0,0 @@ -import canceledSvg from 'icons/_icon_status_canceled.svg'; -import createdSvg from 'icons/_icon_status_created.svg'; -import failedSvg from 'icons/_icon_status_failed.svg'; -import manualSvg from 'icons/_icon_status_manual.svg'; -import pendingSvg from 'icons/_icon_status_pending.svg'; -import runningSvg from 'icons/_icon_status_running.svg'; -import skippedSvg from 'icons/_icon_status_skipped.svg'; -import successSvg from 'icons/_icon_status_success.svg'; -import warningSvg from 'icons/_icon_status_warning.svg'; - -export default { - props: { - pipeline: { - type: Object, - required: true, - }, - }, - - data() { - const svgsDictionary = { - icon_status_canceled: canceledSvg, - icon_status_created: createdSvg, - icon_status_failed: failedSvg, - icon_status_manual: manualSvg, - icon_status_pending: pendingSvg, - icon_status_running: runningSvg, - icon_status_skipped: skippedSvg, - icon_status_success: successSvg, - icon_status_warning: warningSvg, - }; - - return { - svg: svgsDictionary[this.pipeline.details.status.icon], - }; - }, - - computed: { - cssClasses() { - return `ci-status ci-${this.pipeline.details.status.group}`; - }, - - detailsPath() { - const { status } = this.pipeline.details; - return status.has_details ? status.details_path : false; - }, - - content() { - return `${this.svg} ${this.pipeline.details.status.text}`; - }, - }, - template: ` - <td class="commit-link"> - <a - :class="cssClasses" - :href="detailsPath" - v-html="content"> - </a> - </td> - `, -}; diff --git a/app/assets/javascripts/vue_pipelines_index/components/time_ago.js b/app/assets/javascripts/vue_pipelines_index/components/time_ago.js deleted file mode 100644 index 498d0715f54..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/components/time_ago.js +++ /dev/null @@ -1,71 +0,0 @@ -import iconTimerSvg from 'icons/_icon_timer.svg'; -import '../../lib/utils/datetime_utility'; - -export default { - data() { - return { - currentTime: new Date(), - iconTimerSvg, - }; - }, - props: ['pipeline'], - computed: { - timeAgo() { - return gl.utils.getTimeago(); - }, - localTimeFinished() { - return gl.utils.formatDate(this.pipeline.details.finished_at); - }, - timeStopped() { - const changeTime = this.currentTime; - const options = { - weekday: 'long', - year: 'numeric', - month: 'short', - day: 'numeric', - }; - options.timeZoneName = 'short'; - const finished = this.pipeline.details.finished_at; - if (!finished && changeTime) return false; - return ({ words: this.timeAgo.format(finished) }); - }, - duration() { - const { duration } = this.pipeline.details; - const date = new Date(duration * 1000); - - let hh = date.getUTCHours(); - let mm = date.getUTCMinutes(); - let ss = date.getSeconds(); - - if (hh < 10) hh = `0${hh}`; - if (mm < 10) mm = `0${mm}`; - if (ss < 10) ss = `0${ss}`; - - if (duration !== null) return `${hh}:${mm}:${ss}`; - return false; - }, - }, - methods: { - changeTime() { - this.currentTime = new Date(); - }, - }, - template: ` - <td class="pipelines-time-ago"> - <p class="duration" v-if='duration'> - <span v-html="iconTimerSvg"></span> - {{duration}} - </p> - <p class="finished-at" v-if='timeStopped'> - <i class="fa fa-calendar"></i> - <time - data-toggle="tooltip" - data-placement="top" - data-container="body" - :data-original-title='localTimeFinished'> - {{timeStopped.words}} - </time> - </p> - </td> - `, -}; diff --git a/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js b/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js deleted file mode 100644 index 7ac10086a55..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/stores/pipelines_store.js +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable no-underscore-dangle*/ -import '../../vue_realtime_listener'; - -export default class PipelinesStore { - constructor() { - this.state = {}; - - this.state.pipelines = []; - this.state.count = {}; - this.state.pageInfo = {}; - } - - storePipelines(pipelines = []) { - this.state.pipelines = pipelines; - } - - storeCount(count = {}) { - this.state.count = count; - } - - storePagination(pagination = {}) { - let paginationInfo; - - if (Object.keys(pagination).length) { - const normalizedHeaders = gl.utils.normalizeHeaders(pagination); - paginationInfo = gl.utils.parseIntPagination(normalizedHeaders); - } else { - paginationInfo = pagination; - } - - this.state.pageInfo = paginationInfo; - } - - /** - * FIXME: Move this inside the component. - * - * Once the data is received we will start the time ago loops. - * - * Everytime a request is made like retry or cancel a pipeline, every 10 seconds we - * update the time to show how long as passed. - * - */ - startTimeAgoLoops() { - const startTimeLoops = () => { - this.timeLoopInterval = setInterval(() => { - this.$children[0].$children.reduce((acc, component) => { - const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0]; - acc.push(timeAgoComponent); - return acc; - }, []).forEach(e => e.changeTime()); - }, 10000); - }; - - startTimeLoops(); - - const removeIntervals = () => clearInterval(this.timeLoopInterval); - const startIntervals = () => startTimeLoops(); - - gl.VueRealtimeListener(removeIntervals, startIntervals); - } -} diff --git a/app/assets/javascripts/vue_realtime_listener/index.js b/app/assets/javascripts/vue_realtime_listener/index.js deleted file mode 100644 index 30f6680a673..00000000000 --- a/app/assets/javascripts/vue_realtime_listener/index.js +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-disable no-param-reassign */ - -((gl) => { - gl.VueRealtimeListener = (removeIntervals, startIntervals) => { - const removeAll = () => { - removeIntervals(); - window.removeEventListener('beforeunload', removeIntervals); - window.removeEventListener('focus', startIntervals); - window.removeEventListener('blur', removeIntervals); - document.removeEventListener('beforeunload', removeAll); - }; - - window.addEventListener('beforeunload', removeIntervals); - window.addEventListener('focus', startIntervals); - window.addEventListener('blur', removeIntervals); - document.addEventListener('beforeunload', removeAll); - - // add removeAll methods to stack - const stack = gl.VueRealtimeListener.reset; - gl.VueRealtimeListener.reset = () => { - gl.VueRealtimeListener.reset = stack; - removeAll(); - stack(); - }; - }; - - // remove all event listeners and intervals - gl.VueRealtimeListener.reset = () => undefined; // noop -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_shared/ci_action_icons.js b/app/assets/javascripts/vue_shared/ci_action_icons.js new file mode 100644 index 00000000000..b21f0ab49fd --- /dev/null +++ b/app/assets/javascripts/vue_shared/ci_action_icons.js @@ -0,0 +1,21 @@ +import cancelSVG from 'icons/_icon_action_cancel.svg'; +import retrySVG from 'icons/_icon_action_retry.svg'; +import playSVG from 'icons/_icon_action_play.svg'; +import stopSVG from 'icons/_icon_action_stop.svg'; + +/** + * For the provided action returns the respective SVG + * + * @param {String} action + * @return {SVG|String} + */ +export default function getActionIcon(action) { + const icons = { + icon_action_cancel: cancelSVG, + icon_action_play: playSVG, + icon_action_retry: retrySVG, + icon_action_stop: stopSVG, + }; + + return icons[action] || ''; +} diff --git a/app/assets/javascripts/vue_shared/ci_status_icons.js b/app/assets/javascripts/vue_shared/ci_status_icons.js new file mode 100644 index 00000000000..d9d0cad38e4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/ci_status_icons.js @@ -0,0 +1,43 @@ +import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg'; +import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg'; +import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg'; +import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg'; +import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg'; +import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg'; +import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg'; +import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg'; +import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg'; + +import CANCELED_SVG from 'icons/_icon_status_canceled.svg'; +import CREATED_SVG from 'icons/_icon_status_created.svg'; +import FAILED_SVG from 'icons/_icon_status_failed.svg'; +import MANUAL_SVG from 'icons/_icon_status_manual.svg'; +import PENDING_SVG from 'icons/_icon_status_pending.svg'; +import RUNNING_SVG from 'icons/_icon_status_running.svg'; +import SKIPPED_SVG from 'icons/_icon_status_skipped.svg'; +import SUCCESS_SVG from 'icons/_icon_status_success.svg'; +import WARNING_SVG from 'icons/_icon_status_warning.svg'; + +export const borderlessStatusIconEntityMap = { + icon_status_canceled: BORDERLESS_CANCELED_SVG, + icon_status_created: BORDERLESS_CREATED_SVG, + icon_status_failed: BORDERLESS_FAILED_SVG, + icon_status_manual: BORDERLESS_MANUAL_SVG, + icon_status_pending: BORDERLESS_PENDING_SVG, + icon_status_running: BORDERLESS_RUNNING_SVG, + icon_status_skipped: BORDERLESS_SKIPPED_SVG, + icon_status_success: BORDERLESS_SUCCESS_SVG, + icon_status_warning: BORDERLESS_WARNING_SVG, +}; + +export const statusIconEntityMap = { + icon_status_canceled: CANCELED_SVG, + icon_status_created: CREATED_SVG, + icon_status_failed: FAILED_SVG, + icon_status_manual: MANUAL_SVG, + icon_status_pending: PENDING_SVG, + icon_status_running: RUNNING_SVG, + icon_status_skipped: SKIPPED_SVG, + icon_status_success: SUCCESS_SVG, + icon_status_warning: WARNING_SVG, +}; diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue new file mode 100644 index 00000000000..caa28bff6db --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -0,0 +1,52 @@ +<script> +import ciIcon from './ci_icon.vue'; +/** + * Renders CI Badge link with CI icon and status text based on + * API response shared between all places where it is used. + * + * Receives status object containing: + * status: { + * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url + * group:"running" // used for CSS class + * icon: "icon_status_running" // used to render the icon + * label:"running" // used for potential tooltip + * text:"running" // text rendered + * } + * + * Used in: + * - Pipelines table - first column + * - Jobs table - first column + * - Pipeline show view - header + * - Job show view - header + * - MR widget + */ + +export default { + props: { + status: { + type: Object, + required: true, + }, + }, + + components: { + ciIcon, + }, + + computed: { + cssClass() { + const className = this.status.group; + + return className ? `ci-status ci-${this.status.group}` : 'ci-status'; + }, + }, +}; +</script> +<template> + <a + :href="status.details_path" + :class="cssClass"> + <ci-icon :status="status" /> + {{status.text}} + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue new file mode 100644 index 00000000000..ec88119e16c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -0,0 +1,50 @@ +<script> + import { statusIconEntityMap } from '../ci_status_icons'; + + /** + * Renders CI icon based on API response shared between all places where it is used. + * + * Receives status object containing: + * status: { + * details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url + * group:"running" // used for CSS class + * icon: "icon_status_running" // used to render the icon + * label:"running" // used for potential tooltip + * text:"running" // text rendered + * } + * + * Used in: + * - Pipelines table Badge + * - Pipelines table mini graph + * - Pipeline graph + * - Pipeline show view badge + * - Jobs table + * - Jobs show view header + * - Jobs show view sidebar + */ + export default { + props: { + status: { + type: Object, + required: true, + }, + }, + + computed: { + statusIconSvg() { + return statusIconEntityMap[this.status.icon]; + }, + + cssClass() { + const status = this.status.group; + return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; + }, + }, + }; +</script> +<template> + <span + :class="cssClass" + v-html="statusIconSvg"> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js index fb68abd95a2..23bc5fbc034 100644 --- a/app/assets/javascripts/vue_shared/components/commit.js +++ b/app/assets/javascripts/vue_shared/components/commit.js @@ -1,4 +1,5 @@ import commitIconSvg from 'icons/_icon_commit.svg'; +import userAvatarLink from './user_avatar/user_avatar_link.vue'; export default { props: { @@ -110,6 +111,9 @@ export default { return { commitIconSvg }; }, + components: { + userAvatarLink, + }, template: ` <div class="branch-commit"> @@ -119,30 +123,28 @@ export default { </div> <a v-if="hasCommitRef" - class="monospace branch-name" + class="ref-name" :href="commitRef.ref_url"> {{commitRef.name}} </a> <div v-html="commitIconSvg" class="commit-icon js-commit-icon"></div> - <a class="commit-id monospace" + <a class="commit-sha" :href="commitUrl"> {{shortSha}} </a> <p class="commit-title"> <span v-if="title"> - <a v-if="hasAuthor" + <user-avatar-link + v-if="hasAuthor" class="avatar-image-container" - :href="author.web_url"> - <img - class="avatar has-tooltip s20" - :src="author.avatar_url" - :alt="userImageAltDescription" - :title="author.username" /> - </a> - + :link-href="author.web_url" + :img-src="author.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="author.username" + /> <a class="commit-row-message" :href="commitUrl"> {{title}} diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue new file mode 100644 index 00000000000..41b1d0165b0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue @@ -0,0 +1,33 @@ +<script> + export default { + props: { + label: { + type: String, + required: false, + default: 'Loading', + }, + + size: { + type: String, + required: false, + default: '1', + }, + }, + + computed: { + cssClass() { + return `fa-${this.size}x`; + }, + }, + }; +</script> +<template> + <div class="text-center"> + <i + class="fa fa-spin fa-spinner" + :class="cssClass" + aria-hidden="true" + :aria-label="label"> + </i> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.js b/app/assets/javascripts/vue_shared/components/memory_graph.js new file mode 100644 index 00000000000..643b77e04c7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/memory_graph.js @@ -0,0 +1,115 @@ +export default { + name: 'MemoryGraph', + props: { + metrics: { type: Array, required: true }, + deploymentTime: { type: Number, required: true }, + width: { type: String, required: true }, + height: { type: String, required: true }, + }, + data() { + return { + pathD: '', + pathViewBox: '', + dotX: '', + dotY: '', + }; + }, + computed: { + getFormattedMedian() { + const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000); + return `Deployed ${deployedSince}`; + }, + }, + methods: { + /** + * Returns metric value index in metrics array + * with timestamp closest to matching median + */ + getMedianMetricIndex(median, metrics) { + let matchIndex = 0; + let timestampDiff = 0; + let smallestDiff = 0; + + const metricTimestamps = metrics.map(v => v[0]); + + // Find metric timestamp which is closest to deploymentTime + timestampDiff = Math.abs(metricTimestamps[0] - median); + metricTimestamps.forEach((timestamp, index) => { + if (index === 0) { // Skip first element + return; + } + + smallestDiff = Math.abs(timestamp - median); + if (smallestDiff < timestampDiff) { + matchIndex = index; + timestampDiff = smallestDiff; + } + }); + + return matchIndex; + }, + + /** + * Get Graph Plotting values to render Line and Dot + */ + getGraphPlotValues(median, metrics) { + const renderData = metrics.map(v => v[1]); + const medianMetricIndex = this.getMedianMetricIndex(median, metrics); + let cx = 0; + let cy = 0; + + // Find Maximum and Minimum values from `renderData` array + const maxMemory = Math.max.apply(null, renderData); + const minMemory = Math.min.apply(null, renderData); + + // Find difference between extreme ends + const diff = maxMemory - minMemory; + const lineWidth = renderData.length; + + // Iterate over metrics values and perform following + // 1. Find x & y co-ords for deploymentTime's memory value + // 2. Return line path against maxMemory + const linePath = renderData.map((y, x) => { + if (medianMetricIndex === x) { + cx = x; + cy = maxMemory - y; + } + return `${x} ${maxMemory - y}`; + }); + + return { + pathD: linePath, + pathViewBox: { + lineWidth, + diff, + }, + dotX: cx, + dotY: cy, + }; + }, + + /** + * Render Graph based on provided median and metrics values + */ + renderGraph(median, metrics) { + const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics); + + // Set props and update graph on UI. + this.pathD = `M ${pathD}`; + this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`; + this.dotX = dotX; + this.dotY = dotY; + }, + }, + mounted() { + this.renderGraph(this.deploymentTime, this.metrics); + }, + template: ` + <div class="memory-graph-container"> + <svg class="has-tooltip" :title="getFormattedMedian" :width="width" :height="height" xmlns="http://www.w3.org/2000/svg"> + <path :d="pathD" :viewBox="pathViewBox" /> + <circle r="1.5" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" /> + </svg> + </div> + `, +}; diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js b/app/assets/javascripts/vue_shared/components/pipelines_table.js index afd8d7acf6b..48a39f18112 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js @@ -10,13 +10,18 @@ export default { pipelines: { type: Array, required: true, - default: () => ([]), }, service: { type: Object, required: true, }, + + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, + }, }, components: { @@ -40,7 +45,9 @@ export default { v-bind:model="model"> <tr is="pipelines-table-row-component" :pipeline="model" - :service="service"></tr> + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> </template> </tbody> </table> diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index f5b3cb9214e..30d16e4ed3e 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -1,12 +1,11 @@ /* eslint-disable no-param-reassign */ - -import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button'; -import PipelinesActionsComponent from '../../vue_pipelines_index/components/pipelines_actions'; -import PipelinesArtifactsComponent from '../../vue_pipelines_index/components/pipelines_artifacts'; -import PipelinesStatusComponent from '../../vue_pipelines_index/components/status'; -import PipelinesStageComponent from '../../vue_pipelines_index/components/stage'; -import PipelinesUrlComponent from '../../vue_pipelines_index/components/pipeline_url'; -import PipelinesTimeagoComponent from '../../vue_pipelines_index/components/time_ago'; +import AsyncButtonComponent from '../../pipelines/components/async_button.vue'; +import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions'; +import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; +import ciBadge from './ci_badge_link.vue'; +import PipelinesStageComponent from '../../pipelines/components/stage.vue'; +import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; +import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; import CommitComponent from './commit'; /** @@ -25,6 +24,12 @@ export default { type: Object, required: true, }, + + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, + }, }, components: { @@ -34,7 +39,7 @@ export default { 'commit-component': CommitComponent, 'dropdown-stage': PipelinesStageComponent, 'pipeline-url': PipelinesUrlComponent, - 'status-scope': PipelinesStatusComponent, + ciBadge, 'time-ago': PipelinesTimeagoComponent, }, @@ -57,10 +62,12 @@ export default { commitAuthor() { let commitAuthorInformation; + if (!this.pipeline || !this.pipeline.commit) { + return null; + } + // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline && - this.pipeline.commit && - this.pipeline.commit.author) { + if (this.pipeline.commit.author) { // 2. if person who is an author of a commit is a GitLab user // he/she can have a GitLab avatar if (this.pipeline.commit.author.avatar_url) { @@ -72,11 +79,8 @@ export default { avatar_url: this.pipeline.commit.author_gravatar_url, }); } - } - - // 4. If committer is not a GitLab User he/she can have a Gravatar - if (this.pipeline && - this.pipeline.commit) { + // 4. If committer is not a GitLab User he/she can have a Gravatar + } else { commitAuthorInformation = { avatar_url: this.pipeline.commit.author_gravatar_url, web_url: `mailto:${this.pipeline.commit.author_email}`, @@ -166,11 +170,46 @@ export default { } return undefined; }, + + /** + * Timeago components expects a number + * + * @return {type} description + */ + pipelineDuration() { + if (this.pipeline.details && this.pipeline.details.duration) { + return this.pipeline.details.duration; + } + + return 0; + }, + + /** + * Timeago component expects a String. + * + * @return {String} + */ + pipelineFinishedAt() { + if (this.pipeline.details && this.pipeline.details.finished_at) { + return this.pipeline.details.finished_at; + } + + return ''; + }, + + pipelineStatus() { + if (this.pipeline.details && this.pipeline.details.status) { + return this.pipeline.details.status; + } + return {}; + }, }, template: ` <tr class="commit"> - <status-scope :pipeline="pipeline"/> + <td class="commit-link"> + <ci-badge :status="pipelineStatus"/> + </td> <pipeline-url :pipeline="pipeline"></pipeline-url> @@ -188,11 +227,16 @@ export default { <div class="stage-container dropdown js-mini-pipeline-graph" v-if="pipeline.details.stages.length > 0" v-for="stage in pipeline.details.stages"> - <dropdown-stage :stage="stage"/> + + <dropdown-stage + :stage="stage" + :update-dropdown="updateGraphDropdown"/> </div> </td> - <time-ago :pipeline="pipeline"/> + <time-ago + :duration="pipelineDuration" + :finished-time="pipelineFinishedAt" /> <td class="pipeline-actions"> <div class="pull-right btn-group"> diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.js b/app/assets/javascripts/vue_shared/components/table_pagination.vue index ebb14912b00..5e7df22dd83 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.js +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -1,3 +1,4 @@ +<script> const PAGINATION_UI_BUTTON_LIMIT = 4; const UI_LIMIT = 6; const SPREAD = '...'; @@ -114,22 +115,23 @@ export default { return items; }, }, - template: ` - <div class="gl-pagination"> - <ul class="pagination clearfix"> - <li v-for='item in getItems' - :class='{ - page: item.page, - prev: item.prev, - next: item.next, - separator: item.separator, - active: item.active, - disabled: item.disabled - }' - > - <a @click="changePage($event)">{{item.title}}</a> - </li> - </ul> - </div> - `, }; +</script> +<template> + <div class="gl-pagination"> + <ul class="pagination clearfix"> + <li + v-for="item in getItems" + :class="{ + page: item.page, + prev: item.prev, + next: item.next, + separator: item.separator, + active: item.active, + disabled: item.disabled + }"> + <a @click="changePage($event)">{{item.title}}</a> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue new file mode 100644 index 00000000000..b8db6afda12 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -0,0 +1,80 @@ +<script> + +/* This is a re-usable vue component for rendering a user avatar that + does not need to link to the user's profile. The image and an optional + tooltip can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-image + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + +*/ + +import defaultAvatarUrl from 'images/no_avatar.png'; +import TooltipMixin from '../../mixins/tooltip'; + +export default { + name: 'UserAvatarImage', + mixins: [TooltipMixin], + props: { + imgSrc: { + type: String, + required: false, + default: defaultAvatarUrl, + }, + cssClasses: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: 'user avatar', + }, + size: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + }, + computed: { + tooltipContainer() { + return this.tooltipText ? 'body' : null; + }, + avatarSizeClass() { + return `s${this.size}`; + }, + }, +}; +</script> + +<template> + <img + class="avatar" + :class="[avatarSizeClass, cssClasses]" + :src="imgSrc" + :width="size" + :height="size" + :alt="imgAlt" + :data-container="tooltipContainer" + :data-placement="tooltipPlacement" + :title="tooltipText" + ref="tooltip" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue new file mode 100644 index 00000000000..95898d54cf7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -0,0 +1,80 @@ +<script> + +/* This is a re-usable vue component for rendering a user avatar wrapped in + a clickable link (likely to the user's profile). The link, image, and + tooltip can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-link + :link-href="userProfileUrl" + :img-src="userAvatarSrc" + :img-alt="tooltipText" + :img-size="20" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + +*/ + +import userAvatarImage from './user_avatar_image.vue'; + +export default { + name: 'UserAvatarLink', + components: { + userAvatarImage, + }, + props: { + linkHref: { + type: String, + required: false, + default: '', + }, + imgSrc: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: '', + }, + imgCssClasses: { + type: String, + required: false, + default: '', + }, + imgSize: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + }, +}; +</script> + +<template> + <a + class="user-avatar-link" + :href="linkHref"> + <user-avatar-image + :img-src="imgSrc" + :img-alt="imgAlt" + :css-classes="imgCssClasses" + :size="imgSize" + :tooltip-text="tooltipText" + :tooltip-placement="tooltipPlacement" + /> + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue new file mode 100644 index 00000000000..d2ff2ac006e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue @@ -0,0 +1,45 @@ +<script> + +/* This is a re-usable vue component for rendering a user avatar svg (typically + for a blank state). It will receive styles comparable to the user avatar, + but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported. + The svg and avatar size can be configured by props passed to this component. + + Sample configuration: + + <user-avatar-svg + :svg="potentialApproverSvg" + :size="20" + /> + +*/ + +export default { + props: { + svg: { + type: String, + required: true, + }, + size: { + type: Number, + required: false, + default: 20, + }, + }, + computed: { + avatarSizeClass() { + return `s${this.size}`; + }, + }, +}; +</script> + +<template> + <svg + :class="avatarSizeClass" + :height="size" + :width="size" + v-html="svg"> + </svg> +</template> + diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js new file mode 100644 index 00000000000..9bb948bff66 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js @@ -0,0 +1,9 @@ +export default { + mounted() { + $(this.$refs.tooltip).tooltip(); + }, + + updated() { + $(this.$refs.tooltip).tooltip('fixTitle'); + }, +}; diff --git a/app/assets/javascripts/vue_shared/translate.js b/app/assets/javascripts/vue_shared/translate.js new file mode 100644 index 00000000000..f83c4b00761 --- /dev/null +++ b/app/assets/javascripts/vue_shared/translate.js @@ -0,0 +1,42 @@ +import { + __, + n__, + s__, +} from '../locale'; + +export default (Vue) => { + Vue.mixin({ + methods: { + /** + Translates `text` + + @param text The text to be translated + @returns {String} The translated text + **/ + __, + /** + Translate the text with a number + if the number is more than 1 it will use the `pluralText` translation. + This method allows for contexts, see below re. contexts + + @param text Singular text to translate (eg. '%d day') + @param pluralText Plural text to translate (eg. '%d days') + @param count Number to decide which translation to use (eg. 2) + @returns {String} Translated text with the number replaced (eg. '2 days') + **/ + n__, + /** + Translate context based text + Either pass in the context translation like `Context|Text to translate` + or allow for dynamic text by doing passing in the context first & then the text to translate + + @param keyOrContext Can be either the key to translate including the context + (eg. 'Context|Text') or just the context for the translation + (eg. 'Context') + @param key Is the dynamic variable you want to be translated + @returns {String} Translated context based text + **/ + s__, + }, + }); +}; diff --git a/app/assets/javascripts/wikis.js b/app/assets/javascripts/wikis.js index 75fd1394a03..4194c1bc08d 100644 --- a/app/assets/javascripts/wikis.js +++ b/app/assets/javascripts/wikis.js @@ -1,8 +1,8 @@ /* eslint-disable no-param-reassign */ /* global Breakpoints */ -require('./breakpoints'); -require('vendor/jquery.nicescroll'); +import 'vendor/jquery.nicescroll'; +import './breakpoints'; ((global) => { class Wikis { diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index ce626cf7b46..b7fe552dec2 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,5 +1,4 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, consistent-return, camelcase, comma-dangle, max-len */ -/* global Dropzone */ /* global Mousetrap */ // Zen Mode (full screen) textarea @@ -7,10 +6,12 @@ /*= provides zen_mode:enter */ /*= provides zen_mode:leave */ -require('vendor/jquery.scrollTo'); -window.Dropzone = require('dropzone'); -require('mousetrap'); -require('mousetrap/plugins/pause/mousetrap-pause'); +import 'vendor/jquery.scrollTo'; +import Dropzone from 'dropzone'; +import 'mousetrap'; +import 'mousetrap/plugins/pause/mousetrap-pause'; + +window.Dropzone = Dropzone; // // ### Events diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 5bb7e8caec1..d2ec1791d2b 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -47,3 +47,4 @@ @import "framework/emoji-sprites.scss"; @import "framework/icons.scss"; @import "framework/snippets.scss"; +@import "framework/memory_graph.scss"; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 90935b9616b..3cd7f81da47 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -145,3 +145,45 @@ a { .dropdown-menu-nav a { transition: none; } + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.fade-in { + animation: fadeIn $fade-in-duration 1; +} + +@keyframes fadeInHalf { + 0% { + opacity: 0; + } + + 100% { + opacity: 0.5; + } +} + +.fade-in-half { + animation: fadeInHalf $fade-in-duration 1; +} + +@keyframes fadeInFull { + 0% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +} + +.fade-in-full { + animation: fadeInFull $fade-in-duration 1; +} diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index 3f5b78ed445..4ae2b164d2e 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -10,6 +10,8 @@ border-radius: $avatar_radius; border: 1px solid $avatar-border; &.s16 { @include avatar-size(16px, 6px); } + &.s18 { @include avatar-size(18px, 6px); } + &.s19 { @include avatar-size(19px, 6px); } &.s20 { @include avatar-size(20px, 7px); } &.s24 { @include avatar-size(24px, 8px); } &.s26 { @include avatar-size(26px, 8px); } @@ -93,3 +95,14 @@ align-self: center; } } + +.avatar-counter { + background-color: $gray-darkest; + color: $white-light; + border: 1px solid $border-color; + border-radius: 1em; + font-family: $regular_font; + font-size: 9px; + line-height: 16px; + text-align: center; +} diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 1ae144fb471..9159927ed8b 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -38,6 +38,15 @@ height: 300px; overflow-y: scroll; } + + .disabled { + cursor: default; + opacity: 0.5; + + &:hover { + transform: none; + } + } } .emoji-search { @@ -91,7 +100,7 @@ .award-menu-holder { display: inline-block; - position: relative; + position: absolute; .tooltip { white-space: nowrap; @@ -99,8 +108,7 @@ } .award-control { - margin: 3px 5px 3px 0; - padding: .35em .4em; + margin-right: 5px; outline: 0; &.disabled { @@ -117,11 +125,52 @@ &.active, &:hover, - &:active { + &:active, + &.is-active { background-color: $row-hover; border-color: $row-hover-border; box-shadow: none; outline: 0; + + .award-control-icon svg { + background: $award-emoji-positive-add-bg; + + path { + fill: $award-emoji-positive-add-lines; + } + } + + .award-control-icon-neutral { + opacity: 0; + } + + .award-control-icon-positive { + opacity: 1; + transform: scale(1.15); + } + } + + &.is-active { + .award-control-icon-positive { + opacity: 0; + transform: scale(1); + } + + .award-control-icon-super-positive { + opacity: 1; + transform: scale(1); + } + } + + &.user-authored { + cursor: default; + opacity: 0.65; + + &:hover, + &:active { + background-color: $white-light; + border-color: $border-color; + } } &.btn { @@ -162,9 +211,33 @@ color: $border-gray-normal; margin-top: 1px; padding: 0 2px; + + svg { + margin-bottom: 1px; + height: 18px; + width: 18px; + border-radius: 50%; + + path { + fill: $border-gray-normal; + } + } + } + + .award-control-icon-positive, + .award-control-icon-super-positive { + position: absolute; + left: 11px; + bottom: 7px; + opacity: 0; + @include transition(opacity, transform); } .award-control-text { vertical-align: middle; } } + +.note-awards .award-control-icon-positive { + left: 6px; +} diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 9a4129cdc8d..3dec911d289 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -230,7 +230,6 @@ float: right; margin-top: 8px; padding-bottom: 8px; - border-bottom: 1px solid $border-color; } } @@ -255,8 +254,65 @@ padding: 10px 0; } +.landing { + margin-bottom: $gl-padding; + overflow: hidden; + display: flex; + position: relative; + border: 1px solid $blue-300; + border-radius: $border-radius-default; + background-color: $blue-25; + justify-content: center; + + .dismiss-button { + position: absolute; + right: 6px; + top: 6px; + cursor: pointer; + color: $blue-300; + z-index: 1; + border: none; + background-color: transparent; + + &:hover, + &:focus { + border: none; + color: $blue-400; + } + } + + .svg-container { + align-self: center; + } + + .inner-content { + text-align: left; + white-space: nowrap; + + h4 { + color: $gl-text-color; + font-size: 17px; + } + + p { + color: $gl-text-color; + margin-bottom: $gl-padding; + } + } + + @media (max-width: $screen-sm-min) { + flex-direction: column; + + .inner-content { + white-space: normal; + padding: 0 28px; + text-align: center; + } + } +} + .empty-state { - margin: 100px 0 0; + margin: 5% auto 0; .text-content { max-width: 460px; @@ -279,23 +335,12 @@ } .btn { - margin: $btn-side-margin $btn-side-margin 0 0; - } - - @media(max-width: $screen-xs-max) { - margin-top: 50px; - text-align: center; + margin: $btn-side-margin 5px; - .btn { + @media(max-width: $screen-xs-max) { width: 100%; } } - - @media(min-width: $screen-xs-max) { - &.labels .text-content { - margin-top: 70px; - } - } } .flex-container-block { diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 9a0f7a14e57..759401a7806 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -5,7 +5,7 @@ direction: rtl; @media (min-width: $screen-sm-min) and (max-width: $screen-md-max) { - overflow-x: scroll; + overflow-x: auto; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 2c33b235980..57387b913dc 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -40,6 +40,10 @@ line-height: 24px; } +.bold { + font-weight: 600; +} + .tab-content { overflow: visible; } @@ -66,7 +70,7 @@ pre { } hr { - margin: $gl-padding 0; + margin: 24px 0; border-top: 1px solid darken($gray-normal, 8%); } @@ -88,7 +92,8 @@ hr { .item-title { font-weight: 600; } /** FLASH message **/ -.author_link { +.author_link, +.author-link { color: $gl-link-color; } @@ -420,6 +425,11 @@ table { } } +.bordered-box { + border: 1px solid $border-color; + border-radius: $border-radius-default; +} + .str-truncated { &-60 { @include str-truncated(60%); diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 2ede47e9de6..5ab48b6c874 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -14,14 +14,32 @@ } } +@mixin set-visible { + transform: translateY(0); + visibility: visible; + opacity: 1; + transition-duration: 100ms, 150ms, 25ms; + transition-delay: 35ms, 50ms, 25ms; +} + +@mixin set-invisible { + transform: translateY(-10px); + visibility: hidden; + opacity: 0; + transition-property: opacity, transform, visibility; + transition-duration: 70ms, 250ms, 250ms; + transition-timing-function: linear, $dropdown-animation-timing; + transition-delay: 25ms, 50ms, 0ms; +} + .open { .dropdown-menu, .dropdown-menu-nav { display: block; + @include set-visible; @media (max-width: $screen-xs-max) { width: 100%; - min-width: 240px; } } @@ -79,7 +97,7 @@ .fa-chevron-down { font-size: $dropdown-chevron-size; position: relative; - top: -3px; + top: -2px; margin-left: 5px; } @@ -161,8 +179,9 @@ .dropdown-menu, .dropdown-menu-nav { - display: none; + display: block; position: absolute; + width: 100%; top: 100%; left: 0; z-index: 9; @@ -176,9 +195,10 @@ border: 1px solid $dropdown-border-color; border-radius: $border-radius-base; box-shadow: 0 2px 4px $dropdown-shadow-color; + @include set-invisible; - .filtered-search-input-container & { - max-width: 280px; + @media (max-width: $screen-sm-min) { + width: 100%; } &.is-loading { @@ -191,6 +211,15 @@ } } + .shortcut-mappings { + display: none; + } + + &.shortcuts .shortcut-mappings { + display: inline-block; + margin-right: 5px; + } + ul { margin: 0; padding: 0; @@ -222,14 +251,16 @@ } .dropdown-header { - color: $gl-text-color; + color: $gl-text-color-secondary; font-size: 13px; - font-weight: 600; line-height: 22px; - text-transform: capitalize; padding: 0 16px; } + &.capitalize-header .dropdown-header { + text-transform: capitalize; + } + .separator + .dropdown-header { padding-top: 2px; } @@ -247,6 +278,23 @@ } } +.filtered-search-box-input-container .dropdown-menu, +.filtered-search-box-input-container .dropdown-menu-nav, +.comment-type-dropdown .dropdown-menu { + display: none; + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.filtered-search-box-input-container { + .dropdown-menu, + .dropdown-menu-nav { + max-width: 280px; + width: auto; + } +} + .dropdown-menu-drop-up { top: auto; bottom: 100%; @@ -291,8 +339,8 @@ .dropdown-menu-user { .avatar { float: left; - width: 30px; - height: 30px; + width: 2 * $gl-padding; + height: 2 * $gl-padding; margin: 0 10px 0 0; } } @@ -321,6 +369,10 @@ .dropdown-select { width: $dropdown-width; + + @media (max-width: $screen-sm-min) { + width: 100%; + } } .dropdown-menu-align-right { @@ -331,6 +383,7 @@ .dropdown-menu-selectable { a { padding-left: 26px; + position: relative; &.is-indeterminate, &.is-active { @@ -340,7 +393,8 @@ &::before { position: absolute; left: 6px; - top: 6px; + top: 50%; + transform: translateY(-50%); font: normal normal normal 14px/1 FontAwesome; font-size: inherit; text-rendering: auto; @@ -355,6 +409,9 @@ &.is-active::before { content: "\f00c"; + position: absolute; + top: 50%; + transform: translateY(-50%); } } } @@ -467,6 +524,11 @@ overflow-y: auto; } +.dropdown-info-note { + color: $gl-text-color-secondary; + text-align: center; +} + .dropdown-footer { padding-top: 10px; margin-top: 10px; @@ -554,3 +616,28 @@ color: $gl-text-color-secondary; } } + +.droplab-item-ignore { + pointer-events: none; +} + +.pika-single.animate-picker.is-bound, +.pika-single.animate-picker.is-bound.is-hidden { + /* + * Having `!important` is not recommended but + * since `pikaday` sets positioning inline + * there's no way it can be gracefully overridden + * using config options. + */ + position: absolute !important; + display: block; +} + +.pika-single.animate-picker.is-bound { + @include set-visible; +} + +.pika-single.animate-picker.is-bound.is-hidden { + @include set-invisible; + overflow: hidden; +} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index ffece53a093..f8674b763c8 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -4,13 +4,14 @@ */ .file-holder { border: 1px solid $border-color; + border-radius: $border-radius-default; &.file-holder-no-border { border: 0; } &.readme-holder { - margin: $gl-padding-top 0; + margin: $gl-padding 0; } table { @@ -25,7 +26,7 @@ text-align: left; padding: 10px $gl-padding; word-wrap: break-word; - border-radius: 3px 3px 0 0; + border-radius: $border-radius-default $border-radius-default 0 0; &.file-title-clear { padding-left: 0; @@ -61,11 +62,13 @@ .file-content { background: $white-light; - &.image_file { + &.image_file, + &.video { background: $file-image-bg; text-align: center; - img { + img, + video { padding: 20px; max-width: 80%; } @@ -73,14 +76,6 @@ &.wiki { padding: 30px $gl-padding; - - .highlight { - margin-bottom: 9px; - - > pre { - margin: 0; - } - } } &.blob-no-preview { @@ -100,9 +95,16 @@ tr { border-bottom: 1px solid $blame-border; + + &:last-child { + border-bottom: none; + } } td { + border-top: none; + border-bottom: none; + &:first-child { border-left: none; } @@ -113,7 +115,7 @@ } td.blame-commit { - padding: 0 10px; + padding: 5px 10px; min-width: 400px; background: $gray-light; } @@ -168,6 +170,18 @@ &.code { padding: 0; } + + .list-inline.previews { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; + align-items: baseline; + + .preview { + padding: $gl-padding; + } + } } } @@ -240,7 +254,7 @@ span.idiff { border-bottom: 1px solid $border-color; padding: 5px $gl-padding; margin: 0; - border-radius: 3px 3px 0 0; + border-radius: $border-radius-default $border-radius-default 0 0; .file-header-content { white-space: nowrap; @@ -275,3 +289,22 @@ span.idiff { } } } + +.is-stl-loading { + .stl-controls { + display: none; + } +} + +.file-fork-suggestion { + display: flex; + align-items: center; + justify-content: flex-end; + background-color: $gray-light; + border-bottom: 1px solid $border-color; + padding: 5px $gl-padding; +} + +.file-fork-suggestion-note { + margin-right: 1.5em; +} diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 51805c5d734..637731cc479 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -22,7 +22,6 @@ } @media (min-width: $screen-sm-min) { - .issues-filters, .issues_bulk_update { .dropdown-menu-toggle { width: 132px; @@ -56,7 +55,7 @@ } } -.filtered-search-container { +.filtered-search-wrapper { display: -webkit-flex; display: flex; @@ -83,7 +82,7 @@ .input-token:last-child { flex: 1; -webkit-flex: 1; - max-width: initial; + max-width: inherit; } } @@ -105,6 +104,34 @@ padding: 2px 7px; } + .value { + padding-right: 0; + } + + .remove-token { + display: inline-block; + padding-left: 4px; + padding-right: 8px; + + .fa-close { + color: $gl-text-color-secondary; + } + + &:hover .fa-close { + color: $gl-text-color; + } + + &.inverted { + .fa-close { + color: $gl-text-color-secondary-inverted; + } + + &:hover .fa-close { + color: $gl-text-color-inverted; + } + } + } + .name { background-color: $filter-name-resting-color; color: $filter-name-text-color; @@ -113,7 +140,7 @@ text-transform: capitalize; } - .value { + .value-container { background-color: $white-normal; color: $filter-value-text-color; border-radius: 0 2px 2px 0; @@ -125,7 +152,7 @@ background-color: $filter-name-selected-color; } - .value { + .value-container { background-color: $filter-value-selected-color; } } @@ -151,11 +178,13 @@ width: 100%; } -.filtered-search-input-container { +.filtered-search-box { + position: relative; + flex: 1; display: -webkit-flex; display: flex; - position: relative; width: 100%; + min-width: 0; border: 1px solid $border-color; background-color: $white-light; @@ -163,14 +192,6 @@ -webkit-flex: 1 1 auto; flex: 1 1 auto; margin-bottom: 10px; - - .dropdown-menu { - width: auto; - left: 0; - right: 0; - max-width: none; - min-width: 100%; - } } &:hover { @@ -229,6 +250,115 @@ } } +.filtered-search-box-input-container { + flex: 1; + position: relative; + // Fix PhantomJS not supporting `flex: 1;` properly. + // This is important because it can change the expected `e.target` when clicking things in tests. + // See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61 + // - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png + // - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png + width: 100%; + min-width: 0; +} + +.filtered-search-input-dropdown-menu { + max-width: 280px; + + @media (max-width: $screen-xs-min) { + width: auto; + left: 0; + right: 0; + max-width: none; + min-width: 100%; + } +} + +.filtered-search-history-dropdown-wrapper { + position: static; + display: flex; + flex-direction: column; +} + +.filtered-search-history-dropdown-toggle-button { + flex: 1; + width: auto; + border-radius: 0; + border: 0; + border-right: 1px solid $border-color; + color: $gl-text-color-secondary; + transition: color 0.1s linear; + + &:hover, + &:focus { + color: $gl-text-color; + border-color: $dropdown-input-focus-border; + outline: none; + + svg { + fill: $gl-text-color; + } + } + + svg { + height: 14px; + width: 14px; + fill: $gl-text-color-secondary; + vertical-align: middle; + } + + .dropdown-toggle-text { + display: inline-block; + color: inherit; + + .fa { + vertical-align: middle; + color: inherit; + } + } +} + +.filtered-search-history-dropdown { + width: 40%; + + @media (max-width: $screen-xs-min) { + left: 0; + right: 0; + max-width: none; + } +} + +.filtered-search-history-dropdown-content { + max-height: none; +} + +.filtered-search-history-dropdown-item, +.filtered-search-history-clear-button { + @include dropdown-link; + + overflow: hidden; + width: 100%; + margin: 0.5em 0; + + background-color: transparent; + border: 0; + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; +} + +.filtered-search-history-dropdown-token { + display: inline; + + &:not(:last-child) { + margin-right: 0.3em; + } + + & > .value { + font-weight: 600; + } +} + .filter-dropdown-container { display: -webkit-flex; display: flex; @@ -248,10 +378,8 @@ } @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - .issues-details-filters { - .dropdown-menu-toggle { - width: 100px; - } + .issue-bulk-update-dropdown-toggle { + width: 100px; } } @@ -343,10 +471,8 @@ } } -.filter-dropdown-item.dropdown-active { - .btn { - @extend %filter-dropdown-item-btn-hover; - } +.filter-dropdown-item.droplab-item-active .btn { + @extend %filter-dropdown-item-btn-hover; } .filter-dropdown-loading { diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss index c0de09f3968..dbdd5a4464b 100644 --- a/app/assets/stylesheets/framework/gfm.scss +++ b/app/assets/stylesheets/framework/gfm.scss @@ -2,7 +2,7 @@ * Styles that apply to all GFM related forms. */ +.gfm-commit, .gfm-commit_range { - font-family: $monospace_font; - font-size: 90%; + @extend .commit-sha; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index abb092623c0..ce8b27a1951 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -24,19 +24,23 @@ header { &.navbar-gitlab { padding: 0 16px; - z-index: 100; + z-index: 400; margin-bottom: 0; min-height: $header-height; background-color: $gray-light; border: none; border-bottom: 1px solid $border-color; + position: fixed; + top: 0; + left: 0; + right: 0; @media (max-width: $screen-xs-min) { padding: 0 16px; } &.with-horizontal-nav { - border-bottom: none; + border-color: transparent; } .container-fluid { @@ -110,6 +114,16 @@ header { } } + .navbar-border { + height: 1px; + position: absolute; + right: 0; + left: 0; + bottom: 0; + background-color: $border-color; + opacity: 0; + } + .global-dropdown { position: absolute; left: -10px; @@ -155,7 +169,7 @@ header { .header-logo { display: inline-block; - margin: 0 7px 0 2px; + margin: 0 12px 0 2px; position: relative; top: 10px; transition-duration: .3s; @@ -186,7 +200,7 @@ header { display: flex; align-items: flex-start; flex: 1 1 auto; - padding-top: (($header-height - 19) / 2); + padding-top: 14px; overflow: hidden; } @@ -329,8 +343,17 @@ header { .header-user { .dropdown-menu-nav { + width: auto; min-width: 140px; margin-top: -5px; + + .current-user { + padding: 5px 18px; + + .user-name { + display: block; + } + } } } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 87667f39ab8..ef864e8f6a9 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,4 +1,5 @@ -.ci-status-icon-success { +.ci-status-icon-success, +.ci-status-icon-passed { color: $green-500; svg { @@ -64,3 +65,7 @@ text-decoration: none; } } + +.user-avatar-link { + text-decoration: none; +} diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 20c7bc93c28..9e8acf4e73c 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -25,6 +25,10 @@ body { .content-wrapper { padding-bottom: 100px; + + &:not(.page-with-layout-nav) { + margin-top: $header-height; + } } .container { diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 15dc0aa6a52..d76053fe72a 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -152,6 +152,7 @@ ul.content-list { margin-top: 3px; margin-bottom: 4px; + &.has-tooltip, &:last-child { margin-right: 0; @@ -255,6 +256,7 @@ ul.controls { .avatar-inline { margin-left: 0; margin-right: 0; + margin-bottom: 0; } } } diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index a668a6c4c39..80691a234f8 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -120,6 +120,10 @@ // Ensure that image does not exceed viewport max-height: calc(100vh - 100px); } + + table { + @include markdown-table; + } } .toolbar-group { diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss new file mode 100644 index 00000000000..81cdf6b59e4 --- /dev/null +++ b/app/assets/stylesheets/framework/memory_graph.scss @@ -0,0 +1,22 @@ +.memory-graph-container { + svg { + background: $white-light; + cursor: pointer; + + &:hover { + box-shadow: 0 0 4px $gray-darkest inset; + } + } + + path { + fill: none; + stroke: $blue-500; + stroke-width: 2px; + } + + circle { + stroke: $blue-700; + fill: $blue-700; + stroke-width: 4px; + } +} diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index b3340d41333..3a98332e46c 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -13,6 +13,13 @@ } /* + * Mixin for markdown tables + */ +@mixin markdown-table { + width: auto; +} + +/* * Base mixin for lists in GitLab */ @mixin basic-list { diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index eb73f7cc794..678af978edd 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -112,7 +112,7 @@ } } - .issue_edited_ago, + .issue-edited-ago, .note_edited_ago { display: none; } diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 8cd49280e1c..7098203321d 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -16,6 +16,8 @@ body.modal-open { overflow: hidden; } -.modal .modal-dialog { - width: 860px; +@media (min-width: $screen-md-min) { + .modal-dialog { + width: 860px; + } } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 0e09638a8cc..28b2a7cfacd 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -24,10 +24,10 @@ } @mixin scrolling-links() { - white-space: nowrap; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; + display: flex; &::-webkit-scrollbar { display: none; @@ -35,6 +35,7 @@ } .nav-links { + display: flex; padding: 0; margin: 0; list-style: none; @@ -42,17 +43,16 @@ border-bottom: 1px solid $border-color; li { - display: inline-block; + display: flex; a { - display: inline-block; padding: $gl-btn-padding; padding-bottom: 11px; - margin-bottom: -1px; font-size: 14px; line-height: 28px; color: $gl-text-color-secondary; border-bottom: 2px solid transparent; + white-space: nowrap; &:hover, &:active, @@ -85,10 +85,10 @@ .container-fluid { background-color: $gray-normal; margin-bottom: 0; + display: flex; } li { - &.active a { border-bottom: none; color: $link-underline-blue; @@ -110,7 +110,7 @@ .top-area { @include clearfix; - border-bottom: 1px solid $white-normal; + border-bottom: 1px solid $border-color; .nav-text { padding-top: 16px; @@ -137,15 +137,19 @@ } .nav-links { - display: inline-block; margin-bottom: 0; border-bottom: none; + float: left; &.wide { width: 100%; display: block; } + &.scrolling-tabs { + float: left; + } + li a { padding: 16px 15px 11px; } @@ -287,6 +291,7 @@ border-bottom: 1px solid $border-color; transition: padding $sidebar-transition-duration; text-align: center; + margin-top: $header-height; .container-fluid { position: relative; @@ -332,6 +337,10 @@ border-bottom: none; height: 51px; + @media (min-width: $screen-sm-min) { + justify-content: center; + } + li { a { padding-top: 10px; @@ -343,6 +352,10 @@ .scrolling-tabs-container { position: relative; + .merge-request-tabs-container & { + overflow: hidden; + } + .nav-links { @include scrolling-links(); } @@ -424,14 +437,14 @@ top: ($header-height + 1) * 3; &.affix { - top: 0; + top: $header-height; } } } } -.activities { - .nav-block { +.nav-block { + &.activities { border-bottom: 1px solid $border-color; .nav-links { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 746c9c25620..018f61ca3a8 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -53,6 +53,7 @@ .right-sidebar-expanded { padding-right: 0; + z-index: 300; @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper { @@ -80,6 +81,6 @@ &.affix { position: fixed; - top: 0; + top: $header-height; } } diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index ff185cd8767..aa0c512a277 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -1,29 +1,8 @@ .timeline { @include basic-list; - margin: 0; padding: 0; - .timeline-entry { - padding: $gl-padding $gl-btn-padding 11px; - border-color: $white-normal; - color: $gl-text-color; - border-bottom: 1px solid $border-white-light; - - &:target { - background: $line-target-blue; - } - - .avatar { - margin-right: 15px; - } - - .controls { - padding-top: 10px; - float: right; - } - } - .note-text { p:last-child { margin-bottom: 0; @@ -43,20 +22,45 @@ } } +.timeline-entry { + padding: $gl-padding $gl-btn-padding 0; + border-color: $white-normal; + color: $gl-text-color; + border-bottom: 1px solid $border-white-light; + + .timeline-entry-inner { + position: relative; + } + + &:target, + &.target { + background: $line-target-blue; + } + + .avatar { + margin-right: 15px; + } + + .controls { + padding-top: 10px; + float: right; + } +} + @media (max-width: $screen-xs-max) { .timeline { &::before { background: none; } + } - .timeline-entry .timeline-entry-inner { - .timeline-icon { - display: none; - } + .timeline-entry .timeline-entry-inner { + .timeline-icon { + display: none; + } - .timeline-content { - margin-left: 0; - } + .timeline-content { + margin-left: 0; } } } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index c241816788b..0c3407f34f8 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -8,6 +8,13 @@ img { max-width: 100%; + margin: 0 0 8px; + } + + p a:not(.no-attachment-icon) img { + // Remove bottom padding because + // <p> already has $gl-padding bottom + margin-bottom: 0; } *:first-child:not(.katex-display) { @@ -47,44 +54,50 @@ h1 { font-size: 1.75em; font-weight: 600; - margin: 16px 0 10px; - padding: 0 0 0.3em; + margin: 24px 0 16px; + padding-bottom: 0.3em; border-bottom: 1px solid $white-dark; color: $gl-text-color; + + &:first-child { + margin-top: 0; + } } h2 { font-size: 1.5em; font-weight: 600; - margin: 16px 0 10px; + margin: 24px 0 16px; + padding-bottom: 0.3em; + border-bottom: 1px solid $white-dark; color: $gl-text-color; } h3 { - margin: 16px 0 10px; + margin: 24px 0 16px; font-size: 1.3em; } h4 { - margin: 16px 0 10px; + margin: 24px 0 16px; font-size: 1.2em; } h5 { - margin: 16px 0 10px; + margin: 24px 0 16px; font-size: 1em; } h6 { - margin: 16px 0 10px; + margin: 24px 0 16px; font-size: 0.95em; } blockquote { color: $gl-grayish-blue; font-size: inherit; - padding: 8px 21px; - margin: 12px 0; + padding: 8px 24px; + margin: 16px 0; border-left: 3px solid $white-dark; } @@ -95,19 +108,20 @@ blockquote p { color: $gl-grayish-blue !important; + margin: 0; font-size: inherit; line-height: 1.5; } p { color: $gl-text-color; - margin: 6px 0 0; + margin: 0 0 16px; } table { @extend .table; @extend .table-bordered; - margin: 12px 0; + margin: 16px 0; color: $gl-text-color; th { @@ -120,11 +134,20 @@ } pre { - margin: 12px 0; + margin-bottom: 16px; font-size: 13px; line-height: 1.6em; overflow-x: auto; border-radius: 2px; + + + &.plain-readme { + background: none; + border: none; + padding: 0; + margin: 0; + font-size: 14px; + } } p > code { @@ -134,7 +157,7 @@ ul, ol { padding: 0; - margin: 3px 0 !important; + margin: 0 0 16px !important; } ul:dir(rtl), @@ -155,13 +178,14 @@ } ul.task-list { - li.task-list-item { + > li.task-list-item { list-style-type: none; position: relative; + min-height: 22px; padding-left: 28px; margin-left: 0 !important; - input.task-list-item-checkbox { + > input.task-list-item-checkbox { position: absolute; left: 8px; top: 5px; @@ -264,19 +288,6 @@ h6 { /** CODE **/ pre { font-family: $monospace_font; - - &.plain-readme { - background: none; - border: none; - padding: 0; - margin: 0; - font-size: 14px; - } -} - -.monospace { - font-family: $monospace_font; - font-size: 90%; } code { @@ -290,6 +301,24 @@ a > code { color: $link-color; } +.monospace { + font-family: $monospace_font; +} + +.commit-sha, +.ref-name { + @extend .monospace; + font-size: 95%; +} + +.git-revision-dropdown-toggle { + @extend .monospace; +} + +.git-revision-dropdown .dropdown-content ul li a { + @extend .ref-name; +} + /** * Apply Markdown typography * @@ -337,3 +366,32 @@ h4 { .idiff.addition { background: $line-added-dark; } + + +/** + * form text input i.e. search bar, comments, forms, etc. + */ +input, +textarea { + &::-webkit-input-placeholder { + color: $placeholder-text-color; + } + + // support firefox 19+ vendor prefix + &::-moz-placeholder { + color: $placeholder-text-color; + opacity: 1; // FF defaults to 0.54 + } + + // scss-lint:disable PseudoElement + // support Edge vendor prefix + &::-ms-input-placeholder { + color: $placeholder-text-color; + } + + // scss-lint:disable PseudoElement + // support IE vendor prefix + &:-ms-input-placeholder { + color: $placeholder-text-color; + } +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 97794a47df8..17a4e8fd83e 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -26,6 +26,7 @@ $gray-dark: darken($gray-light, $darken-dark-factor); $gray-darker: #eee; $gray-darkest: #c4c4c4; +$green-25: #f6fcf8; $green-50: #e4f5eb; $green-100: #bae6cc; $green-200: #8dd5aa; @@ -37,6 +38,7 @@ $green-700: #12753a; $green-800: #0e5a2d; $green-900: #0a4020; +$blue-25: #f6fafd; $blue-50: #e4eff9; $blue-100: #bcd7f1; $blue-200: #8fbce8; @@ -48,6 +50,7 @@ $blue-700: #17599c; $blue-800: #134a81; $blue-900: #0f3b66; +$orange-25: #fffcf8; $orange-50: #fff2e1; $orange-100: #fedfb3; $orange-200: #feca81; @@ -59,6 +62,7 @@ $orange-700: #c26700; $orange-800: #a35100; $orange-900: #853b00; +$red-25: #fef7f6; $red-50: #fbe7e4; $red-100: #f4c4bc; $red-200: #ed9d90; @@ -97,6 +101,8 @@ $gl-font-size: 14px; $gl-text-color: rgba(0, 0, 0, .85); $gl-text-color-secondary: rgba(0, 0, 0, .55); $gl-text-color-disabled: rgba(0, 0, 0, .35); +$gl-text-color-inverted: rgba(255, 255, 255, 1.0); +$gl-text-color-secondary-inverted: rgba(255, 255, 255, .85); $gl-text-green: $green-600; $gl-text-red: $red-500; $gl-text-orange: $orange-600; @@ -105,8 +111,10 @@ $gl-link-hover-color: $blue-800; $gl-grayish-blue: #7f8fa4; $gl-gray: $gl-text-color; $gl-gray-dark: #313236; +$gl-gray-light: #5c5c5c; $gl-header-color: #4c4e54; $gl-header-nav-hover-color: #434343; +$placeholder-text-color: rgba(0, 0, 0, .42); /* * Lists @@ -147,7 +155,7 @@ $gl-sidebar-padding: 22px; /* * Misc */ -$row-hover: lighten($blue-50, 2%); +$row-hover: $blue-25; $row-hover-border: $blue-100; $progress-color: #c0392b; $header-height: 50px; @@ -155,7 +163,7 @@ $fixed-layout-width: 1280px; $limited-layout-width: 990px; $gl-avatar-size: 40px; $error-exclamation-point: $red-500; -$border-radius-default: 2px; +$border-radius-default: 3px; $settings-icon-size: 18px; $provider-btn-not-active-color: $blue-500; $link-underline-blue: $blue-500; @@ -223,18 +231,18 @@ $gl-btn-active-gradient: inset 0 2px 3px $gl-btn-active-background; /* * Commit Diff Colors */ -$added: $green-300; -$deleted: $red-300; -$line-added: $green-50; -$line-added-dark: $green-100; -$line-removed: $red-50; -$line-removed-dark: $red-100; -$line-number-old: lighten($red-100, 5%); -$line-number-new: lighten($green-100, 5%); -$line-number-select: lighten($orange-100, 5%); -$line-target-blue: $blue-50; -$line-select-yellow: $orange-50; -$line-select-yellow-dark: $orange-100; +$added: #63c363; +$deleted: #f77; +$line-added: #ecfdf0; +$line-added-dark: #c7f0d2; +$line-removed: #fbe9eb; +$line-removed-dark: #fac5cd; +$line-number-old: #f9d7dc; +$line-number-new: #ddfbe6; +$line-number-select: #fbf2da; +$line-target-blue: #f6faff; +$line-select-yellow: #fcf8e7; +$line-select-yellow-dark: #f0e2bd; $dark-diff-match-bg: rgba(255, 255, 255, 0.3); $dark-diff-match-color: rgba(255, 255, 255, 0.1); $file-mode-changed: #777; @@ -293,6 +301,8 @@ $badge-color: $gl-text-color-secondary; * Award emoji */ $award-emoji-menu-shadow: rgba(0,0,0,.175); +$award-emoji-positive-add-bg: #fed159; +$award-emoji-positive-add-lines: #bb9c13; /* * Search Box @@ -452,6 +462,11 @@ $label-remove-border: rgba(0, 0, 0, .1); $label-border-radius: 100px; /* +* Animation +*/ +$fade-in-duration: 200ms; + +/* * Lint */ $lint-incorrect-color: $red-500; @@ -550,3 +565,8 @@ $filter-name-text-color: rgba(0, 0, 0, 0.55); $filter-value-text-color: rgba(0, 0, 0, 0.85); $filter-name-selected-color: #ebebeb; $filter-value-selected-color: #d7d7d7; + +/* +Animation Functions +*/ +$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1); diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss index 32eb750180f..1c1392f8f67 100644 --- a/app/assets/stylesheets/framework/wells.scss +++ b/app/assets/stylesheets/framework/wells.scss @@ -12,10 +12,14 @@ } &.branch-info { - .monospace, + .commit-sha, .commit-info { margin-left: 4px; } + + .ref-name { + font-size: 12px; + } } } diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index 09951fe3d3e..6e3829d994f 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -185,6 +185,11 @@ $dark-il: #de935f; color: $dark-highlight-color !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $dark-na; + } + .hll { background-color: $dark-hll-bg; } .c { color: $dark-c; } /* Comment */ .err { color: $dark-err; } /* Error */ diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index b6a6d298adf..68eb0c7720f 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -185,6 +185,11 @@ $monokai-gi: #a6e22e; color: $black !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $monokai-k; + } + .hll { background-color: $monokai-hll; } .c { color: $monokai-c; } /* Comment */ .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */ diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index 4f7a50dcb4f..2cc968c32f2 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -188,6 +188,11 @@ $solarized-dark-il: #2aa198; background-color: $solarized-dark-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-dark-kd; + } + /* Solarized Dark For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index 6463fe96c1b..b61b85a2cd1 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -196,6 +196,11 @@ $solarized-light-il: #2aa198; background-color: $solarized-light-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $solarized-light-kd; + } + /* Solarized Light For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index ab2018bfbca..1daa10aef24 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -203,6 +203,11 @@ $white-gc-bg: #eaf2f5; background-color: $white-highlight !important; } + // Links to URLs, emails, or dependencies + .line a { + color: $white-nb; + } + .hll { background-color: $white-hll-bg; } .c { color: $white-c; font-style: italic; } .err { color: $white-err; background-color: $white-err-bg; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index b6168a293e0..68d7ab4bf84 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -46,7 +46,7 @@ } .issue-boards-page { - .page-with-sidebar { + .content-wrapper { padding-bottom: 0; } } @@ -72,7 +72,7 @@ @media (min-width: $screen-sm-min) { height: 475px; // Needed for PhantomJS - height: calc(100vh - 220px); + height: calc(100vh - 222px); min-height: 475px; transition: width .2s; @@ -197,7 +197,7 @@ .card { position: relative; - padding: 10px $gl-padding; + padding: 11px 10px 11px $gl-padding; background: $white-light; border-radius: $border-radius-default; box-shadow: 0 1px 2px $issue-boards-card-shadow; @@ -207,8 +207,13 @@ margin-bottom: 5px; } - &.is-active { + &.is-active, + &.is-active .card-assignee:hover a { background-color: $row-hover; + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $row-hover; + } } .label { @@ -217,41 +222,111 @@ } .confidential-icon { + position: relative; + top: 1px; margin-right: 5px; } } .card-title { - margin: 0; + margin: 0 30px 0 0; font-size: 1em; + line-height: inherit; a { - color: inherit; + color: $gl-text-color; word-wrap: break-word; + margin-right: 2px; } } -.card-footer { - margin-top: 5px; - line-height: 25px; - - .label { - margin-right: 5px; - font-size: (14px / $issue-boards-font-size) * 1em; - } +.card-header { + display: flex; + min-height: 20px; .card-assignee { - margin-right: 5px; + display: flex; + justify-content: flex-end; + position: absolute; + right: 15px; + height: 20px; + width: 20px; + + .avatar-counter { + display: none; + vertical-align: middle; + min-width: 20px; + line-height: 19px; + height: 20px; + padding-left: 2px; + padding-right: 2px; + border-radius: 2em; + } + + img { + vertical-align: top; + } + + a { + position: relative; + margin-left: -15px; + } + + a:nth-child(1) { + z-index: 3; + } + + a:nth-child(2) { + z-index: 2; + } + + a:nth-child(3) { + z-index: 1; + } + + a:nth-child(4) { + display: none; + } + + &:hover { + .avatar-counter { + display: inline-block; + } + + a { + position: static; + background-color: $white-light; + transition: background-color 0s; + margin-left: auto; + + &:nth-child(4) { + display: block; + } + + &:first-child:not(:only-child) { + box-shadow: -10px 0 10px 1px $white-light; + } + } + } } .avatar { - margin-left: 0; - margin-right: 0; + margin: 0; + } +} + +.card-footer { + margin: 0 0 5px; + + .label { + margin-top: 5px; + margin-right: 6px; } } .card-number { - margin-right: 5px; + font-size: 12px; + color: $gl-text-color-secondary; } .issue-boards-search { diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 969fc75c6eb..14a62b6cbf0 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -39,7 +39,7 @@ overflow-y: hidden; font-size: 12px; - .fa-refresh { + .fa-spinner { font-size: 24px; margin-left: 20px; } @@ -57,6 +57,48 @@ margin-right: 5px; } } + + .truncated-info { + text-align: center; + border-bottom: 1px solid; + background-color: $black; + height: 45px; + padding: 15px; + + &.affix { + top: 0; + } + + // with sidebar + &.affix.sidebar-expanded { + right: 312px; + left: 22px; + } + + // without sidebar + &.affix.sidebar-collapsed { + right: 20px; + left: 20px; + } + + &.affix-top { + position: absolute; + top: 0; + margin: 0 auto; + right: 5px; + left: 5px; + } + + .truncated-info-size { + margin: 0 5px; + } + + .raw-link { + color: inherit; + margin-left: 5px; + text-decoration: underline; + } + } } .scroll-controls { @@ -158,6 +200,7 @@ .header-content { flex: 1; + line-height: 1.8; a { color: $gl-text-color; @@ -186,8 +229,9 @@ white-space: pre; overflow-x: auto; font-size: 12px; + position: relative; - .fa-refresh { + .fa-spinner { font-size: 24px; } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 0dad91ba128..bb72f453d1b 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -135,7 +135,7 @@ .text-expander { display: inline-block; - background: $gray-light; + background: $white-light; color: $gl-text-color-secondary; padding: 0 5px; cursor: pointer; @@ -146,6 +146,11 @@ line-height: $gl-font-size; outline: none; + &.open { + background: $gray-light; + box-shadow: inset 0 0 2px rgba($black, 0.2); + } + &:hover { background-color: darken($gray-light, 10%); text-decoration: none; @@ -158,7 +163,6 @@ .avatar-cell { width: 46px; - padding-left: 10px; img { margin-right: 0; @@ -170,7 +174,6 @@ justify-content: space-between; align-items: flex-start; flex-grow: 1; - padding-left: 10px; .merge-request-branches & { flex-direction: column; @@ -203,11 +206,11 @@ margin-left: $gl-padding; } } -} -.commit-short-id { - font-family: $monospace_font; - font-weight: 600; + .commit-sha { + font-size: 14px; + font-weight: 600; + } } .commit, @@ -268,7 +271,7 @@ } } - .commit-id { + .commit-sha { color: $gl-link-color; } diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss new file mode 100644 index 00000000000..3266714396e --- /dev/null +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -0,0 +1,16 @@ +/** + * Container Registry + */ + +.container-image { + border-bottom: 1px solid $white-normal; +} + +.container-image-head { + padding: 0 16px; + line-height: 4em; +} + +.table.tags { + margin-bottom: 0; +} diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index ad3dbc7ac48..7bec4bd5f56 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -3,6 +3,25 @@ margin: 24px auto 0; position: relative; + .landing { + margin-top: 10px; + + .inner-content { + white-space: normal; + + h4, + p { + margin: 7px 0 0; + max-width: 480px; + padding: 0 $gl-padding; + + @media (max-width: $screen-sm-min) { + margin: 0 auto; + } + } + } + } + .col-headers { ul { margin: 0; @@ -93,11 +112,6 @@ top: $gl-padding-top; } - .bordered-box { - border: 1px solid $border-color; - border-radius: $border-radius-default; - } - .content-list { li { padding: 18px $gl-padding $gl-padding; @@ -139,42 +153,9 @@ } } - .landing { - margin-bottom: $gl-padding; - overflow: hidden; - - .dismiss-icon { - position: absolute; - right: $cycle-analytics-box-padding; - cursor: pointer; - color: $cycle-analytics-dismiss-icon-color; - } - - .svg-container { - text-align: center; - - svg { - width: 136px; - height: 136px; - } - } - - .inner-content { - @media (max-width: $screen-xs-max) { - padding: 0 28px; - text-align: center; - } - - h4 { - color: $gl-text-color; - font-size: 17px; - } - - p { - color: $cycle-analytics-box-text-color; - margin-bottom: $gl-padding; - } - } + .landing svg { + width: 136px; + height: 136px; } .fa-spinner { @@ -213,7 +194,7 @@ } .stage-nav-item { - display: block; + display: flex; line-height: 65px; border-top: 1px solid transparent; border-bottom: 1px solid transparent; @@ -247,14 +228,10 @@ } .stage-nav-item-cell { - float: left; - - &.stage-name { - width: 65%; - } - &.stage-median { - width: 35%; + margin-left: auto; + margin-right: $gl-padding; + min-width: calc(35% - #{$gl-padding}); } } @@ -410,7 +387,7 @@ padding: 0 3px 0 0; } - .branch-name { + .ref-name { color: $black; display: inline-block; max-width: 180px; @@ -421,7 +398,7 @@ vertical-align: top; } - .short-sha { + .commit-sha { color: $gl-link-color; line-height: 1.3; vertical-align: top; diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index 46fd19c93f9..f3de05aa5f6 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -29,11 +29,5 @@ .description { margin-top: 6px; - - p { - &:last-child { - margin-bottom: 0; - } - } } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 1aa1079903c..cfb1df4df84 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1,38 +1,6 @@ // Common .diff-file { - border: 1px solid $border-color; margin-bottom: $gl-padding; - border-radius: 3px; - - .commit-short-id { - font-family: $regular_font; - font-weight: 400; - } - - .diff-header { - position: relative; - background: $gray-light; - border-bottom: 1px solid $border-color; - padding: 10px 16px; - color: $gl-text-color; - z-index: 10; - border-radius: 3px 3px 0 0; - - .diff-title { - font-family: $monospace_font; - word-break: break-all; - display: block; - - .file-mode { - color: $file-mode-changed; - } - } - - .commit-short-id { - font-family: $monospace_font; - font-size: smaller; - } - } .file-title, .file-title-flex-parent { @@ -106,6 +74,10 @@ span { white-space: pre-wrap; } + + .line { + word-wrap: break-word; + } } } @@ -421,12 +393,6 @@ float: right; } -.diffs { - .content-block { - border-bottom: none; - } -} - .files-changed { border-bottom: none; } @@ -572,14 +538,7 @@ .diff-comments-more-count, .diff-notes-collapse { - background-color: $gray-darkest; - color: $white-light; - border: 1px solid $white-light; - border-radius: 1em; - font-family: $regular_font; - font-size: 9px; - line-height: 17px; - text-align: center; + @extend .avatar-counter; } .diff-notes-collapse { diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 4af267403d8..f6b8c8ee2bc 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -1,4 +1,13 @@ .file-editor { + .nav-links { + border-top: 1px solid $border-color; + border-right: 1px solid $border-color; + border-left: 1px solid $border-color; + border-bottom: none; + border-radius: 2px; + background: $gray-normal; + } + #editor { border: none; border-radius: 0; @@ -72,11 +81,7 @@ } .encoding-selector, - .soft-wrap-toggle, - .license-selector, - .gitignore-selector, - .gitlab-ci-yml-selector, - .dockerfile-selector { + .soft-wrap-toggle { display: inline-block; vertical-align: top; font-family: $regular_font; @@ -103,28 +108,9 @@ } } } - - .gitignore-selector, - .license-selector, - .gitlab-ci-yml-selector, - .dockerfile-selector { - .dropdown { - line-height: 21px; - } - - .dropdown-menu-toggle { - vertical-align: top; - width: 220px; - } - } - - .gitlab-ci-yml-selector { - .dropdown-menu-toggle { - width: 250px; - } - } } + @media(max-width: $screen-xs-max){ .file-editor { .file-title { @@ -149,10 +135,7 @@ margin: 3px 0; } - .encoding-selector, - .license-selector, - .gitignore-selector, - .gitlab-ci-yml-selector { + .encoding-selector { display: block; margin: 3px 0; @@ -163,3 +146,104 @@ } } } + +.blob-new-page-title, +.blob-edit-page-title { + margin: 19px 0 21px; + vertical-align: top; + display: inline-block; + + @media(max-width: $screen-sm-max) { + display: block; + margin: 19px 0 12px; + } +} + +.template-selectors-menu { + display: inline-block; + vertical-align: top; + margin: 14px 0 0 16px; + padding: 0 0 0 14px; + border-left: 1px solid $border-color; + + @media(max-width: $screen-sm-max) { + display: block; + width: 100%; + margin: 5px 0; + padding: 0; + border-left: none; + } +} + +.templates-selectors-label { + display: inline-block; + vertical-align: top; + margin-top: 6px; + line-height: 21px; + + @media(max-width: $screen-sm-max) { + display: block; + margin: 5px 0; + } +} + +.template-selector-dropdowns-wrap { + display: inline-block; + margin-left: 8px; + vertical-align: top; + margin: 5px 0 0 8px; + + @media(max-width: $screen-sm-max) { + display: block; + width: 100%; + margin: 0 0 16px; + } + + .license-selector, + .gitignore-selector, + .gitlab-ci-yml-selector, + .dockerfile-selector, + .template-type-selector { + display: inline-block; + vertical-align: top; + font-family: $regular_font; + margin-top: -5px; + + @media(max-width: $screen-sm-max) { + display: block; + width: 100%; + margin: 5px 0; + } + + .dropdown { + line-height: 21px; + } + + .dropdown-menu-toggle { + width: 250px; + vertical-align: top; + + @media(max-width: $screen-sm-max) { + display: block; + width: 100%; + margin: 5px 0; + } + } + + } +} + +.template-selectors-undo-menu { + display: inline-block; + margin: 7px 0 0 10px; + + @media(max-width: $screen-sm-max) { + display: block; + width: 100%; + margin: 20px 0; + } + + button { + margin: -4px 0 0 15px; + } +} diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 3d91e0b22d8..48d3b7b1d07 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -5,11 +5,6 @@ } } -.environments-list-loading { - width: 100%; - font-size: 34px; -} - .environments-folder-name { font-weight: normal; padding-top: 20px; @@ -73,10 +68,6 @@ margin: 0; } - .avatar-image-container { - text-decoration: none; - } - .icon-play { height: 13px; width: 12px; @@ -95,7 +86,7 @@ } .build-link, - .branch-name { + .ref-name { color: $gl-text-color; } @@ -140,7 +131,7 @@ } .branch-commit { - .commit-id { + .commit-sha { margin-right: 0; } } @@ -157,7 +148,18 @@ .prometheus-graph { text { - fill: $stat-graph-axis-fill; + fill: $gl-text-color; + stroke-width: 0; + } + + .label-axis-text, + .text-metric-usage { + fill: $black; + font-weight: 500; + } + + .legend-axis-text { + fill: $black; } } @@ -200,27 +202,42 @@ .rect-text-metric { fill: $white-light; stroke-width: 1; - stroke: $black; + stroke: $gray-darkest; } .rect-axis-text { fill: $white-light; } -.text-metric, -.text-median-metric, -.text-metric-usage, -.text-metric-date { - fill: $black; +.text-metric { + font-weight: 600; } -.text-metric-date { - font-weight: 200; +.selected-metric-line { + stroke: $gl-gray-dark; + stroke-width: 1; } -.selected-metric-line { +.deployment-line { stroke: $black; - stroke-width: 1; + stroke-width: 2; +} + +.deploy-info-text { + dominant-baseline: text-before-edge; +} + +.text-metric-bold { + font-weight: 600; +} + +.prometheus-state { + margin-top: 10px; + display: none; + + .state-button-section { + margin-top: 10px; + } } .environments-actions { diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 08398bb43a2..5b723f7c722 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -4,14 +4,18 @@ */ .event-item { font-size: $gl-font-size; - padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top); + padding: $gl-padding-top 0 $gl-padding-top 40px; border-bottom: 1px solid $white-normal; color: $list-text-color; + position: relative; &.event-inline { - .avatar { - position: relative; - top: -2px; + .system-note-image { + top: 20px; + } + + .user-avatar { + top: 14px; } .event-title, @@ -24,8 +28,31 @@ color: $gl-text-color; } - .avatar { - margin-left: -($gl-avatar-size + $gl-padding-top); + .system-note-image { + position: absolute; + left: 0; + top: 14px; + + svg { + width: 20px; + height: 20px; + fill: $gl-text-color-secondary; + } + + &.opened-icon, + &.created-icon { + svg { + fill: $green-300; + } + } + + &.closed-icon svg { + fill: $red-300; + } + + &.accepted-icon svg { + fill: $blue-300; + } } .event-title { @@ -108,8 +135,7 @@ li { &.commit { background: transparent; - padding: 3px; - padding-left: 0; + padding: 0; border: none; .commit-row-title { @@ -163,7 +189,7 @@ max-width: 100%; } - .avatar { + .system-note-image { display: none; } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 73a5889867a..72d73b89a2a 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -88,3 +88,26 @@ color: $gl-text-color-secondary; margin-top: 10px; } + +.explore-groups.landing { + margin-top: 10px; + + .inner-content { + padding: 0; + + p { + margin: 7px 0 0; + max-width: 480px; + padding: 0 $gl-padding; + + @media (max-width: $screen-sm-min) { + margin: 0 auto; + } + } + } + + svg { + width: 62px; + height: 50px; + } +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e84a05e3e9e..9a63f758ce1 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -6,7 +6,12 @@ } .limit-container-width { - .detail-page-header { + .detail-page-header, + .page-content-header, + .commit-box, + .info-well, + .commit-ci-menu, + .files-changed { @extend .fixed-width-container; } @@ -17,16 +22,6 @@ .merge-manually { @extend .fixed-width-container; } - - .merge-request-tabs-holder { - &.affix { - border-bottom: 1px solid $border-color; - - .nav-links { - border: 0; - } - } - } } .merge-request-details { @@ -36,8 +31,7 @@ } .diffs { - .mr-version-controls, - .files-changed { + .mr-version-controls { @extend .fixed-width-container; } } @@ -52,7 +46,7 @@ .title { padding: 0; - margin: 0; + margin-bottom: 16px; border-bottom: none; } @@ -90,10 +84,15 @@ } .right-sidebar { - a { + a, + .btn-link { color: inherit; } + .btn-link { + outline: none; + } + .issuable-header-text { margin-top: 7px; } @@ -195,7 +194,17 @@ right: 0; transition: width .3s; background: $gray-light; - padding: 10px 20px; + padding: 0 20px; + z-index: 200; + overflow: hidden; + + .issuable-sidebar { + width: calc(100% + 100px); + height: 100%; + overflow-y: scroll; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } &.right-sidebar-expanded { width: $gutter_width; @@ -209,8 +218,12 @@ } } - .bold { - font-weight: 600; + .issuable-sidebar-header { + padding-top: 10px; + } + + .assign-yourself .btn-link { + padding-left: 0; } .light { @@ -237,6 +250,10 @@ margin-left: 0; } + .assignee .user-list .avatar { + margin: 0; + } + .username { display: block; margin-top: 4px; @@ -258,11 +275,10 @@ } width: $gutter_collapsed_width; - padding-top: 0; + padding: 0; .block { width: $gutter_collapsed_width - 2px; - margin-left: -19px; padding: 15px 0 0; border-bottom: none; overflow: hidden; @@ -299,6 +315,10 @@ margin-top: 0; } + .sidebar-avatar-counter { + padding-top: 2px; + } + .todo-undone { color: $gl-link-color; } @@ -307,10 +327,15 @@ display: none; } - .avatar:hover { + .avatar:hover, + .avatar-counter:hover { border-color: $issuable-sidebar-color; } + .avatar-counter:hover { + color: $issuable-sidebar-color; + } + .btn-clipboard { border: none; color: $issuable-sidebar-color; @@ -320,6 +345,17 @@ color: $gl-text-color; } } + + &.multiple-users { + display: flex; + justify-content: center; + } + } + + .sidebar-avatar-counter { + width: 24px; + height: 24px; + border-radius: 12px; } .sidebar-collapsed-user { @@ -330,6 +366,37 @@ .issuable-header-btn { display: none; } + + .multiple-users { + height: 24px; + margin-bottom: 17px; + margin-top: 4px; + padding-bottom: 4px; + + .btn-link { + padding: 0; + border: 0; + + .avatar { + margin: 0; + } + } + + .btn-link:first-child { + position: absolute; + left: 10px; + z-index: 1; + } + + .btn-link:last-child { + position: absolute; + right: 10px; + + &:hover { + text-decoration: none; + } + } + } } a { @@ -360,6 +427,8 @@ } .detail-page-description { + padding: 16px 0 0; + small { color: $gray-darkest; } @@ -367,6 +436,8 @@ .edited-text { color: $gray-darkest; + display: block; + margin: 0 0 16px; .author_link { color: $gray-darkest; @@ -377,6 +448,12 @@ margin: -5px; } + +.user-list { + display: flex; + flex-wrap: wrap; +} + .participants-author { display: inline-block; padding: 5px; @@ -394,13 +471,39 @@ } } -.participants-more { +.user-item { + display: inline-block; + padding: 5px; + flex-basis: 20%; + + .user-link { + display: inline-block; + } +} + +.participants-more, +.user-list-more { margin-top: 5px; margin-left: 5px; - a { + a, + .btn-link { color: $gl-text-color-secondary; } + + .btn-link { + outline: none; + padding: 0; + } + + .btn-link:hover { + @extend a:hover; + text-decoration: none; + } + + .btn-link:focus { + text-decoration: none; + } } .issuable-form-padding-top { @@ -493,6 +596,19 @@ } } +.issuable-list li, +.issue-info-container .controls { + .avatar-counter { + display: inline-block; + vertical-align: middle; + min-width: 16px; + line-height: 14px; + height: 16px; + padding-left: 2px; + padding-right: 2px; + } +} + .time_tracker { padding-bottom: 0; border-bottom: 0; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index b2f45625a2a..bee9b13b375 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -18,6 +18,15 @@ } } +.issue-realtime-pre-pulse { + opacity: 0; +} + +.issue-realtime-trigger-pulse { + transition: opacity $fade-in-duration linear; + opacity: 1; +} + .check-all-holder { line-height: 36px; float: left; @@ -42,6 +51,7 @@ ul.related-merge-requests > li { display: -ms-flexbox; display: -webkit-flex; display: flex; + align-items: center; .merge-request-id { flex-shrink: 0; @@ -50,6 +60,14 @@ ul.related-merge-requests > li { .merge-request-info { margin-left: 5px; } + + .row_title { + vertical-align: bottom; + } + + gl-emoji { + font-size: 1em; + } } .merge-requests-title, @@ -101,11 +119,15 @@ ul.related-merge-requests > li { } } -.merge-request-ci-status { +.merge-request-ci-status, +.related-merge-requests { + .ci-status-link { + display: block; + margin-right: 5px; + } + svg { - margin-right: 4px; - position: relative; - top: 1px; + display: block; } } @@ -156,3 +178,86 @@ ul.related-merge-requests > li { .recaptcha { margin-bottom: 30px; } + +.new-branch-col { + padding-top: 10px; +} + +.create-mr-dropdown-wrap { + .btn-group:not(.hide) { + display: flex; + } + + .js-create-merge-request { + flex-grow: 1; + flex-shrink: 0; + } + + .dropdown-menu { + width: 300px; + opacity: 1; + visibility: visible; + transform: translateY(0); + display: none; + } + + .dropdown-toggle { + .fa-caret-down { + pointer-events: none; + margin-left: 0; + color: inherit; + margin-left: 0; + } + } + + li:not(.divider) { + padding: 6px; + cursor: pointer; + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + } + + &.droplab-item-selected { + .icon-container { + i { + visibility: visible; + } + } + } + + .icon-container { + float: left; + padding-left: 6px; + + i { + visibility: hidden; + } + } + + .description { + padding-left: 30px; + font-size: 13px; + + strong { + display: block; + font-weight: 600; + } + } + } +} + +@media (min-width: $screen-sm-min) { + .new-branch-col { + padding-top: 0; + text-align: right; + } + + .create-mr-dropdown-wrap { + .btn-group:not(.hide) { + display: inline-block; + } + } +} diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index e1ef0b029a5..c10588ac58e 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -116,7 +116,7 @@ } .manage-labels-list { - > li:not(.empty-message) { + > li:not(.empty-message):not(.is-not-draggable) { background-color: $white-light; cursor: move; cursor: -webkit-grab; diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index be7193bae04..8dbac76e30a 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -133,3 +133,55 @@ right: 160px; } } + +.flex-project-members-panel { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + @media (max-width: $screen-sm-min) { + display: block; + + .flex-project-title { + vertical-align: top; + display: inline-block; + max-width: 90%; + } + } + + .flex-project-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .badge { + height: 17px; + line-height: 16px; + margin-right: 5px; + padding-top: 1px; + padding-bottom: 1px; + } + + .flex-project-members-form { + flex-wrap: nowrap; + white-space: nowrap; + margin-left: auto; + } +} + +.panel { + .panel-heading { + .badge { + margin-top: 0; + } + + @media (max-width: $screen-sm-min) { + .badge { + margin-right: 0; + margin-left: 0; + } + } + } +}
\ No newline at end of file diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 566dcc64802..1ac9d5af21d 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -37,12 +37,6 @@ @include btn-red; } } - - .dropdown-toggle { - .fa { - color: inherit; - } - } } .accept-control { @@ -88,18 +82,13 @@ } } - .ci_widget { - border-bottom: 1px solid $well-inner-border; + .ci-widget { color: $gl-text-color; display: -webkit-flex; display: flex; -webkit-align-items: center; align-items: center; - - i, - svg { - margin-right: 8px; - } + padding: $gl-padding-top $gl-padding 0; svg { position: relative; @@ -115,16 +104,20 @@ flex-wrap: wrap; } - .ci-status-icon > .icon-link > svg { + .icon-link > .ci-status-icon > svg { width: 22px; height: 22px; + margin-right: 8px; + } + + .ci-error { + margin-right: $btn-side-margin; } } .mr-widget-body, - .ci_widget, .mr-widget-footer { - padding: 16px; + margin: 16px; } .mr-widget-pipeline-graph { @@ -132,18 +125,13 @@ .dropdown-menu { margin-top: 11px; + z-index: 200; } .ci-action-icon-wrapper { line-height: 16px; } - @media (min-width: $screen-sm-min) { - .stage-cell { - padding: 0 4px; - } - } - @media (max-width: $screen-xs-max) { order: 1; margin-top: $gl-padding-top; @@ -166,12 +154,78 @@ .normal { color: $gl-text-color; + font-size: 15px; + } + + .capitalize { + text-transform: capitalize; + } + + .label-branch { + @extend .ref-name; + + color: $gl-text-color; + font-weight: bold; + overflow: hidden; + margin: 0 3px; + word-break: break-all; + + &.label-truncated { + position: relative; + display: inline-block; + width: 250px; + margin-bottom: -3px; + white-space: nowrap; + text-overflow: clip; + line-height: 14px; + + &::after { + position: absolute; + content: '...'; + right: 0; + font-family: $regular_font; + background-color: $gray-light; + } + } } .js-deployment-link { display: inline-block; } + .mr-widget-help { + margin: $gl-padding; + color: $ci-skipped-color; + } + + .mr-info-list { + + &.mr-links { + margin-left: 28px; + } + + &.mr-memory-usage { + margin: 5px 0 10px 25px; + } + } + + .mr-widget-heading, + .mr-widget-body { + .btn-default.btn-xs { + margin-left: 5px; + } + } + + .mr-widget-body { + .btn { + font-size: 15px; + } + + .btn-group .btn { + padding: 5px 10px; + } + } + .mr-widget-body { h4 { font-weight: 600; @@ -182,6 +236,10 @@ &.has-conflicts .fa-exclamation-triangle { color: $gl-warning; } + + time { + font-weight: normal; + } } .btn-grouped { @@ -189,6 +247,86 @@ margin-right: 7px; } + label { + font-weight: normal; + } + + .spacing { + margin: 0 $gl-padding; + } + + .bold { + margin-left: 5px; + font-weight: bold; + font-size: 15px; + color: $gl-gray-light; + } + + .state-label { + font-size: 16px; + font-weight: bold; + padding-right: 10px; + } + + .danger { + color: $gl-danger; + } + + .mr-widget-help { + margin: $gl-padding 0; + } + + .with-button { + position: relative; + top: 6px; + margin-bottom: 24px; + } + + .spacing, + .bold { + vertical-align: middle; + } + + .dropdown-menu { + li a { + padding: 5px; + } + + .merge-opt-icon, + .merge-opt-title { + display: inline-block; + float: left; + } + + .merge-opt-icon svg { + height: 15px; + width: 15px; + } + + .merge-opt-title { + margin-left: 8px; + } + } + + .dropdown-toggle { + .fa { + color: inherit; + } + } + + .has-error-message + .has-custom-error { + margin-left: 0; + } + + .has-custom-error { + display: inline-block; + margin-left: 70px; + } + + .merge-error-text { + margin-left: 70px; + } + @media (max-width: $screen-xs-max) { h4 { font-size: 14px; @@ -220,6 +358,33 @@ margin: 0; } } + + .commit-message-editor { + label { + padding: 0; + } + } + + &.mr-state-locked .mr-info-list { + margin-top: 10px; + margin-left: 12px; + } + + &.empty-state { + .artwork { + margin-bottom: $gl-padding; + } + + .text { + span { + font-weight: bold; + } + + p { + margin-top: $gl-padding; + } + } + } } .mr-widget-footer { @@ -255,16 +420,6 @@ } } -.label-branch { - color: $gl-text-color; - font-family: $monospace_font; - font-weight: bold; - overflow: hidden; - font-size: 90%; - margin: 0 3px; - word-break: break-all; -} - .commits-empty { text-align: center; @@ -329,8 +484,6 @@ } #modal_merge_info .modal-dialog { - width: 600px; - .dark { margin-right: 40px; } @@ -345,61 +498,79 @@ } } -.remove-message-pipes { - ul { - margin: 10px 0 0 12px; - padding: 0; - list-style: none; - border-left: 2px solid $border-color; - display: inline-block; - } +.mr-info-list { + position: relative; + margin: 10px 0 $gl-padding 12px; - li { + p { + margin: 6px 0; position: relative; - margin: 0; - padding: 0; - display: block; + padding-left: 15px; + + &::before { + content: ''; + position: absolute; + border-top: 2px solid $border-color; + height: 1px; + top: 8px; + width: 8px; + left: 0; + } - span { - margin-left: 15px; - max-height: 20px; + &:last-child { + margin-bottom: 0; + + &::before { + top: 14px; + } } } - li::before { - content: ''; + .legend { + height: 100%; + width: 2px; + background: $border-color; position: absolute; - border-top: 2px solid $border-color; - height: 1px; - top: 8px; - width: 8px; + top: -5px; } +} - li:last-child { - &::before { - top: 18px; +.mr-info-list.mr-memory-usage { + .legend { + height: 65%; + top: 0; + + @media (max-width: $screen-xs-max) { + height: 20px; } + } - span { - display: block; - position: relative; - top: 5px; - margin-top: 5px; + p { + float: left; + padding-left: 20px; + + &::before { + top: 13px; } } + + .memory-graph-container { + float: left; + margin-left: 5px; + } } .mr-source-target { background-color: $gray-light; - line-height: 31px; - border-style: solid; - border-width: 1px; - border-color: $border-color; - border-top-right-radius: 3px; - border-top-left-radius: 3px; - border-bottom: none; - padding: 16px; - margin-bottom: -1px; + border-radius: 3px 3px 0 0; + border-bottom: 1px solid $border-color; + padding: 0 $gl-padding; + margin-bottom: 6px; + line-height: 44px; + + .dropdown-toggle .fa { + color: $gl-text-color; + } } .panel-new-merge-request { @@ -484,6 +655,10 @@ } } +.target-branch-select-dropdown-container { + position: relative; +} + .assign-to-me-link { padding-left: 12px; white-space: nowrap; @@ -513,7 +688,6 @@ .mr-version-controls { background: $gray-light; - border-bottom: 1px solid $border-color; color: $gl-text-color; .mr-version-menus-container { @@ -525,11 +699,12 @@ } .content-block { - border-top: 1px solid $border-color; padding: $gl-padding-top $gl-padding; } .comments-disabled-notif { + line-height: 28px; + .btn { margin-left: 5px; } @@ -551,12 +726,18 @@ } .merge-request-tabs-holder { + top: $header-height; + z-index: 100; background-color: $white-light; + border-bottom: 1px solid $border-color; + + @media(min-width: $screen-sm-min) { + position: sticky; + position: -webkit-sticky; + } &.affix { - top: 0; left: 0; - z-index: 10; transition: right .15s; @media (max-width: $screen-xs-max) { @@ -568,6 +749,16 @@ padding-right: $gl-padding; } } + + .nav-links { + border: 0; + } +} + +.merge-request-tabs { + display: flex; + margin-bottom: 0; + padding: 0; } .limit-container-width { @@ -578,6 +769,15 @@ } } +.merge-request-tabs-container { + display: flex; + justify-content: space-between; + + @media (max-width: $screen-xs-max) { + flex-direction: column-reverse; + } +} + .limit-container-width:not(.container-limited) { .merge-request-tabs-holder:not(.affix) { .merge-request-tabs-container { @@ -585,3 +785,22 @@ } } } + +.mr-memory-usage { + p.usage-info-loading, + p.usage-info-unavailable, + p.usage-info-failed { + margin-bottom: 5px; + } + + p.usage-info-loading .usage-info-load-spinner { + margin-right: 10px; + font-size: 16px; + } + + @media (max-width: $screen-md-min) { + .mr-info-list.mr-memory-usage .legend { + height: 80%; + } + } +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 927bf9805ce..9db26f99a75 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -28,7 +28,7 @@ .note-edit-form { .note-form-actions { position: relative; - margin-top: $gl-padding; + margin: $gl-padding 0; } .note-preview-holder { @@ -277,6 +277,7 @@ .toolbar-text { font-size: 14px; line-height: 16px; + margin-top: 2px; @media (min-width: $screen-md-min) { float: left; @@ -310,3 +311,137 @@ margin-bottom: 10px; } } + +.comment-type-dropdown { + .comment-btn { + width: auto; + } + + .dropdown-toggle { + float: right; + + .toggle-icon { + color: $white-light; + padding-right: 2px; + margin-top: 2px; + pointer-events: none; + } + } + + .dropdown-menu { + top: initial; + bottom: 40px; + width: 298px; + } + + .description { + display: inline-block; + white-space: normal; + margin-left: 8px; + padding-right: 33px; + } + + li { + padding-top: 6px; + + & > a { + margin: 0; + padding: 0; + color: inherit; + border-radius: 0; + text-overflow: inherit; + + &:hover, + &:focus { + background-color: inherit; + color: inherit; + } + } + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + } + + &.droplab-item-selected i { + visibility: visible; + } + + i { + visibility: hidden; + } + } + + i { + display: inline-block; + vertical-align: top; + padding-top: 2px; + } + + .divider { + margin: 0 8px; + padding: 0; + border-top: $gray-darkest; + } + + @media (max-width: $screen-xs-max) { + display: flex; + width: 100%; + margin-bottom: 10px; + + .comment-btn { + flex-grow: 1; + flex-shrink: 0; + width: auto; + } + + .dropdown-toggle { + flex-grow: 0; + flex-shrink: 1; + width: auto; + } + } +} + +.uploading-container { + float: right; + + @media (max-width: $screen-xs-max) { + float: left; + margin-top: 5px; + } +} + +.uploading-error-icon, +.uploading-error-message { + color: $gl-text-red; +} + +.uploading-error-message { + @media (max-width: $screen-xs-max) { + &::after { + content: "\a"; + white-space: pre; + } + } +} + +.uploading-progress { + margin-right: 5px; +} + +.attach-new-file, +.button-attach-file, +.retry-uploading-link { + color: $gl-link-color; + padding: 0; + background: none; + border: 0; + font-size: 14px; + line-height: 16px; +} + +.markdown-selector { + color: $gl-link-color; +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 57cf8e136e2..4b15fc2bd82 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -16,6 +16,15 @@ ul.notes { .timeline-icon { float: left; + + svg { + width: 16px; + height: 16px; + fill: $gray-darkest; + position: absolute; + left: 0; + top: 16px; + } } .timeline-content { @@ -33,11 +42,135 @@ ul.notes { white-space: nowrap; } + .discussion-body { + padding-top: 15px; + } + + .discussion { + overflow: hidden; + display: block; + position: relative; + } + + .note { + display: block; + position: relative; + border-bottom: 1px solid $white-normal; + + &.being-posted { + pointer-events: none; + opacity: 0.5; + + .dummy-avatar { + display: inline-block; + height: 40px; + width: 40px; + border-radius: 50%; + background-color: $kdb-border; + border: 1px solid darken($kdb-border, 25%); + } + + .note-headline-light, + .fa-spinner { + margin-left: 3px; + } + } + + &.note-discussion { + &.timeline-entry { + padding: 14px 10px; + } + + .system-note { + padding: 0; + } + } + + &.is-editing { + .note-header, + .note-text, + .edited-text { + display: none; + } + + .note-edit-form { + display: block; + + &.current-note-edit-form + .note-awards { + display: none; + } + } + } + + .note-body { + overflow-x: auto; + overflow-y: hidden; + + .note-text { + word-wrap: break-word; + @include md-typography; + // Reset ul style types since we're nested inside a ul already + @include bulleted-list; + ul.task-list { + ul:not(.task-list) { + padding-left: 1.3em; + } + } + + table { + @include markdown-table; + } + } + } + + .note-awards { + .js-awards-block { + margin-bottom: 16px; + } + } + + .note-header { + + @media (max-width: $screen-xs-min) { + .inline { + display: block; + } + } + } + + .note-emoji-button { + position: relative; + line-height: 1; + + .fa-spinner { + display: none; + } + + &.is-loading { + .fa-smile-o { + display: none; + } + + .fa-spinner { + display: inline-block; + } + } + } + } + .system-note { font-size: 14px; padding: 0; clear: both; + @media (min-width: $screen-sm-min) { + margin-left: 65px; + } + + .note-header { + padding-bottom: 0; + } + &.timeline-entry::after { clear: none; } @@ -66,6 +199,14 @@ ul.notes { .timeline-content { padding: 14px 10px; + + @media (min-width: $screen-sm-min) { + margin-left: 20px; + } + } + + .note-header { + padding-bottom: 0; } .note-body { @@ -97,11 +238,6 @@ ul.notes { ul { margin: 3px 0 3px 16px !important; - - .gfm-commit { - font-family: $monospace_font; - font-size: 12px; - } } p:first-child { @@ -130,116 +266,6 @@ ul.notes { } } } - - .timeline-icon { - display: none; - - .avatar { - visibility: hidden; - - .discussion-body & { - visibility: visible; - } - } - } - } - - .discussion-body { - padding-top: 15px; - } - - .discussion { - overflow: hidden; - display: block; - position: relative; - } - - .note { - display: block; - position: relative; - border-bottom: 1px solid $white-normal; - - &.note-discussion { - &.timeline-entry { - padding: 14px 10px; - } - - .system-note { - padding: 0; - } - } - - &.is-editting { - .note-header, - .note-text, - .edited-text { - display: none; - } - - .note-edit-form { - display: block; - - &.current-note-edit-form + .note-awards { - display: none; - } - } - } - - .note-body { - overflow-x: auto; - overflow-y: hidden; - - .note-text { - word-wrap: break-word; - @include md-typography; - // Reset ul style types since we're nested inside a ul already - @include bulleted-list; - ul.task-list { - ul:not(.task-list) { - padding-left: 1.3em; - } - } - } - } - - .note-awards { - .js-awards-block { - padding: 2px; - margin-top: 10px; - } - } - - .note-header { - padding-bottom: 3px; - padding-right: 20px; - - @media (min-width: $screen-sm-min) { - padding-right: 0; - } - - @media (max-width: $screen-xs-min) { - .inline { - display: block; - } - } - } - - .note-emoji-button { - .fa-spinner { - display: none; - } - - &.is-loading { - .fa-smile-o { - display: none; - } - - .fa-spinner { - display: inline-block; - } - } - } - } } @@ -253,10 +279,6 @@ ul.notes { } } - .diff-header > span { - margin-right: 10px; - } - .line_content { white-space: pre-wrap; } @@ -294,6 +316,18 @@ ul.notes { border-width: 1px; } + .discussion-notes { + &:not(:first-child) { + border-top: 1px solid $white-normal; + margin-top: 20px; + } + + &:not(:last-child) { + border-bottom: 1px solid $white-normal; + margin-bottom: 20px; + } + } + .notes { background-color: $white-light; } @@ -332,6 +366,20 @@ ul.notes { font-size: 14px; } +.note-header { + display: flex; + justify-content: space-between; + + @media (max-width: $screen-xs-max) { + flex-flow: row wrap; + } +} + +.note-header-info { + min-width: 0; + padding-bottom: 5px; +} + .note-headline-light { display: inline; @@ -351,21 +399,36 @@ ul.notes { } } +.note-headline-meta { + display: inline-block; + white-space: nowrap; + + .system-note-message { + white-space: normal; + } +} + /** * Actions for Discussions/Notes */ -.discussion-actions, -.note-actions { +.discussion-actions { float: right; margin-left: 10px; color: $gray-darkest; } .note-actions { - position: absolute; - right: 0; - top: 0; + flex-shrink: 0; + // For PhantomJS that does not support flex + float: right; + margin-left: 10px; + color: $gray-darkest; + + @media (max-width: $screen-xs-max) { + float: none; + margin-left: 0; + } .note-action-button { margin-left: 8px; @@ -398,13 +461,51 @@ ul.notes { font-size: 17px; } - &:hover { + svg { + height: 16px; + width: 16px; + fill: $gray-darkest; + vertical-align: text-top; + } + + .award-control-icon-positive, + .award-control-icon-super-positive { + position: absolute; + top: 0; + left: 0; + opacity: 0; + } + + &:hover, + &.is-active { .danger-highlight { color: $gl-text-red; } .link-highlight { color: $gl-link-color; + + svg { + fill: $gl-link-color; + } + } + + .award-control-icon-neutral { + opacity: 0; + } + + .award-control-icon-positive { + opacity: 1; + } + } + + &.is-active { + .award-control-icon-positive { + opacity: 0; + } + + .award-control-icon-super-positive { + opacity: 1; } } } @@ -508,6 +609,14 @@ ul.notes { } .line-resolve-all-container { + @media (min-width: $screen-sm-min) { + margin-right: 0; + padding-left: $gl-padding; + } + + > div { + white-space: nowrap; + } .btn-group { margin-left: -4px; @@ -537,7 +646,6 @@ ul.notes { fill: $gray-darkest; } } - } .line-resolve-all { @@ -561,7 +669,7 @@ ul.notes { .line-resolve-btn { position: relative; - top: 2px; + top: 0; padding: 0; background-color: transparent; border: none; @@ -572,7 +680,6 @@ ul.notes { } &:not(.is-disabled):hover, - &:not(.is-disabled):focus, &.is-active { color: $gl-text-green; @@ -583,8 +690,13 @@ ul.notes { svg { fill: $gray-darkest; - height: 15px; - width: 15px; + height: 16px; + width: 16px; + } + + .loading { + margin: 0; + height: auto; } } @@ -598,6 +710,10 @@ ul.notes { } } +.discussion-notes .flash-container { + margin-bottom: 0; +} + // Merge request notes in diffs .diff-file { // Diff is side by side diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss new file mode 100644 index 00000000000..ab417948931 --- /dev/null +++ b/app/assets/stylesheets/pages/pipeline_schedules.scss @@ -0,0 +1,76 @@ +.js-pipeline-schedule-form { + .dropdown-select, + .dropdown-menu-toggle { + width: 100%!important; + } + + .gl-field-error { + margin: 10px 0 0; + } +} + +.interval-pattern-form-group { + label { + margin-right: 10px; + font-size: 12px; + + &[for='custom'] { + margin-right: 0; + } + } + + .cron-interval-input-wrapper { + padding-left: 0; + } + + .cron-interval-input { + margin: 10px 10px 0 0; + } + + .cron-syntax-link-wrap { + margin-right: 10px; + font-size: 12px; + } +} + +.pipeline-schedule-table-row { + .branch-name-cell { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .next-run-cell { + color: $gl-text-color-secondary; + } + + a { + color: $text-color; + } +} + +.pipeline-schedules-user-callout { + .bordered-box.content-block { + border: 1px solid $border-color; + background-color: transparent; + padding: 16px; + } + + #dismiss-callout-btn { + color: $gl-text-color; + } +} + +.cron-preset-radio-input { + display: inline-block; + + @media (max-width: $screen-md-max) { + display: block; + margin: 0 0 5px 5px; + } + + input { + margin-right: 3px; + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a4fe652b52f..292584eba28 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -1,10 +1,4 @@ .pipelines { - .realtime-loading { - font-size: 40px; - text-align: center; - margin: 0 auto; - } - .stage { max-width: 90px; width: 90px; @@ -14,10 +8,6 @@ white-space: nowrap; } - .empty-state { - margin: 5% auto 0; - } - .table-holder { width: 100%; @@ -168,9 +158,13 @@ float: none; } + .api { + @extend .monospace; + } + .branch-commit { - .branch-name { + .ref-name { font-weight: bold; max-width: 120px; overflow: hidden; @@ -192,12 +186,11 @@ color: $gl-text-color; } - .commit-id { + .commit-sha { color: $gl-link-color; } .commit-title { - margin-top: 4px; max-width: 225px; overflow: hidden; white-space: nowrap; @@ -230,7 +223,7 @@ .duration, .finished-at { color: $gl-text-color-secondary; - margin: 4px 0; + margin: 0; white-space: nowrap; .fa { @@ -257,7 +250,7 @@ .stage-cell { font-size: 0; - padding: 10px 4px; + padding: 0 4px; > .stage-container > div > button > span > svg, > .stage-container > button > svg { @@ -273,6 +266,7 @@ .stage-container { display: inline-block; position: relative; + vertical-align: middle; height: 22px; margin: 3px 6px 3px 0; @@ -316,6 +310,32 @@ } } +.build-failures { + .build-state { + padding: 20px 2px; + + .build-name { + float: right; + font-weight: 500; + } + + .ci-status-icon-failed svg { + vertical-align: middle; + } + + .stage { + color: $gl-text-color-secondary; + font-weight: 500; + vertical-align: middle; + } + } + + .build-log { + border: none; + line-height: initial; + } +} + // Pipeline graph .pipeline-graph { width: 100%; @@ -357,9 +377,9 @@ content: ''; position: absolute; top: 48%; - left: -48px; + left: -44px; border-top: 2px solid $border-color; - width: 48px; + width: 44px; height: 1px; } } @@ -459,7 +479,7 @@ color: $gl-text-color-secondary; // Action Icons in big pipeline-graph nodes - > .ci-action-icon-container .ci-action-icon-wrapper { + .ci-action-icon-container .ci-action-icon-wrapper { height: 30px; width: 30px; background: $white-light; @@ -484,7 +504,7 @@ } } - > .ci-action-icon-container { + .ci-action-icon-container { position: absolute; right: 5px; top: 5px; @@ -514,7 +534,7 @@ } } - > .build-content { + .build-content { display: inline-block; padding: 8px 10px 9px; width: 100%; @@ -530,34 +550,6 @@ } - .arrow { - &::before, - &::after { - content: ''; - display: inline-block; - position: absolute; - width: 0; - height: 0; - border-color: transparent; - border-style: solid; - top: 18px; - } - - &::before { - left: -5px; - margin-top: -6px; - border-width: 7px 5px 7px 0; - border-right-color: $border-color; - } - - &::after { - left: -4px; - margin-top: -9px; - border-width: 10px 7px 10px 0; - border-right-color: $white-light; - } - } - // Connect first build in each stage with right horizontal line &:first-child { &::after { @@ -781,16 +773,11 @@ } .scrollable-menu { + padding: 0; max-height: 245px; overflow: auto; } - // Loading icon - .builds-dropdown-loading { - margin: 0 auto; - width: 20px; - } - // Action icon on the right a.ci-action-icon-wrapper { color: $action-icon-color; @@ -837,7 +824,8 @@ border-radius: 3px; // build name - .ci-build-text { + .ci-build-text, + .ci-status-text { font-weight: 200; overflow: hidden; white-space: nowrap; @@ -890,33 +878,64 @@ } /** + * Top arrow in the dropdown in the big pipeline graph + */ +.big-pipeline-graph-dropdown-menu { + + &::before, + &::after { + content: ''; + display: inline-block; + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + top: 18px; + } + + &::before { + left: -5px; + margin-top: -6px; + border-width: 7px 5px 7px 0; + border-right-color: $border-color; + } + + &::after { + left: -4px; + margin-top: -9px; + border-width: 10px 7px 10px 0; + border-right-color: $white-light; + } +} + +/** * Top arrow in the dropdown in the mini pipeline graph */ .mini-pipeline-graph-dropdown-menu { - .arrow-up { - &::before, - &::after { - content: ''; - display: inline-block; - position: absolute; - width: 0; - height: 0; - border-color: transparent; - border-style: solid; - top: -6px; - left: 2px; - border-width: 0 5px 6px; - } - &::before { - border-width: 0 5px 5px; - border-bottom-color: $border-color; - } + &::before, + &::after { + content: ''; + display: inline-block; + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + top: -6px; + left: 2px; + border-width: 0 5px 6px; + } - &::after { - margin-top: 1px; - border-bottom-color: $white-light; - } + &::before { + border-width: 0 5px 5px; + border-bottom-color: $border-color; + } + + &::after { + margin-top: 1px; + border-bottom-color: $white-light; } } diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 703c5fc8869..fe084eb9397 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -230,6 +230,14 @@ font-size: 0; } + .fade-right { + right: 0; + } + + .fade-left { + left: 0; + } + @media (max-width: $screen-xs-max) { .cover-block { padding-top: 20px; @@ -281,8 +289,12 @@ table.u2f-registrations { margin: 0 auto; .bordered-box { - border: 1px solid $border-color; + border: 1px solid $blue-300; border-radius: $border-radius-default; + background-color: $blue-25; + position: relative; + display: flex; + justify-content: center; } .landing { @@ -290,28 +302,59 @@ table.u2f-registrations { margin-bottom: $gl-padding; .close { - margin-right: 20px; - } + position: absolute; + right: 20px; + opacity: 1; + + .dismiss-icon { + float: right; + cursor: pointer; + color: $blue-300; + } + + &:hover { + background-color: transparent; + border: 0; - .dismiss-icon { - float: right; - cursor: pointer; - color: $cycle-analytics-dismiss-icon-color; + .dismiss-icon { + color: $blue-400; + } + } } .svg-container { - text-align: center; + margin-right: 30px; + display: inline-block; svg { - width: 136px; - height: 136px; + height: 110px; + vertical-align: top; } } + + .user-callout-copy { + display: inline-block; + vertical-align: top; + } } @media(max-width: $screen-xs-max) { - .inner-content { - padding-left: 30px; + text-align: center; + + .bordered-box { + display: block; + } + + .landing { + .svg-container, + .user-callout-copy { + margin: 0; + display: block; + + svg { + height: 75px; + } + } } } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index c2c2f371b87..f0bf3d4c267 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -459,20 +459,13 @@ a.deploy-project-label { flex-wrap: wrap; .btn { - margin: 0 10px 10px 0; padding: 8px; + margin-left: 10px; } > div { + margin-bottom: 10px; padding-left: 0; - - &:last-child { - margin-bottom: 0; - - .btn { - margin-right: 0; - } - } } } } @@ -603,6 +596,10 @@ pre.light-well { .avatar-container { align-self: flex-start; + + > a { + width: 100%; + } } .project-details { @@ -617,6 +614,7 @@ pre.light-well { .controls { margin-left: auto; + text-align: right; } .ci-status-link { @@ -641,59 +639,6 @@ pre.light-well { } } -.project-last-commit { - background-color: $gray-light; - border: 1px solid $border-color; - border-radius: $border-radius-base; - padding: 12px; - - @media (min-width: $screen-sm-min) { - margin-top: $gl-padding; - } - - .ci-status { - margin-right: $gl-padding; - } - - .commit-row-message { - color: $gl-text-color; - } - - .commit_short_id { - margin-right: 5px; - color: $gl-link-color; - font-weight: 600; - } - - .commit-author-link { - .commit-author-name { - font-weight: 600; - } - } -} - -.project-show-readme { - .row-content-block { - background-color: inherit; - border: none; - } - - .readme-holder { - padding: $gl-padding 0; - border-top: 0; - - .edit-project-readme { - z-index: 2; - position: relative; - } - - .wiki h1 { - border-bottom: none; - padding: 0; - } - } -} - .git-clone-holder { width: 380px; @@ -751,7 +696,8 @@ pre.light-well { text-align: left; } -.protected-branches-list { +.protected-branches-list, +.protected-tags-list { margin-bottom: 30px; a { @@ -783,6 +729,17 @@ pre.light-well { } } +.protected-tags-list { + .dropdown-menu-toggle { + width: 100%; + max-width: 300px; + } + + .flash-container { + padding: 0; + } +} + .custom-notifications-form { .is-loading { .custom-notification-event-loading { @@ -815,7 +772,8 @@ pre.light-well { } .compare-form-group { - .dropdown-menu { + .dropdown-menu, + .inline-input-group { width: 100%; @media (min-width: $screen-sm-min) { @@ -834,14 +792,6 @@ pre.light-well { width: auto; } } - - .inline-input-group { - width: 100%; - - @media (min-width: $screen-sm-min) { - width: 250px; - } - } } .clearable-input { @@ -924,27 +874,23 @@ pre.light-well { } .variable-key { - width: 300px; - max-width: 300px; + max-width: 120px; overflow: hidden; word-wrap: break-word; - - // override bootstrap - white-space: normal!important; - - @media (max-width: $screen-sm-max) { - width: 150px; - max-width: 150px; - } + white-space: nowrap; + text-overflow: ellipsis; } .variable-value { - @media(max-width: $screen-xs-max) { - width: 150px; - max-width: 150px; - overflow: hidden; - word-wrap: break-word; - } + max-width: 150px; + overflow: hidden; + word-wrap: break-word; + white-space: nowrap; + text-overflow: ellipsis; + } + + .variable-menu { + text-align: right; } } diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 543d2ece3df..b9818ffcf42 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -124,7 +124,13 @@ input[type="checkbox"]:hover { // Custom dropdown positioning .dropdown-menu { - top: 37px; + transition-property: opacity, transform; + transition-duration: 250ms, 250ms; + transition-delay: 0ms, 25ms; + transition-timing-function: $dropdown-animation-timing; + transform: translateY(0); + opacity: 0; + display: block; left: -5px; padding: 0; @@ -156,6 +162,13 @@ input[type="checkbox"]:hover { color: $layout-link-gray; } } + + .dropdown-menu { + transition-duration: 100ms, 75ms; + transition-delay: 75ms, 100ms; + transform: translateY(13px); + opacity: 1; + } } &.has-value { diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss index b97a29cd1a0..fe22d186af1 100644 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -6,6 +6,8 @@ } .trigger-actions { + white-space: nowrap; + .btn { margin-left: 10px; } diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index a39815319f3..de652a79369 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -54,8 +54,9 @@ background-color: $white-light; &:hover { - border-color: $white-dark; + border-color: $white-normal; background-color: $gray-light; + border-top: 1px solid transparent; .todo-avatar, .todo-item { diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index fc4da4c495f..ab63225147f 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -138,14 +138,13 @@ .blob-commit-info { list-style: none; - background: $gray-light; - padding: 16px 16px 16px 6px; - border: 1px solid $border-color; - border-bottom: none; margin: 0; + padding: 0; } -#modal-remove-blob > .modal-dialog { width: 850px; } +.blob-content-holder { + margin-top: $gl-padding; +} .blob-upload-dropzone-previews { text-align: center; @@ -162,7 +161,6 @@ .tree-controls { float: right; - margin-top: 11px; position: relative; z-index: 2; diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 9bc47bbe173..b64b89485f7 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -71,7 +71,6 @@ .nav-controls { width: auto; min-width: 50%; - white-space: nowrap; } } @@ -159,3 +158,9 @@ ul.wiki-pages-list.content-list { padding: 5px 0; } } + +.wiki { + table { + @include markdown-table; + } +} diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 6cc1cc8e263..136d0c79467 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -28,9 +28,6 @@ nav.navbar-collapse.collapse, .profiler-results, .tree-ref-holder, .tree-holder .breadcrumb, -.blob-commit-info, -.file-title, -.file-holder, .nav, .btn, ul.notes-form, @@ -43,6 +40,11 @@ ul.notes-form, display: none!important; } +pre { + page-break-before: avoid; + page-break-inside: auto; +} + .page-gutter { padding-top: 0; padding-left: 0; diff --git a/app/assets/stylesheets/test.scss b/app/assets/stylesheets/test.scss new file mode 100644 index 00000000000..7d9f3da79c5 --- /dev/null +++ b/app/assets/stylesheets/test.scss @@ -0,0 +1,17 @@ +* { + -o-transition: none !important; + -moz-transition: none !important; + -ms-transition: none !important; + -webkit-transition: none !important; + transition: none !important; + -o-transform: none !important; + -moz-transform: none !important; + -ms-transform: none !important; + -webkit-transform: none !important; + transform: none !important; + -webkit-animation: none !important; + -moz-animation: none !important; + -o-animation: none !important; + -ms-animation: none !important; + animation: none !important; +} diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 5055c318a5f..dc9a6df5f75 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -1,6 +1,7 @@ class Admin::AbuseReportsController < Admin::ApplicationController def index @abuse_reports = AbuseReport.order(id: :desc).page(params[:page]) + @abuse_reports.includes(:reporter, :user) end def destroy diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index cf795d977ce..a4648b33cfa 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -6,6 +6,6 @@ class Admin::ApplicationController < ApplicationController layout 'admin' def authenticate_admin! - render_404 unless current_user.is_admin? + render_404 unless current_user.admin? end end diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 0bfbe47eb4f..152d7baad49 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -17,6 +17,18 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end end + def usage_data + respond_to do |format| + format.html do + usage_data = Gitlab::UsageData.data + usage_data_json = params[:pretty] ? JSON.pretty_generate(usage_data) : usage_data.to_json + + render html: Gitlab::Highlight.highlight('payload.json', usage_data_json) + end + format.json { render json: Gitlab::UsageData.to_json } + end + end + def reset_runners_token @application_setting.reset_runners_registration_token! flash[:notice] = 'New runners registration token has been generated!' @@ -121,6 +133,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :signup_enabled, :sentry_dsn, :sentry_enabled, + :clientside_sentry_dsn, + :clientside_sentry_enabled, :send_user_confirmation_email, :shared_runners_enabled, :shared_runners_text, @@ -134,6 +148,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :unique_ips_limit_enabled, :version_check_enabled, :terminal_max_session_time, + :polling_interval_multiplier, + :usage_ping_enabled, disabled_oauth_sign_in_sources: [], import_sources: [], diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb new file mode 100644 index 00000000000..9b77c554908 --- /dev/null +++ b/app/controllers/admin/cohorts_controller.rb @@ -0,0 +1,11 @@ +class Admin::CohortsController < Admin::ApplicationController + def index + if current_application_settings.usage_ping_enabled + cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do + CohortsService.new.execute + end + + @cohorts = CohortsSerializer.new.represent(cohorts_results) + end + end +end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index cea3d088e94..5885b3543bb 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -28,7 +28,7 @@ class Admin::GroupsController < Admin::ApplicationController if @group.save @group.add_owner(current_user) - redirect_to [:admin, @group], notice: 'Group was successfully created.' + redirect_to [:admin, @group], notice: "Group '#{@group.name}' was successfully created." else render "new" end @@ -43,9 +43,13 @@ class Admin::GroupsController < Admin::ApplicationController end def members_update - @group.add_users(params[:user_ids].split(','), params[:access_level], current_user: current_user) + status = Members::CreateService.new(@group, current_user, params).execute - redirect_to [:admin, @group], notice: 'Users were successfully added.' + if status + redirect_to [:admin, @group], notice: 'Users were successfully added.' + else + redirect_to [:admin, @group], alert: 'No users specified.' + end end def destroy @@ -72,7 +76,9 @@ class Admin::GroupsController < Admin::ApplicationController :name, :path, :request_access_enabled, - :visibility_level + :visibility_level, + :require_two_factor_authentication, + :two_factor_grace_period ] end end diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index cbfc4581411..ccfe553c89e 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -1,4 +1,6 @@ class Admin::HooksController < Admin::ApplicationController + before_action :hook, only: :edit + def index @hooks = SystemHook.all @hook = SystemHook.new @@ -15,15 +17,25 @@ class Admin::HooksController < Admin::ApplicationController end end + def edit + end + + def update + if hook.update_attributes(hook_params) + flash[:notice] = 'System hook was successfully updated.' + redirect_to admin_hooks_path + else + render 'edit' + end + end + def destroy - @hook = SystemHook.find(params[:id]) - @hook.destroy + hook.destroy redirect_to admin_hooks_path end def test - @hook = SystemHook.find(params[:hook_id]) data = { event_name: "project_create", name: "Ruby", @@ -32,16 +44,23 @@ class Admin::HooksController < Admin::ApplicationController owner_name: "Someone", owner_email: "example@gitlabhq.com" } - @hook.execute(data, 'system_hooks') + hook.execute(data, 'system_hooks') redirect_back_or_default end + private + + def hook + @hook ||= SystemHook.find(params[:id]) + end + def hook_params params.require(:hook).permit( :enable_ssl_verification, :push_events, :tag_push_events, + :repository_update_events, :token, :url ) diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb index 9433da02f64..8e7adc06584 100644 --- a/app/controllers/admin/impersonations_controller.rb +++ b/app/controllers/admin/impersonations_controller.rb @@ -21,6 +21,6 @@ class Admin::ImpersonationsController < Admin::ApplicationController end def authenticate_impersonator! - render_404 unless impersonator && impersonator.is_admin? && !impersonator.blocked? + render_404 unless impersonator && impersonator.admin? && !impersonator.blocked? end end diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index daecfc832bf..a1975c0e341 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -3,6 +3,7 @@ class Admin::ProjectsController < Admin::ApplicationController before_action :group, only: [:show, :transfer] def index + params[:sort] ||= 'latest_activity_desc' @projects = Project.with_statistics @projects = @projects.in_namespace(params[:namespace_id]) if params[:namespace_id].present? @projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present? diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index 37a1a23178e..4c3d336b3af 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -16,6 +16,8 @@ class Admin::ServicesController < Admin::ApplicationController def update if service.update_attributes(service_params[:service]) + PropagateServiceTemplateWorker.perform_async(service.id) if service.active? + redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' else diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 2abfa22712d..1d66955bb71 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -7,7 +7,7 @@ class Admin::SpamLogsController < Admin::ApplicationController spam_log = SpamLog.find(params[:id]) if params[:remove_user] - spam_log.remove_user + spam_log.remove_user(deleted_by: current_user) redirect_to admin_spam_logs_path, notice: "User #{spam_log.user.username} was successfully removed." else spam_log.destroy diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6a6e335d314..8ce9150e4a9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base include PageLayoutHelper include SentryHelper include WorkhorseHelper + include EnforcesTwoFactorAuthentication before_action :authenticate_user_from_private_token! before_action :authenticate_user! before_action :validate_user_service_ticket! before_action :check_password_expiration - before_action :check_2fa_requirement before_action :ldap_security_check before_action :sentry_context before_action :default_headers @@ -21,6 +21,8 @@ class ApplicationController < ActionController::Base before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? + around_action :set_locale + protect_from_forgery with: :exception helper_method :can?, :current_application_settings @@ -56,7 +58,7 @@ class ApplicationController < ActionController::Base if current_user not_found else - redirect_to new_user_session_path + authenticate_user! end end @@ -98,7 +100,10 @@ class ApplicationController < ActionController::Base end def access_denied! - render "errors/access_denied", layout: "errors", status: 404 + respond_to do |format| + format.json { head :not_found } + format.any { render "errors/access_denied", layout: "errors", status: 404 } + end end def git_not_found! @@ -118,6 +123,10 @@ class ApplicationController < ActionController::Base end end + def respond_422 + head :unprocessable_entity + end + def no_cache_headers response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate" response.headers["Pragma"] = "no-cache" @@ -151,12 +160,6 @@ class ApplicationController < ActionController::Base end end - def check_2fa_requirement - if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor? - redirect_to profile_two_factor_auth_path - end - end - def ldap_security_check if current_user && current_user.requires_ldap_check? return unless current_user.try_obtain_ldap_lease @@ -265,27 +268,18 @@ class ApplicationController < ActionController::Base current_application_settings.import_sources.include?('gitlab_project') end - def two_factor_authentication_required? - current_application_settings.require_two_factor_authentication - end - - def two_factor_grace_period - current_application_settings.two_factor_grace_period - end - - def two_factor_grace_period_expired? - date = current_user.otp_grace_period_started_at - date && (date + two_factor_grace_period.hours) < Time.current - end - - def skip_two_factor? - session[:skip_tfa] && session[:skip_tfa] > Time.current - end - # U2F (universal 2nd factor) devices need a unique identifier for the application # to perform authentication. # https://developers.yubico.com/U2F/App_ID.html def u2f_app_id request.base_url end + + def set_locale + Gitlab::I18n.set_locale(current_user) + + yield + ensure + Gitlab::I18n.reset_locale + end end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index b79ca034c5b..e2f5aa8508e 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -41,7 +41,7 @@ class AutocompleteController < ApplicationController no_project = { id: 0, - name_with_namespace: 'No project', + name_with_namespace: 'No project' } projects.unshift(no_project) unless params[:offset_id].present? diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb index 0a995c45bdf..eb3a623acdd 100644 --- a/app/controllers/concerns/continue_params.rb +++ b/app/controllers/concerns/continue_params.rb @@ -7,6 +7,7 @@ module ContinueParams continue_params = continue_params.permit(:to, :notice, :notice_now) return unless continue_params[:to] && continue_params[:to].start_with?('/') + return if continue_params[:to].start_with?('//') continue_params end diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 9ac8197e45a..183eb00ef67 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -1,17 +1,29 @@ module CreatesCommit extend ActiveSupport::Concern + def set_start_branch_to_branch_name + branch_exists = @repository.find_branch(@branch_name) + @start_branch = @branch_name if branch_exists + end + def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil) - set_commit_variables + if can?(current_user, :push_code, @project) + @project_to_commit_into = @project + @branch_name ||= @ref + else + @project_to_commit_into = current_user.fork_of(@project) + @branch_name ||= @project_to_commit_into.repository.next_branch('patch') + end + + @start_branch ||= @ref || @branch_name commit_params = @commit_params.merge( - start_project: @mr_target_project, - start_branch: @mr_target_branch, - target_branch: @mr_source_branch + start_project: @project, + start_branch: @start_branch, + branch_name: @branch_name ) - result = service.new( - @mr_source_project, current_user, commit_params).execute + result = service.new(@project_to_commit_into, current_user, commit_params).execute if result[:status] == :success update_flash_notice(success_notice) @@ -72,30 +84,30 @@ module CreatesCommit def new_merge_request_path new_namespace_project_merge_request_path( - @mr_source_project.namespace, - @mr_source_project, + @project_to_commit_into.namespace, + @project_to_commit_into, merge_request: { - source_project_id: @mr_source_project.id, - target_project_id: @mr_target_project.id, - source_branch: @mr_source_branch, - target_branch: @mr_target_branch + source_project_id: @project_to_commit_into.id, + target_project_id: @project.id, + source_branch: @branch_name, + target_branch: @start_branch } ) end def existing_merge_request_path - namespace_project_merge_request_path(@mr_target_project.namespace, @mr_target_project, @merge_request) + namespace_project_merge_request_path(@project.namespace, @project, @merge_request) end def merge_request_exists? return @merge_request if defined?(@merge_request) - @merge_request = MergeRequestsFinder.new(current_user, project_id: @mr_target_project.id).execute.opened. - find_by(source_branch: @mr_source_branch, target_branch: @mr_target_branch, source_project_id: @mr_source_project) + @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened. + find_by(source_project_id: @project_to_commit_into, source_branch: @branch_name, target_branch: @start_branch) end def different_project? - @mr_source_project != @mr_target_project + @project_to_commit_into != @project end def create_merge_request? @@ -103,22 +115,6 @@ module CreatesCommit # as the target branch in the same project, # we don't want to create a merge request. params[:create_merge_request].present? && - (different_project? || @mr_target_branch != @mr_source_branch) - end - - def set_commit_variables - if can?(current_user, :push_code, @project) - @mr_source_project = @project - @target_branch ||= @ref - else - @mr_source_project = current_user.fork_of(@project) - @target_branch ||= @mr_source_project.repository.next_branch('patch') - end - - # Merge request to this project - @mr_target_project = @project - @mr_target_branch ||= @ref || @target_branch - - @mr_source_branch = @target_branch + (different_project? || @start_branch != @branch_name) end end diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb new file mode 100644 index 00000000000..688e8bd4a37 --- /dev/null +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -0,0 +1,58 @@ +# == EnforcesTwoFactorAuthentication +# +# Controller concern to enforce two-factor authentication requirements +# +# Upon inclusion, adds `check_two_factor_requirement` as a before_action, +# and makes `two_factor_grace_period_expired?` and `two_factor_skippable?` +# available as view helpers. +module EnforcesTwoFactorAuthentication + extend ActiveSupport::Concern + + included do + before_action :check_two_factor_requirement + helper_method :two_factor_grace_period_expired?, :two_factor_skippable? + end + + def check_two_factor_requirement + if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor? + redirect_to profile_two_factor_auth_path + end + end + + def two_factor_authentication_required? + current_application_settings.require_two_factor_authentication? || + current_user.try(:require_two_factor_authentication_from_group?) + end + + def two_factor_authentication_reason(global: -> {}, group: -> {}) + if two_factor_authentication_required? + if current_application_settings.require_two_factor_authentication? + global.call + else + groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc) + group.call(groups) + end + end + end + + def two_factor_grace_period + periods = [current_application_settings.two_factor_grace_period] + periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?) + periods.min + end + + def two_factor_grace_period_expired? + date = current_user.otp_grace_period_started_at + date && (date + two_factor_grace_period.hours) < Time.current + end + + def two_factor_skippable? + two_factor_authentication_required? && + !current_user.two_factor_enabled? && + !two_factor_grace_period_expired? + end + + def skip_two_factor? + session[:skip_two_factor] && session[:skip_two_factor] > Time.current + end +end diff --git a/app/controllers/concerns/filter_projects.rb b/app/controllers/concerns/filter_projects.rb deleted file mode 100644 index 6014112256a..00000000000 --- a/app/controllers/concerns/filter_projects.rb +++ /dev/null @@ -1,17 +0,0 @@ -# == FilterProjects -# -# Controller concern to handle projects filtering -# * by name -# * by archived state -# -module FilterProjects - extend ActiveSupport::Concern - - def filter_projects(projects) - projects = projects.search(params[:name]) if params[:name].present? - projects = projects.non_archived if params[:archived].blank? - projects = projects.personal(current_user) if params[:personal].present? && current_user - - projects - end -end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 3ccf2a9ce33..4cf645d6341 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -60,7 +60,7 @@ module IssuableActions end def bulk_update_params - params.require(:update).permit( + permitted_keys = [ :issuable_ids, :assignee_id, :milestone_id, @@ -69,7 +69,15 @@ module IssuableActions label_ids: [], add_label_ids: [], remove_label_ids: [] - ) + ] + + if resource_name == 'issue' + permitted_keys << { assignee_ids: [] } + else + permitted_keys.unshift(:assignee_id) + end + + params.require(:update).permit(permitted_keys) end def resource_name diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 85ae4985e58..650ec1e326a 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -15,6 +15,9 @@ module IssuableCollections # a new order into the collection. # We cannot use reorder to not mess up the paginated collection. issuable_ids = issuable_collection.map(&:id) + + return {} if issuable_ids.empty? + issuable_note_count = Note.count_for_collection(issuable_ids, @collection_type) issuable_votes_count = AwardEmoji.votes_for_collection(issuable_ids, @collection_type) issuable_merge_requests_count = @@ -40,11 +43,11 @@ module IssuableCollections end def issues_collection - issues_finder.execute.preload(:project, :author, :assignee, :labels, :milestone, project: :namespace) + issues_finder.execute.preload(:project, :author, :assignees, :labels, :milestone, project: :namespace) end def merge_requests_collection - merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, target_project: :namespace) + merge_requests_finder.execute.preload(:source_project, :target_project, :author, :assignee, :labels, :milestone, :merge_request_diff, :head_pipeline, target_project: :namespace) end def issues_finder diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index ed22b1e5470..ae91e02488a 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -23,7 +23,7 @@ module LfsRequest render( json: { message: 'Git LFS is not enabled on this GitLab server, contact your admin.', - documentation_url: help_url, + documentation_url: help_url }, status: 501 ) @@ -48,7 +48,7 @@ module LfsRequest render( json: { message: 'Access forbidden. Check your access level.', - documentation_url: help_url, + documentation_url: help_url }, content_type: "application/vnd.git-lfs+json", status: 403 @@ -59,7 +59,7 @@ module LfsRequest render( json: { message: 'Not found.', - documentation_url: help_url, + documentation_url: help_url }, content_type: "application/vnd.git-lfs+json", status: 404 diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index c13333641d3..b1bacc8ffe5 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -1,6 +1,32 @@ module MembershipActions extend ActiveSupport::Concern + def create + status = Members::CreateService.new(membershipable, current_user, params).execute + + redirect_url = members_page_url + + if status + redirect_to redirect_url, notice: 'Users were successfully added.' + else + redirect_to redirect_url, alert: 'No users specified.' + end + end + + def destroy + Members::DestroyService.new(membershipable, current_user, params). + execute(:all) + + respond_to do |format| + format.html do + message = "User was successfully removed from #{source_type}." + redirect_to members_page_url, notice: message + end + + format.js { head :ok } + end + end + def request_access membershipable.request_access(current_user) @@ -11,20 +37,20 @@ module MembershipActions def approve_access_request Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute - redirect_to polymorphic_url([membershipable, :members]) + redirect_to members_page_url end def leave member = Members::DestroyService.new(membershipable, current_user, user_id: current_user.id). execute(:all) - source_type = membershipable.class.to_s.humanize(capitalize: false) notice = if member.request? "Your access request to the #{source_type} has been withdrawn." else "You left the \"#{membershipable.human_name}\" #{source_type}." end + redirect_path = member.request? ? member.source : [:dashboard, membershipable.class.to_s.tableize] redirect_to redirect_path, notice: notice @@ -35,4 +61,16 @@ module MembershipActions def membershipable raise NotImplementedError end + + def members_page_url + if membershipable.is_a?(Project) + project_settings_members_path(membershipable) + else + polymorphic_url([membershipable, :members]) + end + end + + def source_type + @source_type ||= membershipable.class.to_s.humanize(capitalize: false) + end end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb new file mode 100644 index 00000000000..3e2a0fe4f8b --- /dev/null +++ b/app/controllers/concerns/milestone_actions.rb @@ -0,0 +1,53 @@ +module MilestoneActions + extend ActiveSupport::Concern + + def merge_requests + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_merge_requests_tab", { + merge_requests: @milestone.merge_requests, + show_project_name: true + }) + end + end + end + + def participants + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_participants_tab", { + users: @milestone.participants + }) + end + end + end + + def labels + respond_to do |format| + format.html { redirect_to milestone_redirect_path } + format.json do + render json: tabs_json("shared/milestones/_labels_tab", { + labels: @milestone.labels + }) + end + end + end + + private + + def tabs_json(partial, data = {}) + { + html: view_to_html_string(partial, data) + } + end + + def milestone_redirect_path + if @project + namespace_project_milestone_path(@project.namespace, @project, @milestone) + else + group_milestone_path(@group, @milestone.safe_title, title: @milestone.title) + end + end +end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb new file mode 100644 index 00000000000..a57d9e6e6c0 --- /dev/null +++ b/app/controllers/concerns/notes_actions.rb @@ -0,0 +1,180 @@ +module NotesActions + include RendersNotes + extend ActiveSupport::Concern + + included do + before_action :authorize_admin_note!, only: [:update, :destroy] + end + + def index + current_fetched_at = Time.now.to_i + + notes_json = { notes: [], last_fetched_at: current_fetched_at } + + @notes = notes_finder.execute.inc_relations_for_view + @notes = prepare_notes_for_rendering(@notes) + + @notes.each do |note| + next if note.cross_reference_not_visible_for?(current_user) + + notes_json[:notes] << note_json(note) + end + + render json: notes_json + end + + def create + create_params = note_params.merge( + merge_request_diff_head_sha: params[:merge_request_diff_head_sha], + in_reply_to_discussion_id: params[:in_reply_to_discussion_id] + ) + @note = Notes::CreateService.new(project, current_user, create_params).execute + + if @note.is_a?(Note) + Banzai::NoteRenderer.render([@note], @project, current_user) + end + + respond_to do |format| + format.json { render json: note_json(@note) } + format.html { redirect_back_or_default } + end + end + + def update + @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) + + if @note.is_a?(Note) + Banzai::NoteRenderer.render([@note], @project, current_user) + end + + respond_to do |format| + format.json { render json: note_json(@note) } + format.html { redirect_back_or_default } + end + end + + def destroy + if note.editable? + Notes::DestroyService.new(project, current_user).execute(note) + end + + respond_to do |format| + format.js { head :ok } + end + end + + private + + def note_html(note) + render_to_string( + "shared/notes/_note", + layout: false, + formats: [:html], + locals: { note: note } + ) + end + + def note_json(note) + attrs = { + commands_changes: note.commands_changes + } + + if note.persisted? + attrs.merge!( + valid: true, + id: note.id, + discussion_id: note.discussion_id(noteable), + html: note_html(note), + note: note.note + ) + + discussion = note.to_discussion(noteable) + unless discussion.individual_note? + attrs.merge!( + discussion_resolvable: discussion.resolvable?, + + diff_discussion_html: diff_discussion_html(discussion), + discussion_html: discussion_html(discussion) + ) + end + else + attrs.merge!( + valid: false, + errors: note.errors + ) + end + + attrs + end + + def diff_discussion_html(discussion) + return unless discussion.diff_discussion? + + if params[:view] == 'parallel' + template = "discussions/_parallel_diff_discussion" + locals = + if params[:line_type] == 'old' + { discussions_left: [discussion], discussions_right: nil } + else + { discussions_left: nil, discussions_right: [discussion] } + end + else + template = "discussions/_diff_discussion" + locals = { discussions: [discussion] } + end + + render_to_string( + template, + layout: false, + formats: [:html], + locals: locals + ) + end + + def discussion_html(discussion) + return if discussion.individual_note? + + render_to_string( + "discussions/_discussion", + layout: false, + formats: [:html], + locals: { discussion: discussion } + ) + end + + def authorize_admin_note! + return access_denied! unless can?(current_user, :admin_note, note) + end + + def note_params + params.require(:note).permit( + :project_id, + :noteable_type, + :noteable_id, + :commit_id, + :noteable, + :type, + + :note, + :attachment, + + # LegacyDiffNote + :line_code, + + # DiffNote + :position + ) + end + + def noteable + @noteable ||= notes_finder.target + end + + def last_fetched_at + request.headers['X-Last-Fetched-At'] + end + + def notes_finder + @notes_finder ||= NotesFinder.new(project, current_user, finder_params) + end +end diff --git a/app/controllers/concerns/params_backward_compatibility.rb b/app/controllers/concerns/params_backward_compatibility.rb new file mode 100644 index 00000000000..b0e3d9c7b34 --- /dev/null +++ b/app/controllers/concerns/params_backward_compatibility.rb @@ -0,0 +1,7 @@ +module ParamsBackwardCompatibility + private + + def set_non_archived_param + params[:non_archived] = params[:archived].blank? + end +end diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb new file mode 100644 index 00000000000..1d37e4cb3bd --- /dev/null +++ b/app/controllers/concerns/renders_blob.rb @@ -0,0 +1,24 @@ +module RendersBlob + extend ActiveSupport::Concern + + def render_blob_json(blob) + viewer = + case params[:viewer] + when 'rich' + blob.rich_viewer + when 'auxiliary' + blob.auxiliary_viewer + else + blob.simple_viewer + end + return render_404 unless viewer + + render json: { + html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false) + } + end + + def override_max_blob_size(blob) + blob.override_max_size! if params[:override_max_size] == 'true' + end +end diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb new file mode 100644 index 00000000000..41c3114ad1e --- /dev/null +++ b/app/controllers/concerns/renders_notes.rb @@ -0,0 +1,22 @@ +module RendersNotes + def prepare_notes_for_rendering(notes) + preload_noteable_for_regular_notes(notes) + preload_max_access_for_authors(notes, @project) + Banzai::NoteRenderer.render(notes, @project, current_user) + + notes + end + + private + + def preload_max_access_for_authors(notes, project) + return nil unless project + + user_ids = notes.map(&:author_id) + project.team.max_member_access_for_user_ids(user_ids) + end + + def preload_noteable_for_regular_notes(notes) + ActiveRecord::Associations::Preloader.new.preload(notes.reject(&:for_commit?), :noteable) + end +end diff --git a/app/controllers/concerns/requires_health_token.rb b/app/controllers/concerns/requires_health_token.rb new file mode 100644 index 00000000000..34ab1a97649 --- /dev/null +++ b/app/controllers/concerns/requires_health_token.rb @@ -0,0 +1,25 @@ +module RequiresHealthToken + extend ActiveSupport::Concern + included do + before_action :validate_health_check_access! + end + + private + + def validate_health_check_access! + render_404 unless token_valid? + end + + def token_valid? + token = params[:token].presence || request.headers['TOKEN'] + token.present? && + ActiveSupport::SecurityUtils.variable_size_secure_compare( + token, + current_application_settings.health_check_access_token + ) + end + + def render_404 + render file: Rails.root.join('public', '404'), layout: false, status: '404' + end +end diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb new file mode 100644 index 00000000000..4199da9cdf5 --- /dev/null +++ b/app/controllers/concerns/routable_actions.rb @@ -0,0 +1,38 @@ +module RoutableActions + extend ActiveSupport::Concern + + def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil) + routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?) + + if routable_authorized?(routable, extra_authorization_proc) + ensure_canonical_path(routable, requested_full_path) + routable + else + route_not_found + nil + end + end + + def routable_authorized?(routable, extra_authorization_proc) + action = :"read_#{routable.class.to_s.underscore}" + return false unless can?(current_user, action, routable) + + if extra_authorization_proc + extra_authorization_proc.call(routable) + else + true + end + end + + def ensure_canonical_path(routable, requested_full_path) + return unless request.get? + + canonical_path = routable.full_path + if canonical_path != requested_full_path + if canonical_path.casecmp(requested_full_path) != 0 + flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_full_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." + end + redirect_to build_canonical_path(routable) + end + end +end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index a8c0937569c..be2e6c7f193 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -38,6 +38,7 @@ module ServiceParams :new_issue_url, :notify, :notify_only_broken_pipelines, + :notify_only_default_branch, :password, :priority, :project_key, diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index ca6dffe1cc5..ffea712a833 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -5,10 +5,12 @@ module SnippetsActions end def raw + disposition = params[:inline] == 'false' ? 'attachment' : 'inline' + send_data( convert_line_endings(@snippet.content), type: 'text/plain; charset=utf-8', - disposition: 'inline', + disposition: disposition, filename: @snippet.sanitized_file_name ) end diff --git a/app/controllers/concerns/toggle_award_emoji.rb b/app/controllers/concerns/toggle_award_emoji.rb index fbf9a026b10..ba5b7d33f87 100644 --- a/app/controllers/concerns/toggle_award_emoji.rb +++ b/app/controllers/concerns/toggle_award_emoji.rb @@ -22,7 +22,8 @@ module ToggleAwardEmoji def to_todoable(awardable) case awardable when Note - awardable.noteable + # we don't create todos for personal snippet comments for now + awardable.for_personal_snippet? ? nil : awardable.noteable when MergeRequest, Issue awardable when Snippet diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb new file mode 100644 index 00000000000..dec2e27335a --- /dev/null +++ b/app/controllers/concerns/uploads_actions.rb @@ -0,0 +1,27 @@ +module UploadsActions + def create + link_to_file = UploadService.new(model, params[:file], uploader_class).execute + + respond_to do |format| + if link_to_file + format.json do + render json: { link: link_to_file } + end + else + format.json do + render json: 'Invalid file.', status: :unprocessable_entity + end + end + end + end + + def show + return render_404 unless uploader.exists? + + disposition = uploader.image_or_video? ? 'inline' : 'attachment' + + expires_in 0.seconds, must_revalidate: true, private: true + + send_file uploader.file.path, disposition: disposition + end +end diff --git a/app/controllers/dashboard/labels_controller.rb b/app/controllers/dashboard/labels_controller.rb index d5031da867a..dd1d46a68c7 100644 --- a/app/controllers/dashboard/labels_controller.rb +++ b/app/controllers/dashboard/labels_controller.rb @@ -3,7 +3,7 @@ class Dashboard::LabelsController < Dashboard::ApplicationController labels = LabelsFinder.new(current_user).execute respond_to do |format| - format.json { render json: labels.as_json(only: [:id, :title, :color]) } + format.json { render json: LabelSerializer.new.represent_appearance(labels) } end end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index be00d765f73..5a1efcab1a3 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -1,10 +1,11 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController - include FilterProjects + include ParamsBackwardCompatibility + + before_action :set_non_archived_param + before_action :default_sorting def index - @projects = load_projects(current_user.authorized_projects) - @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.page(params[:page]) + @projects = load_projects(params.merge(non_public: true)).page(params[:page]) respond_to do |format| format.html { @last_push = current_user.recent_push } @@ -21,10 +22,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end def starred - @projects = load_projects(current_user.viewable_starred_projects) - @projects = @projects.includes(:forked_from_project, :tags) - @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.page(params[:page]) + @projects = load_projects(params.merge(starred: true)). + includes(:forked_from_project, :tags).page(params[:page]) @last_push = current_user.recent_push @groups = [] @@ -41,14 +40,18 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController private - def load_projects(base_scope) - projects = base_scope.sorted_by_activity.includes(:route, namespace: :route) + def default_sorting + params[:sort] ||= 'latest_activity_desc' + @sort = params[:sort] + end - filter_projects(projects) + def load_projects(finder_params) + ProjectsFinder.new(params: finder_params, current_user: current_user). + execute.includes(:route, namespace: :route) end def load_events - @events = Event.in_projects(load_projects(current_user.authorized_projects)) + @events = Event.in_projects(load_projects(params.merge(non_public: true))) @events = event_filter.apply_filter(@events).with_associations @events = @events.limit(20).offset(params[:offset] || 0) end diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb index bcfdbe14be9..8dd91264451 100644 --- a/app/controllers/dashboard/snippets_controller.rb +++ b/app/controllers/dashboard/snippets_controller.rb @@ -1,11 +1,10 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController def index - @snippets = SnippetsFinder.new.execute( + @snippets = SnippetsFinder.new( current_user, - filter: :by_user, - user: current_user, + author: current_user, scope: params[:scope] - ) + ).execute @snippets = @snippets.page(params[:page]) end end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 498690e8f11..4d7d45787fc 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -7,7 +7,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController @sort = params[:sort] @todos = @todos.page(params[:page]) if @todos.out_of_range? && @todos.total_pages != 0 - redirect_to url_for(params.merge(page: @todos.total_pages)) + redirect_to url_for(params.merge(page: @todos.total_pages, only_path: true)) end end diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb index 68228c095da..81883c543ba 100644 --- a/app/controllers/explore/groups_controller.rb +++ b/app/controllers/explore/groups_controller.rb @@ -1,6 +1,6 @@ class Explore::GroupsController < Explore::ApplicationController def index - @groups = GroupsFinder.new.execute(current_user) + @groups = GroupsFinder.new(current_user).execute @groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present? @groups = @groups.sort(@sort = params[:sort]) @groups = @groups.page(params[:page]) diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index 6167f9bd335..8f1870759e4 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -1,14 +1,12 @@ class Explore::ProjectsController < Explore::ApplicationController - include FilterProjects + include ParamsBackwardCompatibility + + before_action :set_non_archived_param def index - @projects = load_projects - @tags = @projects.tags_on(:tags) - @projects = @projects.tagged_with(params[:tag]) if params[:tag].present? - @projects = @projects.where(visibility_level: params[:visibility_level]) if params[:visibility_level].present? - @projects = filter_projects(@projects) - @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.includes(:namespace).page(params[:page]) + params[:sort] ||= 'latest_activity_desc' + @sort = params[:sort] + @projects = load_projects.page(params[:page]) respond_to do |format| format.html @@ -21,10 +19,9 @@ class Explore::ProjectsController < Explore::ApplicationController end def trending - @projects = load_projects(Project.trending) - @projects = filter_projects(@projects) - @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.page(params[:page]) + params[:trending] = true + @sort = params[:sort] + @projects = load_projects.page(params[:page]) respond_to do |format| format.html @@ -37,10 +34,7 @@ class Explore::ProjectsController < Explore::ApplicationController end def starred - @projects = load_projects - @projects = filter_projects(@projects) - @projects = @projects.reorder('star_count DESC') - @projects = @projects.page(params[:page]) + @projects = load_projects.reorder('star_count DESC').page(params[:page]) respond_to do |format| format.html @@ -52,10 +46,10 @@ class Explore::ProjectsController < Explore::ApplicationController end end - protected + private - def load_projects(base_scope = nil) - base_scope ||= ProjectsFinder.new.execute(current_user) - base_scope.includes(:route, namespace: :route) + def load_projects + ProjectsFinder.new(current_user: current_user, params: params). + execute.includes(:route, namespace: :route) end end diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb index 28760c3f84b..d3f0e033068 100644 --- a/app/controllers/explore/snippets_controller.rb +++ b/app/controllers/explore/snippets_controller.rb @@ -1,6 +1,6 @@ class Explore::SnippetsController < Explore::ApplicationController def index - @snippets = SnippetsFinder.new.execute(current_user, filter: :all) + @snippets = SnippetsFinder.new(current_user).execute @snippets = @snippets.page(params[:page]) end end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index c411c21bb80..c0ac47e363d 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -1,4 +1,6 @@ class Groups::ApplicationController < ApplicationController + include RoutableActions + layout 'group' skip_before_action :authenticate_user! @@ -7,26 +9,15 @@ class Groups::ApplicationController < ApplicationController private def group - unless @group - id = params[:group_id] || params[:id] - @group = Group.find_by_full_path(id) - - unless @group && can?(current_user, :read_group, @group) - @group = nil - - if current_user.nil? - authenticate_user! - else - render_404 - end - end - end - - @group + @group ||= find_routable!(Group, params[:group_id] || params[:id]) end def group_projects - @projects ||= GroupProjectsFinder.new(group).execute(current_user) + @projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute + end + + def group_merge_requests + @group_merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id).execute end def authorize_admin_group! @@ -40,4 +31,10 @@ class Groups::ApplicationController < ApplicationController return render_403 end end + + def build_canonical_path(group) + params[:group_id] = group.to_param + + url_for(params) + end end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 0cbf3eb58a3..8fc234a62b1 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -14,27 +14,13 @@ class Groups::GroupMembersController < Groups::ApplicationController @members = @members.search(params[:search]) if params[:search].present? @members = @members.sort(@sort) @members = @members.page(params[:page]).per(50) + @members.includes(:user) @requesters = AccessRequestsFinder.new(@group).execute(current_user) @group_member = @group.group_members.new end - def create - if params[:user_ids].blank? - return redirect_to(group_group_members_path(@group), alert: 'No users specified.') - end - - @group.add_users( - params[:user_ids].split(','), - params[:access_level], - current_user: current_user, - expires_at: params[:expires_at] - ) - - redirect_to group_group_members_path(@group), notice: 'Users were successfully added.' - end - def update @group_member = @group.group_members.find(params[:id]) @@ -43,15 +29,6 @@ class Groups::GroupMembersController < Groups::ApplicationController @group_member.update_attributes(member_params) end - def destroy - Members::DestroyService.new(@group, current_user, id: params[:id]).execute(:all) - - respond_to do |format| - format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } - format.js { head :ok } - end - end - def resend_invite redirect_path = group_group_members_path(@group) diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index facb25525b5..3fa0516fb0c 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -15,7 +15,7 @@ class Groups::LabelsController < Groups::ApplicationController format.json do available_labels = LabelsFinder.new(current_user, group_id: @group.id).execute - render json: available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(available_labels) end end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 43102596201..e52fa766044 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -1,6 +1,8 @@ class Groups::MilestonesController < Groups::ApplicationController + include MilestoneActions + before_action :group_projects - before_action :milestone, only: [:show, :update] + before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels] before_action :authorize_admin_milestones!, only: [:new, :create, :update] def index diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 05f9ee1ee90..965ced4d372 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -1,7 +1,7 @@ class GroupsController < Groups::ApplicationController - include FilterProjects include IssuesAction include MergeRequestsAction + include ParamsBackwardCompatibility respond_to :html @@ -12,8 +12,8 @@ class GroupsController < Groups::ApplicationController before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects] before_action :authorize_create_group!, only: [:new, :create] - # Load group projects before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] + before_action :group_merge_requests, only: [:merge_requests] before_action :event_filter, only: [:activity] before_action :user_actions, only: [:show, :subgroups] @@ -64,7 +64,7 @@ class GroupsController < Groups::ApplicationController end def subgroups - @nested_groups = group.children + @nested_groups = GroupsFinder.new(current_user, parent: group).execute @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present? end @@ -105,15 +105,16 @@ class GroupsController < Groups::ApplicationController protected def setup_projects + set_non_archived_param + params[:sort] ||= 'latest_activity_desc' + @sort = params[:sort] + options = {} options[:only_owned] = true if params[:shared] == '0' options[:only_shared] = true if params[:shared] == '1' - @projects = GroupProjectsFinder.new(group, options).execute(current_user) + @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute @projects = @projects.includes(:namespace) - @projects = @projects.sorted_by_activity - @projects = filter_projects(@projects) - @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.page(params[:page]) if params[:name].blank? end @@ -150,7 +151,9 @@ class GroupsController < Groups::ApplicationController :visibility_level, :parent_id, :create_chat_team, - :chat_team_name + :chat_team_name, + :require_two_factor_authentication, + :two_factor_grace_period ] end @@ -166,4 +169,12 @@ class GroupsController < Groups::ApplicationController @notification_setting = current_user.notification_settings_for(group) end end + + def build_canonical_path(group) + return group_path(group) if action_name == 'show' # root group path + + params[:id] = group.to_param + + url_for(params) + end end diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb index 037da7d2bce..5d3109b7187 100644 --- a/app/controllers/health_check_controller.rb +++ b/app/controllers/health_check_controller.rb @@ -1,22 +1,3 @@ class HealthCheckController < HealthCheck::HealthCheckController - before_action :validate_health_check_access! - - private - - def validate_health_check_access! - render_404 unless token_valid? - end - - def token_valid? - token = params[:token].presence || request.headers['TOKEN'] - token.present? && - ActiveSupport::SecurityUtils.variable_size_secure_compare( - token, - current_application_settings.health_check_access_token - ) - end - - def render_404 - render file: Rails.root.join('public', '404'), layout: false, status: '404' - end + include RequiresHealthToken end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 00000000000..125746d0426 --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,60 @@ +class HealthController < ActionController::Base + protect_from_forgery with: :exception + include RequiresHealthToken + + CHECKS = [ + Gitlab::HealthChecks::DbCheck, + Gitlab::HealthChecks::RedisCheck, + Gitlab::HealthChecks::FsShardsCheck + ].freeze + + def readiness + results = CHECKS.map { |check| [check.name, check.readiness] } + + render_check_results(results) + end + + def liveness + results = CHECKS.map { |check| [check.name, check.liveness] } + + render_check_results(results) + end + + def metrics + results = CHECKS.flat_map(&:metrics) + + response = results.map(&method(:metric_to_prom_line)).join("\n") + + render text: response, content_type: 'text/plain; version=0.0.4' + end + + private + + def metric_to_prom_line(metric) + labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || '' + if labels.empty? + "#{metric.name} #{metric.value}" + else + "#{metric.name}{#{labels}} #{metric.value}" + end + end + + def render_check_results(results) + flattened = results.flat_map do |name, result| + if result.is_a?(Gitlab::HealthChecks::Result) + [[name, result]] + else + result.map { |r| [name, r] } + end + end + success = flattened.all? { |name, r| r.success } + + response = flattened.map do |name, r| + info = { status: r.success ? 'ok' : 'failed' } + info['message'] = r.message if r.message + info[:labels] = r.labels if r.labels + [name, info] + end + render json: response.to_h, status: success ? :ok : :service_unavailable + end +end diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index eeee027ef2d..9de0297ecfd 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -1,17 +1,27 @@ class Import::BaseController < ApplicationController private - def find_or_create_namespace(name, owner) - return current_user.namespace if name == owner + def find_or_create_namespace(names, owner) + return current_user.namespace if names == owner return current_user.namespace unless current_user.can_create_group? - begin - name = params[:target_namespace].presence || name - namespace = Group.create!(name: name, path: name, owner: current_user) - namespace.add_owner(current_user) - namespace - rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid - Namespace.find_by_full_path(name) + names = params[:target_namespace].presence || names + full_path_namespace = Namespace.find_by_full_path(names) + + return full_path_namespace if full_path_namespace + + names.split('/').inject(nil) do |parent, name| + begin + namespace = Group.create!(name: name, + path: name, + owner: current_user, + parent: parent) + namespace.add_owner(current_user) + + namespace + rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid + Namespace.where(parent: parent).find_by_path_or_name(name) + end end end end diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 3109439b2ff..1c01be06451 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -4,7 +4,7 @@ class JwtController < ApplicationController before_action :authenticate_project_or_user SERVICES = { - Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService, + Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService }.freeze def auth diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 58d50ad647b..2a8c8ca4bad 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -67,7 +67,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def omniauth_error @provider = params[:provider] @error = params[:error] - render 'errors/omniauth_error', layout: "errors", status: 422 + render 'errors/omniauth_error', layout: "oauth_error", status: 422 end def cas3 diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index 69959fe3687..7d1aa8d1ce0 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -1,11 +1,22 @@ class Profiles::AccountsController < Profiles::ApplicationController + include AuthHelper + def show @user = current_user end def unlink provider = params[:provider] - current_user.identities.find_by(provider: provider).destroy unless provider.to_s == 'saml' + identity = current_user.identities.find_by(provider: provider) + + return render_404 unless identity + + if unlink_allowed?(provider) + identity.destroy + else + flash[:alert] = "You are not allowed to unlink your primary login account" + end + redirect_to profile_account_path end end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 0d891ef4004..5414142e2df 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -33,7 +33,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :color_scheme_id, :layout, :dashboard, - :project_view, + :project_view ) end end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 26e7e93533e..d3fa81cd623 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -1,5 +1,5 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController - skip_before_action :check_2fa_requirement + skip_before_action :check_two_factor_requirement def show unless current_user.otp_secret @@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController current_user.save! if current_user.changed? if two_factor_authentication_required? && !current_user.two_factor_enabled? - if two_factor_grace_period_expired? - flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.' - else + two_factor_authentication_reason( + global: lambda do + flash.now[:alert] = + 'The global settings require you to enable Two-Factor Authentication for your account.' + end, + group: lambda do |groups| + group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence + + flash.now[:alert] = %{ + The group settings for #{group_links} require you to enable + Two-Factor Authentication for your account. + }.html_safe + end + ) + + unless two_factor_grace_period_expired? grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours - flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}." + flash.now[:alert] << " You need to do this before #{l(grace_period_deadline)}." end end @@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController if two_factor_grace_period_expired? redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup' else - session[:skip_tfa] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours + session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours redirect_to root_path end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 987b95e89b9..57e23cea00e 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -85,7 +85,8 @@ class ProfilesController < Profiles::ApplicationController :twitter, :username, :website_url, - :organization + :organization, + :preferred_language ) end end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index f1a93ccb3ad..cb4bd0ad5f5 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -1,5 +1,8 @@ class Projects::ApplicationController < ApplicationController + include RoutableActions + skip_before_action :authenticate_user! + before_action :redirect_git_extension before_action :project before_action :repository layout 'project' @@ -8,40 +11,29 @@ class Projects::ApplicationController < ApplicationController private + def redirect_git_extension + # Redirect from + # localhost/group/project.git + # to + # localhost/group/project + # + redirect_to url_for(params.merge(format: nil)) if params[:format] == 'git' + end + def project - unless @project - namespace = params[:namespace_id] - id = params[:project_id] || params[:id] - - # Redirect from - # localhost/group/project.git - # to - # localhost/group/project - # - if params[:format] == 'git' - redirect_to request.original_url.gsub(/\.git\/?\Z/, '') - return - end - - project_path = "#{namespace}/#{id}" - @project = Project.find_by_full_path(project_path) - - if can?(current_user, :read_project, @project) && !@project.pending_delete? - if @project.path_with_namespace != project_path - redirect_to request.original_url.gsub(project_path, @project.path_with_namespace) - end - else - @project = nil - - if current_user.nil? - authenticate_user! - else - render_404 - end - end - end + return @project if @project + + path = File.join(params[:namespace_id], params[:project_id] || params[:id]) + auth_proc = ->(project) { !project.pending_delete? } - @project + @project = find_routable!(Project, path, extra_authorization_proc: auth_proc) + end + + def build_canonical_path(project) + params[:namespace_id] = project.namespace.to_param + params[:project_id] = project.to_param + + url_for(params) end def repository @@ -55,13 +47,15 @@ class Projects::ApplicationController < ApplicationController (current_user && current_user.already_forked?(project)) end - def authorize_project!(action) - return access_denied! unless can?(current_user, action, project) + def authorize_action!(action) + unless can?(current_user, action, project) + return access_denied! + end end def method_missing(method_sym, *arguments, &block) if method_sym.to_s =~ /\Aauthorize_(.*)!\z/ - authorize_project!($1.to_sym) + authorize_action!($1.to_sym) else super end @@ -90,8 +84,7 @@ class Projects::ApplicationController < ApplicationController return render_404 unless @project.feature_available?(:builds, current_user) end - def update_ref - branch_exists = @repository.find_branch(@target_branch) - @ref = @target_branch if branch_exists + def require_pages_enabled! + not_found unless Gitlab.config.pages.enabled end end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 59222637961..1224e9503c9 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,11 +1,13 @@ class Projects::ArtifactsController < Projects::ApplicationController include ExtractsPath + include RendersBlob layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path before_action :validate_artifacts! + before_action :set_path_and_entry, only: [:file, :raw] def download if artifacts_file.file_storage? @@ -16,22 +18,32 @@ class Projects::ArtifactsController < Projects::ApplicationController end def browse - directory = params[:path] ? "#{params[:path]}/" : '' + @path = params[:path] + directory = @path ? "#{@path}/" : '' @entry = build.artifacts_metadata_entry(directory) render_404 unless @entry.exists? end def file - entry = build.artifacts_metadata_entry(params[:path]) + blob = @entry.blob + override_max_blob_size(blob) - if entry.exists? - send_artifacts_entry(build, entry) - else - render_404 + respond_to do |format| + format.html do + render 'file' + end + + format.json do + render_blob_json(blob) + end end end + def raw + send_artifacts_entry(build, @entry) + end + def keep build.keep_artifacts! redirect_to namespace_project_build_path(project.namespace, project, build) @@ -60,7 +72,10 @@ class Projects::ArtifactsController < Projects::ApplicationController end def build - @build ||= build_from_id || build_from_ref + @build ||= begin + build = build_from_id || build_from_ref + build&.present(current_user: current_user) + end end def build_from_id @@ -77,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController def artifacts_file @artifacts_file ||= build.artifacts_file end + + def set_path_and_entry + @path = params[:path] + @entry = build.artifacts_metadata_entry(@path) + + render_404 unless @entry.exists? + end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 80a95c6158b..87721fbe2f5 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -2,14 +2,17 @@ class Projects::BlobController < Projects::ApplicationController include ExtractsPath include CreatesCommit + include RendersBlob include ActionView::Helpers::SanitizeHelper # Raised when given an invalid file path InvalidPathError = Class.new(StandardError) + prepend_before_action :authenticate_user!, only: [:edit] + before_action :require_non_empty_project, except: [:new, :create] before_action :authorize_download_code! - before_action :authorize_edit_tree!, only: [:new, :create, :edit, :update, :destroy] + before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] before_action :assign_blob_vars before_action :commit, except: [:new, :create] before_action :blob, except: [:new, :create] @@ -23,21 +26,39 @@ class Projects::BlobController < Projects::ApplicationController end def create - update_ref + set_start_branch_to_branch_name create_commit(Files::CreateService, success_notice: "The file has been successfully created.", - success_path: -> { namespace_project_blob_path(@project.namespace, @project, File.join(@target_branch, @file_path)) }, + success_path: -> { namespace_project_blob_path(@project.namespace, @project, File.join(@branch_name, @file_path)) }, failure_view: :new, failure_path: namespace_project_new_blob_path(@project.namespace, @project, @ref)) end def show - environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } - @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + override_max_blob_size(@blob) + + respond_to do |format| + format.html do + environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + + @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path) + + render 'show' + end + + format.json do + render_blob_json(@blob) + end + end end def edit - blob.load_all_data!(@repository) + if can_collaborate_with_project? + blob.load_all_data!(@repository) + else + redirect_to action: 'show' + end end def update @@ -63,10 +84,10 @@ class Projects::BlobController < Projects::ApplicationController end def destroy - create_commit(Files::DestroyService, success_notice: "The file has been successfully deleted.", - success_path: -> { namespace_project_tree_path(@project.namespace, @project, @target_branch) }, - failure_view: :show, - failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) + create_commit(Files::DeleteService, success_notice: "The file has been successfully deleted.", + success_path: -> { namespace_project_tree_path(@project.namespace, @project, @branch_name) }, + failure_view: :show, + failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) end def diff @@ -90,7 +111,7 @@ class Projects::BlobController < Projects::ApplicationController private def blob - @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path)) + @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path), @project) if @blob @blob @@ -121,16 +142,16 @@ class Projects::BlobController < Projects::ApplicationController def after_edit_path from_merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:from_merge_request_iid]) - if from_merge_request && @target_branch == @ref + if from_merge_request && @branch_name == @ref diffs_namespace_project_merge_request_path(from_merge_request.target_project.namespace, from_merge_request.target_project, from_merge_request) + "##{hexdigest(@path)}" else - namespace_project_blob_path(@project.namespace, @project, File.join(@target_branch, @path)) + namespace_project_blob_path(@project.namespace, @project, File.join(@branch_name, @path)) end end def editor_variables - @target_branch = params[:target_branch] + @branch_name = params[:branch_name] @file_path = if action_name.to_s == 'create' diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb index 28c9646910d..da9b789d617 100644 --- a/app/controllers/projects/boards/issues_controller.rb +++ b/app/controllers/projects/boards/issues_controller.rb @@ -82,7 +82,7 @@ module Projects labels: true, only: [:id, :iid, :title, :confidential, :due_date, :relative_position], include: { - assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, milestone: { only: [:id, :title] } }, user: current_user diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 840405f38cb..d8ed470e461 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -46,32 +46,45 @@ class Projects::BranchesController < Projects::ApplicationController SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue end - if result[:status] == :success - @branch = result[:branch] - - if redirect_to_autodeploy - redirect_to( - url_to_autodeploy_setup(project, branch_name), - notice: view_context.autodeploy_flash_notice(branch_name)) - else - redirect_to namespace_project_tree_path(@project.namespace, @project, - @branch.name) + respond_to do |format| + format.html do + if result[:status] == :success + if redirect_to_autodeploy + redirect_to url_to_autodeploy_setup(project, branch_name), + notice: view_context.autodeploy_flash_notice(branch_name) + else + redirect_to namespace_project_tree_path(@project.namespace, @project, branch_name) + end + else + @error = result[:message] + render action: 'new' + end + end + + format.json do + if result[:status] == :success + render json: { name: branch_name, url: namespace_project_tree_url(@project.namespace, @project, branch_name) } + else + render json: result[:messsage], status: :unprocessable_entity + end end - else - @error = result[:message] - render action: 'new' end end def destroy @branch_name = Addressable::URI.unescape(params[:id]) - status = DeleteBranchService.new(project, current_user).execute(@branch_name) + result = DeleteBranchService.new(project, current_user).execute(@branch_name) + respond_to do |format| format.html do - redirect_to namespace_project_branches_path(@project.namespace, - @project), status: 303 + flash_type = result[:status] == :error ? :alert : :notice + flash[flash_type] = result[:message] + + redirect_to namespace_project_branches_path(@project.namespace, @project), status: 303 end - format.js { render nothing: true, status: status[:return_code] } + + format.js { render nothing: true, status: result[:return_code] } + format.json { render json: { message: result[:message] }, status: result[:return_code] } end end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 3f3c90a49ab..dfaaea71b9c 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -1,7 +1,11 @@ class Projects::BuildsController < Projects::ApplicationController before_action :build, except: [:index, :cancel_all] - before_action :authorize_read_build!, except: [:cancel, :cancel_all, :retry, :play] - before_action :authorize_update_build!, except: [:index, :show, :status, :raw, :trace] + + before_action :authorize_read_build!, + only: [:index, :show, :status, :raw, :trace] + before_action :authorize_update_build!, + except: [:index, :show, :status, :raw, :trace, :cancel_all] + layout 'project' def index @@ -19,11 +23,21 @@ class Projects::BuildsController < Projects::ApplicationController else @builds end + @builds = @builds.includes([ + { pipeline: :project }, + :project, + :tags + ]) @builds = @builds.page(params[:page]).per(30) end def cancel_all - @project.builds.running_or_pending.each(&:cancel) + return access_denied! unless can?(current_user, :update_build, project) + + @project.builds.running_or_pending.each do |build| + build.cancel if can?(current_user, :update_build, build) + end + redirect_to namespace_project_builds_path(project.namespace, project) end @@ -31,72 +45,84 @@ class Projects::BuildsController < Projects::ApplicationController @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') @builds = @builds.where("id not in (?)", @build.id) @pipeline = @build.pipeline - - respond_to do |format| - format.html - format.json do - render json: { - id: @build.id, - status: @build.status, - trace_html: @build.trace_html - } - end - end end def trace - respond_to do |format| - format.json do - state = params[:state].presence - render json: @build.trace_with_state(state: state). - merge!(id: @build.id, status: @build.status) + build.trace.read do |stream| + respond_to do |format| + format.json do + result = { + id: @build.id, status: @build.status, complete: @build.complete? + } + + if stream.valid? + stream.limit + state = params[:state].presence + trace = stream.html_with_state(state) + result.merge!(trace.to_h) + end + + render json: result + end end end end def retry - return render_404 unless @build.retryable? + return respond_422 unless @build.retryable? build = Ci::Build.retry(@build, current_user) redirect_to build_path(build) end def play - return render_404 unless @build.playable? + return respond_422 unless @build.playable? build = @build.play(current_user) redirect_to build_path(build) end def cancel + return respond_422 unless @build.cancelable? + @build.cancel redirect_to build_path(@build) end def status render json: BuildSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@build) end def erase - @build.erase(erased_by: current_user) - redirect_to namespace_project_build_path(project.namespace, project, @build), + if @build.erase(erased_by: current_user) + redirect_to namespace_project_build_path(project.namespace, project, @build), notice: "Build has been successfully erased!" + else + respond_422 + end end def raw - if @build.has_trace_file? - send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline' - else - render_404 + build.trace.read do |stream| + if stream.file? + send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' + else + render_404 + end end end private + def authorize_update_build! + return access_denied! unless can?(current_user, :update_build, build) + end + def build - @build ||= project.builds.find_by!(id: params[:id]).present(current_user: current_user) + @build ||= project.builds.find(params[:id]) + .present(current_user: current_user) end def build_path(build) diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index cc67f688d51..7c3cce1c241 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -2,6 +2,7 @@ # # Not to be confused with CommitsController, plural. class Projects::CommitController < Projects::ApplicationController + include RendersNotes include CreatesCommit include DiffForPath include DiffHelper @@ -35,8 +36,10 @@ class Projects::CommitController < Projects::ApplicationController respond_to do |format| format.html format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end @@ -53,9 +56,7 @@ class Projects::CommitController < Projects::ApplicationController return render_404 if @start_branch.blank? - @target_branch = create_new_branch? ? @commit.revert_branch_name : @start_branch - - @mr_target_branch = @start_branch + @branch_name = create_new_branch? ? @commit.revert_branch_name : @start_branch create_commit(Commits::RevertService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.", success_path: -> { successful_change_path }, failure_path: failed_change_path) @@ -66,9 +67,7 @@ class Projects::CommitController < Projects::ApplicationController return render_404 if @start_branch.blank? - @target_branch = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch - - @mr_target_branch = @start_branch + @branch_name = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch create_commit(Commits::CherryPickService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked.", success_path: -> { successful_change_path }, failure_path: failed_change_path) @@ -81,7 +80,7 @@ class Projects::CommitController < Projects::ApplicationController end def successful_change_path - referenced_merge_request_url || namespace_project_commits_url(@project.namespace, @project, @target_branch) + referenced_merge_request_url || namespace_project_commits_url(@project.namespace, @project, @branch_name) end def failed_change_path @@ -111,22 +110,19 @@ class Projects::CommitController < Projects::ApplicationController end def define_note_vars - @grouped_diff_discussions = commit.notes.grouped_diff_discussions - @notes = commit.notes.non_diff_notes.fresh - - Banzai::NoteRenderer.render( - @grouped_diff_discussions.values.flat_map(&:notes) + @notes, - @project, - current_user, - ) - + @noteable = @commit @note = @project.build_commit_note(commit) - @noteable = @commit - @comments_target = { + @new_diff_note_attrs = { noteable_type: 'Commit', commit_id: @commit.id } + + @grouped_diff_discussions = commit.grouped_diff_discussions + @discussions = commit.discussions + + @notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes) + @notes = prepare_notes_for_rendering(@notes) end def assign_change_commit_vars diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index c6651254d70..008d2f5815f 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -61,7 +61,6 @@ class Projects::CompareController < Projects::ApplicationController @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last @diff_notes_disabled = true - @grouped_diff_discussions = {} end end diff --git a/app/controllers/projects/container_registry_controller.rb b/app/controllers/projects/container_registry_controller.rb deleted file mode 100644 index d1f46497207..00000000000 --- a/app/controllers/projects/container_registry_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -class Projects::ContainerRegistryController < Projects::ApplicationController - before_action :verify_registry_enabled - before_action :authorize_read_container_image! - before_action :authorize_update_container_image!, only: [:destroy] - layout 'project' - - def index - @tags = container_registry_repository.tags - end - - def destroy - url = namespace_project_container_registry_index_path(project.namespace, project) - - if tag.delete - redirect_to url - else - redirect_to url, alert: 'Failed to remove tag' - end - end - - private - - def verify_registry_enabled - render_404 unless Gitlab.config.registry.enabled - end - - def container_registry_repository - @container_registry_repository ||= project.container_registry_repository - end - - def tag - @tag ||= container_registry_repository.tag(params[:id]) - end -end diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb index d0c44e297e3..f27089b8590 100644 --- a/app/controllers/projects/deploy_keys_controller.rb +++ b/app/controllers/projects/deploy_keys_controller.rb @@ -8,7 +8,12 @@ class Projects::DeployKeysController < Projects::ApplicationController layout "project_settings" def index - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json do + render json: Projects::Settings::DeployKeysPresenter.new(@project, current_user: current_user).as_json + end + end end def new @@ -19,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController @key = DeployKey.new(deploy_key_params.merge(user: current_user)) unless @key.valid? && @project.deploy_keys << @key - flash[:alert] = @key.errors.full_messages.join(', ').html_safe + flash[:alert] = @key.errors.full_messages.join(', ').html_safe end redirect_to_repository_settings(@project) end @@ -27,7 +32,10 @@ class Projects::DeployKeysController < Projects::ApplicationController def enable Projects::EnableDeployKeyService.new(@project, current_user, params).execute - redirect_to_repository_settings(@project) + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end def disable @@ -35,7 +43,11 @@ class Projects::DeployKeysController < Projects::ApplicationController return render_404 unless deploy_key_project deploy_key_project.destroy! - redirect_to_repository_settings(@project) + + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.json { head :ok } + end end protected diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb new file mode 100644 index 00000000000..6644deb49c9 --- /dev/null +++ b/app/controllers/projects/deployments_controller.rb @@ -0,0 +1,34 @@ +class Projects::DeploymentsController < Projects::ApplicationController + before_action :authorize_read_environment! + before_action :authorize_read_deployment! + + def index + deployments = environment.deployments.reorder(created_at: :desc) + deployments = deployments.where('created_at > ?', params[:after].to_time) if params[:after]&.to_time + + render json: { deployments: DeploymentSerializer.new(project: project) + .represent_concise(deployments) } + end + + def metrics + return render_404 unless deployment.has_metrics? + @metrics = deployment.metrics + if @metrics&.any? + render json: @metrics, status: :ok + else + head :no_content + end + rescue NotImplementedError + render_404 + end + + private + + def deployment + @deployment ||= environment.deployments.find_by(iid: params[:id]) + end + + def environment + @environment ||= project.environments.find(params[:environment_id]) + end +end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 1349b015a63..f4a18a5e8f7 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -28,7 +28,7 @@ class Projects::DiscussionsController < Projects::ApplicationController end def discussion - @discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404 + @discussion ||= @merge_request.find_discussion(params[:id]) || render_404 end def authorize_resolve_discussion! diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index fa37963dfd4..fd57afbd05f 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -17,7 +17,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.json do render json: { environments: EnvironmentSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .within_folders .represent(@environments), @@ -37,7 +37,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.json do render json: { environments: EnvironmentSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@environments), available_count: folder_environments.available.count, @@ -81,10 +81,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController stop_action = @environment.stop_with_action!(current_user) - if stop_action - redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, stop_action]) - else - redirect_to namespace_project_environment_path(project.namespace, project, @environment) + action_or_env_url = + if stop_action + polymorphic_url([project.namespace.becomes(Namespace), project, stop_action]) + else + namespace_project_environment_url(project.namespace, project, @environment) + end + + respond_to do |format| + format.html { redirect_to action_or_env_url } + format.json { render json: { redirect_url: action_or_env_url } } end end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index ba46e2528e6..1eb3800e49d 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -9,7 +9,7 @@ class Projects::ForksController < Projects::ApplicationController def index base_query = project.forks.includes(:creator) - @forks = base_query.merge(ProjectsFinder.new.execute(current_user)) + @forks = base_query.merge(ProjectsFinder.new(current_user: current_user).execute) @total_forks_count = base_query.size @private_forks_count = @total_forks_count - @forks.size @public_forks_count = @total_forks_count - @private_forks_count diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 278098fcc58..9e4edcae101 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -5,6 +5,8 @@ class Projects::GitHttpController < Projects::GitHttpClientController # GET /foo/bar.git/info/refs?service=git-receive-pack (git push) def info_refs if upload_pack? && upload_pack_allowed? + log_user_activity + render_ok elsif receive_pack? && receive_pack_allowed? render_ok @@ -57,7 +59,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController def render_ok set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.git_http_ok(repository, user) + render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name) end def render_http_not_allowed @@ -106,4 +108,8 @@ class Projects::GitHttpController < Projects::GitHttpClientController def access_klass @access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess end + + def log_user_activity + Users::ActivityService.new(user, 'pull').execute + end end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index b668a9331e7..86d13a0d222 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -1,6 +1,7 @@ class Projects::HooksController < Projects::ApplicationController # Authorize before_action :authorize_admin_project! + before_action :hook, only: :edit respond_to :html @@ -10,13 +11,25 @@ class Projects::HooksController < Projects::ApplicationController @hook = @project.hooks.new(hook_params) @hook.save - unless @hook.valid? + unless @hook.valid? @hooks = @project.hooks.select(&:persisted?) flash[:alert] = @hook.errors.full_messages.join.html_safe end redirect_to namespace_project_settings_integrations_path(@project.namespace, @project) end + def edit + end + + def update + if hook.update_attributes(hook_params) + flash[:notice] = 'Hook was successfully updated.' + redirect_to namespace_project_settings_integrations_path(@project.namespace, @project) + else + render 'edit' + end + end + def test if !@project.empty_repo? status, message = TestHookService.new.execute(hook, current_user) @@ -49,7 +62,7 @@ class Projects::HooksController < Projects::ApplicationController def hook_params params.require(:hook).permit( - :build_events, + :job_events, :pipeline_events, :enable_ssl_verification, :issues_events, diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d984e6d3918..cbef8fa94d4 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,5 +1,5 @@ class Projects::IssuesController < Projects::ApplicationController - include NotesHelper + include RendersNotes include ToggleSubscriptionAction include IssuableActions include ToggleAwardEmoji @@ -11,10 +11,10 @@ class Projects::IssuesController < Projects::ApplicationController before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, - :related_branches, :can_create_branch] + :related_branches, :can_create_branch, :realtime_changes, :create_merge_request] # Allow read any issue - before_action :authorize_read_issue!, only: [:show] + before_action :authorize_read_issue!, only: [:show, :realtime_changes] # Allow write(create) issue before_action :authorize_create_issue!, only: [:new, :create] @@ -22,6 +22,9 @@ class Projects::IssuesController < Projects::ApplicationController # Allow modify issue before_action :authorize_update_issue!, only: [:edit, :update] + # Allow create a new branch and empty WIP merge request from current issue + before_action :authorize_create_merge_request!, only: [:create_merge_request] + respond_to :html def index @@ -31,7 +34,7 @@ class Projects::IssuesController < Projects::ApplicationController @issuable_meta_data = issuable_meta_data(@issues, @collection_type) if @issues.out_of_range? && @issues.total_pages != 0 - return redirect_to url_for(params.merge(page: @issues.total_pages)) + return redirect_to url_for(params.merge(page: @issues.total_pages, only_path: true)) end if params[:label_name].present? @@ -64,7 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController def new params[:issue] ||= ActionController::Parameters.new( - assignee_id: "" + assignee_ids: "" ) build_params = issue_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], @@ -84,15 +87,11 @@ class Projects::IssuesController < Projects::ApplicationController end def show - raw_notes = @issue.notes.inc_relations_for_view.fresh - - @notes = Banzai::NoteRenderer. - render(raw_notes, @project, current_user, @path, @project_wiki, @ref) - - @note = @project.notes.new(noteable: @issue) @noteable = @issue + @note = @project.notes.new(noteable: @issue) - preload_max_access_for_authors(@notes, @project) + @discussions = @issue.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) respond_to do |format| format.html @@ -151,7 +150,7 @@ class Projects::IssuesController < Projects::ApplicationController if @issue.valid? render json: @issue.to_json(methods: [:task_status, :task_status_short], include: { milestone: {}, - assignee: { only: [:name, :username], methods: [:avatar_url] }, + assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, labels: { methods: :text_color } }) else render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity @@ -195,16 +194,39 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.json do - render json: { can_create_branch: can_create } + render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? } end end end + def realtime_changes + Gitlab::PollingInterval.set_header(response, interval: 3_000) + + render json: { + title: view_context.markdown_field(@issue, :title), + title_text: @issue.title, + description: view_context.markdown_field(@issue, :description), + description_text: @issue.description, + task_status: @issue.task_status, + updated_at: @issue.updated_at + } + end + + def create_merge_request + result = MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute + + if result[:status] == :success + render json: MergeRequestCreateSerializer.new.represent(result[:merge_request]) + else + render json: result[:messsage], status: :unprocessable_entity + end + end + protected def issue # The Sortable default scope causes performance issues when used with find_by - @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old + @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take! end alias_method :subscribable_resource, :issue alias_method :issuable, :issue @@ -223,6 +245,10 @@ class Projects::IssuesController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_issue, @project) end + def authorize_create_merge_request! + return render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) + end + def module_enabled return render_404 unless @project.feature_available?(:issues, current_user) && @project.default_issues_tracker? end @@ -239,25 +265,10 @@ class Projects::IssuesController < Projects::ApplicationController end end - # Since iids are implemented only in 6.1 - # user may navigate to issue page using old global ids. - # - # To prevent 404 errors we provide a redirect to correct iids until 7.0 release - # - def redirect_old - issue = @project.issues.find_by(id: params[:id]) - - if issue - redirect_to issue_path(issue) - else - raise ActiveRecord::RecordNotFound.new - end - end - def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, - :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] + :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [] ) end @@ -266,7 +277,10 @@ class Projects::IssuesController < Projects::ApplicationController notice = "Please sign in to create the new issue." - store_location_for :user, request.fullpath + if request.get? && !request.xhr? + store_location_for :user, request.fullpath + end + redirect_to new_user_session_path, notice: notice end end diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 2f55ba4e700..71bfb7163da 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -19,7 +19,7 @@ class Projects::LabelsController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: @available_labels.as_json(only: [:id, :title, :color]) + render json: LabelSerializer.new.represent_appearance(@available_labels) end end end diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index 8a5a645ed0e..1b0d3aab3fa 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -22,7 +22,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController render( json: { message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.', - documentation_url: "#{Gitlab.config.gitlab.url}/help", + documentation_url: "#{Gitlab.config.gitlab.url}/help" }, status: 501 ) @@ -55,7 +55,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController else object[:error] = { code: 404, - message: "Object does not exist on the server or you don't have permissions to access it", + message: "Object does not exist on the server or you don't have permissions to access it" } end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 9621b30b251..0352065998b 100755..100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -3,22 +3,21 @@ class Projects::MergeRequestsController < Projects::ApplicationController include DiffForPath include DiffHelper include IssuableActions - include NotesHelper + include RendersNotes include ToggleAwardEmoji include IssuableCollections before_action :module_enabled before_action :merge_request, only: [ - :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge, :merge_check, - :ci_status, :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues + :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge, + :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues, :commit_change_content ] before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines] - before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] - before_action :define_widget_vars, only: [:merge, :cancel_merge_when_pipeline_succeeds, :merge_check] + before_action :define_show_vars, only: [:diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] before_action :define_commit_vars, only: [:diffs] - before_action :define_diff_comment_vars, only: [:diffs] before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines] before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines] + before_action :check_if_can_be_merged, only: :show before_action :apply_diff_view_cookie!, only: [:new_diffs] before_action :build_merge_request, only: [:new, :new_diffs] @@ -39,10 +38,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController @collection_type = "MergeRequest" @merge_requests = merge_requests_collection @merge_requests = @merge_requests.page(params[:page]) + @merge_requests = @merge_requests.preload(merge_request_diff: :merge_request) @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type) if @merge_requests.out_of_range? && @merge_requests.total_pages != 0 - return redirect_to url_for(params.merge(page: @merge_requests.total_pages)) + return redirect_to url_for(params.merge(page: @merge_requests.total_pages, only_path: true)) end if params[:label_name].present? @@ -74,10 +74,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController def show respond_to do |format| - format.html { define_discussion_vars } + format.html do + define_discussion_vars + define_show_vars + end format.json do - render json: MergeRequestSerializer.new.represent(@merge_request) + Gitlab::PollingInterval.set_header(response, interval: 10_000) + + render json: serializer.represent(@merge_request, basic: params[:basic]) end format.patch do @@ -100,34 +105,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController respond_to do |format| format.html { define_discussion_vars } format.json do - @merge_request_diff = - if params[:diff_id] - @merge_request.merge_request_diffs.viewable.find(params[:diff_id]) - else - @merge_request.merge_request_diff - end - - @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff - @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id } - - if params[:start_sha].present? - @start_sha = params[:start_sha] - @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha } - - unless @start_version - @start_sha = @merge_request_diff.head_commit_sha - @start_version = @merge_request_diff - end - end + define_diff_vars + define_diff_comment_vars @environment = @merge_request.environments_for(current_user).last - if @start_sha - compared_diff_version - else - original_diff_version - end - render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") } end end @@ -139,16 +121,18 @@ class Projects::MergeRequestsController < Projects::ApplicationController def diff_for_path if params[:id] merge_request + define_diff_vars define_diff_comment_vars else build_merge_request + @compare = @merge_request + @diffs = @compare.diffs(diff_options) @diff_notes_disabled = true - @grouped_diff_discussions = {} end define_commit_vars - render_diff_for_path(@merge_request.diffs(diff_options)) + render_diff_for_path(@diffs) end def commits @@ -175,8 +159,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController format.html { define_discussion_vars } format.json do - if @merge_request.conflicts_can_be_resolved_in_ui? - render json: @merge_request.conflicts + if @conflicts_list.can_be_resolved_in_ui? + render json: @conflicts_list elsif @merge_request.can_be_merged? render json: { message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.', @@ -193,9 +177,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def conflict_for_path - return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui? + return render_404 unless @conflicts_list.can_be_resolved_in_ui? - file = @merge_request.conflicts.file_for_path(params[:old_path], params[:new_path]) + file = @conflicts_list.file_for_path(params[:old_path], params[:new_path]) return render_404 unless file @@ -203,7 +187,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def resolve_conflicts - return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui? + return render_404 unless @conflicts_list.can_be_resolved_in_ui? if @merge_request.can_be_merged? render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' } @@ -211,7 +195,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end begin - MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request) + MergeRequests::Conflicts::ResolveService. + new(merge_request). + execute(current_user, params) flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.' @@ -232,8 +218,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController end format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) end end @@ -245,9 +233,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController format.json do define_pipelines_vars + Gitlab::PollingInterval.set_header(response, interval: 10_000) + render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent(@pipelines) } end @@ -316,17 +306,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def remove_wip - MergeRequests::UpdateService.new(project, current_user, wip_event: 'unwip').execute(@merge_request) + @merge_request = MergeRequests::UpdateService + .new(project, current_user, wip_event: 'unwip') + .execute(@merge_request) - redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), - notice: "The merge request can now be merged." + render json: serializer.represent(@merge_request) end - def merge_check - @merge_request.check_if_can_be_merged - @pipelines = @merge_request.all_pipelines - - render partial: "projects/merge_requests/widget/show.html.haml", layout: false + def commit_change_content + render partial: 'projects/merge_requests/widget/commit_change_content', layout: false end def cancel_merge_when_pipeline_succeeds @@ -337,65 +325,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController MergeRequests::MergeWhenPipelineSucceedsService .new(@project, current_user) .cancel(@merge_request) + + render json: serializer.represent(@merge_request) end def merge return access_denied! unless @merge_request.can_be_merged_by?(current_user) - # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have - # to wait until CI completes to know - unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) - @status = :failed - return - end - - if params[:sha] != @merge_request.diff_head_sha - @status = :sha_mismatch - return - end - - @merge_request.update(merge_error: nil) - - if params[:merge_when_pipeline_succeeds].present? - unless @merge_request.head_pipeline - @status = :failed - return - end - - if @merge_request.head_pipeline.active? - MergeRequests::MergeWhenPipelineSucceedsService - .new(@project, current_user, merge_params) - .execute(@merge_request) + status = merge! - @status = :merge_when_pipeline_succeeds - elsif @merge_request.head_pipeline.success? - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success - else - @status = :failed - end + if @merge_request.merge_error + render json: { status: status, merge_error: @merge_request.merge_error } else - MergeWorker.perform_async(@merge_request.id, current_user.id, params) - @status = :success + render json: { status: status } end end - def merge_widget_refresh - @status = - if merge_request.merge_when_pipeline_succeeds - :merge_when_pipeline_succeeds - else - # Only MRs that can be merged end in this action - # MR can be already picked up for merge / merged already or can be waiting for worker to be picked up - # in last case it does not have any special status. Possible error is handled inside widget js function - :success - end - - render 'merge' - end - def branch_from # This is always source @source_project = @merge_request.nil? ? @project : @merge_request.source_project @@ -445,37 +390,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - def ci_status - pipeline = @merge_request.head_pipeline - @pipelines = @merge_request.all_pipelines - - if pipeline - status = pipeline.status - coverage = pipeline.try(:coverage) - - status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? - - status ||= "preparing" - else - ci_service = @merge_request.source_project.try(:ci_service) - status = ci_service.commit_status(merge_request.diff_head_sha, merge_request.source_branch) if ci_service - end - - response = { - title: merge_request.title, - sha: (merge_request.diff_head_commit.short_id if merge_request.diff_head_sha), - status: status, - coverage: coverage, - pipeline: pipeline.try(:id), - has_ci: @merge_request.has_ci? - } - - render json: response - end - def pipeline_status render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@merge_request.head_pipeline) end @@ -491,10 +408,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController stop_namespace_project_environment_path(project.namespace, project, environment) end + metrics_url = + if can?(current_user, :read_environment, environment) && environment.has_metrics? + metrics_namespace_project_environment_deployment_path(environment.project.namespace, + environment.project, + environment, + deployment) + end + { id: environment.id, name: environment.name, url: namespace_project_environment_path(project.namespace, project, environment), + metrics_url: metrics_url, stop_url: stop_url, external_url: environment.external_url, external_url_formatted: environment.formatted_external_url, @@ -533,7 +459,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def authorize_can_resolve_conflicts! - return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user) + @conflicts_list = MergeRequests::Conflicts::ListService.new(@merge_request) + + return render_404 unless @conflicts_list.can_be_resolved_by?(current_user) end def module_enabled @@ -569,24 +497,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController @note = @project.notes.new(noteable: @merge_request) @discussions = @merge_request.discussions - - preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) - - # This is not executed lazily - @notes = Banzai::NoteRenderer.render( - @discussions.flat_map(&:notes), - @project, - current_user, - @path, - @project_wiki, - @ref - ) - - preload_max_access_for_authors(@notes, @project) - end - - def define_widget_vars - @pipeline = @merge_request.head_pipeline + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end def define_commit_vars @@ -594,23 +505,49 @@ class Projects::MergeRequestsController < Projects::ApplicationController @base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit end + def define_diff_vars + @merge_request_diff = + if params[:diff_id] + @merge_request.merge_request_diffs.viewable.find(params[:diff_id]) + else + @merge_request.merge_request_diff + end + + @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff + @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id } + + if params[:start_sha].present? + @start_sha = params[:start_sha] + @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha } + + unless @start_version + @start_sha = @merge_request_diff.head_commit_sha + @start_version = @merge_request_diff + end + end + + @compare = + if @start_sha + @merge_request_diff.compare_with(@start_sha) + else + @merge_request_diff + end + + @diffs = @compare.diffs(diff_options) + end + def define_diff_comment_vars - @comments_target = { + @new_diff_note_attrs = { noteable_type: 'MergeRequest', noteable_id: @merge_request.id } + @diff_notes_disabled = false + @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? - @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions - Banzai::NoteRenderer.render( - @grouped_diff_discussions.values.flat_map(&:notes), - @project, - current_user, - @path, - @project_wiki, - @ref - ) + @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs) + @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) end def define_pipelines_vars @@ -693,19 +630,55 @@ class Projects::MergeRequestsController < Projects::ApplicationController @merge_request = MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute end - def compared_diff_version - @diff_notes_disabled = true - @diffs = @merge_request_diff.compare_with(@start_sha).diffs(diff_options) + def close_merge_request_without_source_project + if !@merge_request.source_project && @merge_request.open? + @merge_request.close + end end - def original_diff_version - @diff_notes_disabled = !@merge_request_diff.latest? - @diffs = @merge_request_diff.diffs(diff_options) + private + + def check_if_can_be_merged + @merge_request.check_if_can_be_merged end - def close_merge_request_without_source_project - if !@merge_request.source_project && @merge_request.open? - @merge_request.close + def merge! + # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have + # to wait until CI completes to know + unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?) + return :failed end + + return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha + + @merge_request.update(merge_error: nil) + + if params[:merge_when_pipeline_succeeds].present? + return :failed unless @merge_request.head_pipeline + + if @merge_request.head_pipeline.active? + MergeRequests::MergeWhenPipelineSucceedsService + .new(@project, current_user, merge_params) + .execute(@merge_request) + + :merge_when_pipeline_succeeds + elsif @merge_request.head_pipeline.success? + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + + :success + else + :failed + end + else + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + + :success + end + end + + def serializer + MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) end end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 5922e686cd0..c56bce19eee 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -1,12 +1,14 @@ class Projects::MilestonesController < Projects::ApplicationController + include MilestoneActions + before_action :module_enabled - before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests] + before_action :milestone, only: [:edit, :update, :destroy, :show, :sort_issues, :sort_merge_requests, :merge_requests, :participants, :labels] # Allow read any milestone before_action :authorize_read_milestone! # Allow admin milestone - before_action :authorize_admin_milestone!, except: [:index, :show] + before_action :authorize_admin_milestone!, except: [:index, :show, :merge_requests, :participants, :labels] respond_to :html @@ -21,9 +23,10 @@ class Projects::MilestonesController < Projects::ApplicationController @sort = params[:sort] || 'due_date_asc' @milestones = @milestones.sort(@sort) - @milestones = @milestones.includes(:project) respond_to do |format| format.html do + @project_namespace = @project.namespace.becomes(Namespace) + @milestones = @milestones.includes(:project) @milestones = @milestones.page(params[:page]) end format.json do diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index d00177e7612..41a13f6f577 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,62 +1,22 @@ class Projects::NotesController < Projects::ApplicationController + include NotesActions include ToggleAwardEmoji - # Authorize before_action :authorize_read_note! before_action :authorize_create_note!, only: [:create] - before_action :authorize_admin_note!, only: [:update, :destroy] before_action :authorize_resolve_note!, only: [:resolve, :unresolve] - before_action :find_current_user_notes, only: [:index] - - def index - current_fetched_at = Time.now.to_i - - notes_json = { notes: [], last_fetched_at: current_fetched_at } - - @notes.each do |note| - next if note.cross_reference_not_visible_for?(current_user) - - notes_json[:notes] << note_json(note) - end - - render json: notes_json - end + # + # This is a fix to make spinach feature tests passing: + # Controller actions are returned from AbstractController::Base and methods of parent classes are + # excluded in order to return only specific controller related methods. + # That is ok for the app (no :create method in ancestors) + # but fails for tests because there is a :create method on FactoryGirl (one of the ancestors) + # + # see https://github.com/rails/rails/blob/v4.2.7/actionpack/lib/abstract_controller/base.rb#L78 + # def create - create_params = note_params.merge(merge_request_diff_head_sha: params[:merge_request_diff_head_sha]) - @note = Notes::CreateService.new(project, current_user, create_params).execute - - if @note.is_a?(Note) - Banzai::NoteRenderer.render([@note], @project, current_user) - end - - respond_to do |format| - format.json { render json: note_json(@note) } - format.html { redirect_back_or_default } - end - end - - def update - @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) - - if @note.is_a?(Note) - Banzai::NoteRenderer.render([@note], @project, current_user) - end - - respond_to do |format| - format.json { render json: note_json(@note) } - format.html { redirect_back_or_default } - end - end - - def destroy - if note.editable? - Notes::DestroyService.new(project, current_user).execute(note) - end - - respond_to do |format| - format.js { head :ok } - end + super end def delete_attachment @@ -102,120 +62,11 @@ class Projects::NotesController < Projects::ApplicationController end alias_method :awardable, :note - def note_html(note) - render_to_string( - "projects/notes/_note", - layout: false, - formats: [:html], - locals: { note: note } - ) - end - - def diff_discussion_html(discussion) - return unless discussion.diff_discussion? - - if params[:view] == 'parallel' - template = "discussions/_parallel_diff_discussion" - locals = - if params[:line_type] == 'old' - { discussion_left: discussion, discussion_right: nil } - else - { discussion_left: nil, discussion_right: discussion } - end - else - template = "discussions/_diff_discussion" - locals = { discussion: discussion } - end - - render_to_string( - template, - layout: false, - formats: [:html], - locals: locals - ) - end - - def discussion_html(discussion) - return unless discussion.diff_discussion? - - render_to_string( - "discussions/_discussion", - layout: false, - formats: [:html], - locals: { discussion: discussion } - ) - end - - def note_json(note) - attrs = { - id: note.id - } - - if note.persisted? - Banzai::NoteRenderer.render([note], @project, current_user) - - attrs.merge!( - valid: true, - discussion_id: note.discussion_id, - html: note_html(note), - note: note.note - ) - - if note.diff_note? - discussion = note.to_discussion - - attrs.merge!( - diff_discussion_html: diff_discussion_html(discussion), - discussion_html: discussion_html(discussion) - ) - - # The discussion_id is used to add the comment to the correct discussion - # element on the merge request page. Among other things, the discussion_id - # contains the sha of head commit of the merge request. - # When new commits are pushed into the merge request after the initial - # load of the merge request page, the discussion elements will still have - # the old discussion_ids, with the old head commit sha. The new comment, - # however, will have the new discussion_id with the new commit sha. - # To ensure that these new comments will still end up in the correct - # discussion element, we also send the original discussion_id, with the - # old commit sha, along, and fall back on this value when no discussion - # element with the new discussion_id could be found. - if note.new_diff_note? && note.position != note.original_position - attrs[:original_discussion_id] = note.original_discussion_id - end - end - else - attrs.merge!( - valid: false, - errors: note.errors - ) - end - - attrs[:commands_changes] = note.commands_changes - attrs - end - - def authorize_admin_note! - return access_denied! unless can?(current_user, :admin_note, note) + def finder_params + params.merge(last_fetched_at: last_fetched_at) end def authorize_resolve_note! return access_denied! unless can?(current_user, :resolve_note, note) end - - def note_params - params.require(:note).permit( - :note, :noteable, :noteable_id, :noteable_type, :project_id, - :attachment, :line_code, :commit_id, :type, :position - ) - end - - def find_current_user_notes - @notes = NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) - .execute.inc_author - end - - def last_fetched_at - request.headers['X-Last-Fetched-At'] - end end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index fbd18b68141..93b2c180810 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -1,6 +1,7 @@ class Projects::PagesController < Projects::ApplicationController layout 'project_settings' + before_action :require_pages_enabled! before_action :authorize_read_pages!, only: [:show] before_action :authorize_update_pages!, except: [:show] diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index b8c253f6ae3..3a93977fd27 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -1,6 +1,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController layout 'project_settings' + before_action :require_pages_enabled! before_action :authorize_update_pages!, except: [:show] before_action :domain, only: [:show, :destroy] diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb new file mode 100644 index 00000000000..1616b2cb6b8 --- /dev/null +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -0,0 +1,68 @@ +class Projects::PipelineSchedulesController < Projects::ApplicationController + before_action :authorize_read_pipeline_schedule! + before_action :authorize_create_pipeline_schedule!, only: [:new, :create, :edit, :take_ownership, :update] + before_action :authorize_admin_pipeline_schedule!, only: [:destroy] + + before_action :schedule, only: [:edit, :update, :destroy, :take_ownership] + + def index + @scope = params[:scope] + @all_schedules = PipelineSchedulesFinder.new(@project).execute + @schedules = PipelineSchedulesFinder.new(@project).execute(scope: params[:scope]) + .includes(:last_pipeline) + end + + def new + @schedule = project.pipeline_schedules.new + end + + def create + @schedule = Ci::CreatePipelineScheduleService + .new(@project, current_user, schedule_params) + .execute + + if @schedule.persisted? + redirect_to pipeline_schedules_path(@project) + else + render :new + end + end + + def edit + end + + def update + if schedule.update(schedule_params) + redirect_to namespace_project_pipeline_schedules_path(@project.namespace.becomes(Namespace), @project) + else + render :edit + end + end + + def take_ownership + if schedule.update(owner: current_user) + redirect_to pipeline_schedules_path(@project) + else + redirect_to pipeline_schedules_path(@project), alert: "Failed to change the owner" + end + end + + def destroy + if schedule.destroy + redirect_to pipeline_schedules_path(@project) + else + redirect_to pipeline_schedules_path(@project), alert: "Failed to remove the pipeline schedule" + end + end + + private + + def schedule + @schedule ||= project.pipeline_schedules.find(params[:id]) + end + + def schedule_params + params.require(:schedule) + .permit(:description, :cron, :cron_timezone, :ref, :active) + end +end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 43a1abaa662..602d3dd8c1c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -1,27 +1,31 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :pipeline, except: [:index, :new, :create, :charts] - before_action :commit, only: [:show, :builds] + before_action :commit, only: [:show, :builds, :failures] before_action :authorize_read_pipeline! before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :builds_enabled, only: :charts + wrap_parameters Ci::Pipeline + + POLLING_INTERVAL = 10_000 + def index @scope = params[:scope] @pipelines = PipelinesFinder - .new(project) - .execute(scope: @scope) + .new(project, scope: @scope) + .execute .page(params[:page]) .per(30) @running_count = PipelinesFinder - .new(project).execute(scope: 'running').count + .new(project, scope: 'running').execute.count @pending_count = PipelinesFinder - .new(project).execute(scope: 'pending').count + .new(project, scope: 'pending').execute.count @finished_count = PipelinesFinder - .new(project).execute(scope: 'finished').count + .new(project, scope: 'finished').execute.count @pipelines_count = PipelinesFinder .new(project).execute.count @@ -29,16 +33,18 @@ class Projects::PipelinesController < Projects::ApplicationController respond_to do |format| format.html format.json do + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) + render json: { pipelines: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .with_pagination(request, response) .represent(@pipelines), count: { all: @pipelines_count, running: @running_count, pending: @pending_count, - finished: @finished_count, + finished: @finished_count } } end @@ -53,28 +59,42 @@ class Projects::PipelinesController < Projects::ApplicationController @pipeline = Ci::CreatePipelineService .new(project, current_user, create_params) .execute(ignore_skip_ci: true, save_on_errors: false) - unless @pipeline.persisted? + + if @pipeline.persisted? + redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) + else render 'new' - return end - - redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) end def show + respond_to do |format| + format.html + format.json do + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) + + render json: PipelineSerializer + .new(project: @project, current_user: @current_user) + .represent(@pipeline, grouped: true) + end + end end def builds - respond_to do |format| - format.html do - render 'show' - end + render_show + end + + def failures + if @pipeline.statuses.latest.failed.present? + render_show + else + redirect_to pipeline_path(@pipeline) end end def status render json: PipelineSerializer - .new(project: @project, user: @current_user) + .new(project: @project, current_user: @current_user) .represent_status(@pipeline) end @@ -90,13 +110,25 @@ class Projects::PipelinesController < Projects::ApplicationController def retry pipeline.retry_failed(current_user) - redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + respond_to do |format| + format.html do + redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + end + + format.json { head :no_content } + end end def cancel pipeline.cancel_running - redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + respond_to do |format| + format.html do + redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) + end + + format.json { head :no_content } + end end def charts @@ -109,12 +141,20 @@ class Projects::PipelinesController < Projects::ApplicationController private + def render_show + respond_to do |format| + format.html do + render 'show' + end + end + end + def create_params params.require(:pipeline).permit(:ref) end def pipeline - @pipeline ||= project.pipelines.find_by!(id: params[:id]) + @pipeline ||= project.pipelines.find_by!(id: params[:id]).present(current_user: current_user) end def commit diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index c8c80551ac9..38a47651000 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -7,7 +7,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def update if @project.update_attributes(update_params) - flash[:notice] = "CI/CD Pipelines settings for '#{@project.name}' were successfully updated." + flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project) else render 'show' @@ -23,7 +23,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def update_params params.require(:project).permit( :runners_token, :builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex, - :public_builds + :public_builds, :auto_cancel_pending_pipelines ) end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 6e158e685e9..d2d26738582 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -10,18 +10,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController redirect_to namespace_project_settings_members_path(@project.namespace, @project, sort: sort) end - def create - status = Members::CreateService.new(@project, current_user, params).execute - - redirect_url = namespace_project_settings_members_path(@project.namespace, @project) - - if status - redirect_to redirect_url, notice: 'Users were successfully added.' - else - redirect_to redirect_url, alert: 'No users or groups specified.' - end - end - def update @project_member = @project.project_members.find(params[:id]) @@ -30,18 +18,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController @project_member.update_attributes(member_params) end - def destroy - Members::DestroyService.new(@project, current_user, params). - execute(:all) - - respond_to do |format| - format.html do - redirect_to namespace_project_settings_members_path(@project.namespace, @project) - end - format.js { head :ok } - end - end - def resend_invite redirect_path = namespace_project_settings_members_path(@project.namespace, @project) diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index a8cb07eb67a..ba24fa9acfe 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -1,58 +1,23 @@ -class Projects::ProtectedBranchesController < Projects::ApplicationController - include RepositorySettingsRedirect - # Authorize - before_action :require_non_empty_project - before_action :authorize_admin_project! - before_action :load_protected_branch, only: [:show, :update, :destroy] +class Projects::ProtectedBranchesController < Projects::ProtectedRefsController + protected - layout "project_settings" - - def index - redirect_to_repository_settings(@project) - end - - def create - @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute - unless @protected_branch.persisted? - flash[:alert] = @protected_branches.errors.full_messages.join(', ').html_safe - end - redirect_to_repository_settings(@project) - end - - def show - @matching_branches = @protected_branch.matching(@project.repository.branches) + def project_refs + @project.repository.branches end - def update - @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) - - if @protected_branch.valid? - respond_to do |format| - format.json { render json: @protected_branch, status: :ok } - end - else - respond_to do |format| - format.json { render json: @protected_branch.errors, status: :unprocessable_entity } - end - end + def create_service_class + ::ProtectedBranches::CreateService end - def destroy - @protected_branch.destroy - - respond_to do |format| - format.html { redirect_to_repository_settings(@project) } - format.js { head :ok } - end + def update_service_class + ::ProtectedBranches::UpdateService end - private - - def load_protected_branch - @protected_branch = @project.protected_branches.find(params[:id]) + def load_protected_ref + @protected_ref = @project.protected_branches.find(params[:id]) end - def protected_branch_params + def protected_ref_params params.require(:protected_branch).permit(:name, merge_access_levels_attributes: [:access_level, :id], push_access_levels_attributes: [:access_level, :id]) diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb new file mode 100644 index 00000000000..083a70968e5 --- /dev/null +++ b/app/controllers/projects/protected_refs_controller.rb @@ -0,0 +1,47 @@ +class Projects::ProtectedRefsController < Projects::ApplicationController + include RepositorySettingsRedirect + + # Authorize + before_action :require_non_empty_project + before_action :authorize_admin_project! + before_action :load_protected_ref, only: [:show, :update, :destroy] + + layout "project_settings" + + def index + redirect_to_repository_settings(@project) + end + + def create + protected_ref = create_service_class.new(@project, current_user, protected_ref_params).execute + + unless protected_ref.persisted? + flash[:alert] = protected_ref.errors.full_messages.join(', ').html_safe + end + + redirect_to_repository_settings(@project) + end + + def show + @matching_refs = @protected_ref.matching(project_refs) + end + + def update + @protected_ref = update_service_class.new(@project, current_user, protected_ref_params).execute(@protected_ref) + + if @protected_ref.valid? + render json: @protected_ref, status: :ok + else + render json: @protected_ref.errors, status: :unprocessable_entity + end + end + + def destroy + @protected_ref.destroy + + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.js { head :ok } + end + end +end diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb new file mode 100644 index 00000000000..c61ddf145e6 --- /dev/null +++ b/app/controllers/projects/protected_tags_controller.rb @@ -0,0 +1,23 @@ +class Projects::ProtectedTagsController < Projects::ProtectedRefsController + protected + + def project_refs + @project.repository.tags + end + + def create_service_class + ::ProtectedTags::CreateService + end + + def update_service_class + ::ProtectedTags::UpdateService + end + + def load_protected_ref + @protected_ref = @project.protected_tags.find(params[:id]) + end + + def protected_ref_params + params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id]) + end +end diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index c55b37ae0dd..a02cc477e08 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController return if cached_blob? - if @blob.lfs_pointer? && project.lfs_enabled? + if @blob.stored_externally? send_lfs_object else send_git_blob @repository, @blob diff --git a/app/controllers/projects/registry/application_controller.rb b/app/controllers/projects/registry/application_controller.rb new file mode 100644 index 00000000000..a56f9c58726 --- /dev/null +++ b/app/controllers/projects/registry/application_controller.rb @@ -0,0 +1,16 @@ +module Projects + module Registry + class ApplicationController < Projects::ApplicationController + layout 'project' + + before_action :verify_registry_enabled! + before_action :authorize_read_container_image! + + private + + def verify_registry_enabled! + render_404 unless Gitlab.config.registry.enabled + end + end + end +end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb new file mode 100644 index 00000000000..17f391ba07f --- /dev/null +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -0,0 +1,43 @@ +module Projects + module Registry + class RepositoriesController < ::Projects::Registry::ApplicationController + before_action :authorize_update_container_image!, only: [:destroy] + before_action :ensure_root_container_repository!, only: [:index] + + def index + @images = project.container_repositories + end + + def destroy + if image.destroy + redirect_to project_container_registry_path(@project), + notice: 'Image repository has been removed successfully!' + else + redirect_to project_container_registry_path(@project), + alert: 'Failed to remove image repository!' + end + end + + private + + def image + @image ||= project.container_repositories.find(params[:id]) + end + + ## + # Container repository object for root project path. + # + # Needed to maintain a backwards compatibility. + # + def ensure_root_container_repository! + ContainerRegistry::Path.new(@project.full_path).tap do |path| + break if path.has_repository? + + ContainerRepository.build_from_path(path).tap do |repository| + repository.save! if repository.has_tags? + end + end + end + end + end +end diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb new file mode 100644 index 00000000000..d689cade3ab --- /dev/null +++ b/app/controllers/projects/registry/tags_controller.rb @@ -0,0 +1,28 @@ +module Projects + module Registry + class TagsController < ::Projects::Registry::ApplicationController + before_action :authorize_update_container_image!, only: [:destroy] + + def destroy + if tag.delete + redirect_to project_container_registry_path(@project), + notice: 'Registry tag has been removed successfully!' + else + redirect_to project_container_registry_path(@project), + alert: 'Failed to remove registry tag!' + end + end + + private + + def image + @image ||= project.container_repositories + .find(params[:repository_id]) + end + + def tag + @tag ||= image.tag(params[:id]) + end + end + end +end diff --git a/app/controllers/projects/settings/integrations_controller.rb b/app/controllers/projects/settings/integrations_controller.rb index fb2a4837735..1ff08cce8cb 100644 --- a/app/controllers/projects/settings/integrations_controller.rb +++ b/app/controllers/projects/settings/integrations_controller.rb @@ -5,7 +5,7 @@ module Projects before_action :authorize_admin_project! layout "project_settings" - + def show @hooks = @project.hooks @hook = ProjectHook.new diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index b6ce4abca45..44de8a49593 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -4,46 +4,48 @@ module Projects before_action :authorize_admin_project! def show - @deploy_keys = DeployKeysPresenter - .new(@project, current_user: current_user) + @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user) - define_protected_branches + define_protected_refs end private - def define_protected_branches - load_protected_branches + def define_protected_refs + @protected_branches = @project.protected_branches.order(:name).page(params[:page]) + @protected_tags = @project.protected_tags.order(:name).page(params[:page]) @protected_branch = @project.protected_branches.new + @protected_tag = @project.protected_tags.new load_gon_index end - def load_protected_branches - @protected_branches = @project.protected_branches.order(:name).page(params[:page]) - end - def access_levels_options { - push_access_levels: { - roles: ProtectedBranch::PushAccessLevel.human_access_levels.map do |id, text| - { id: id, text: text, before_divider: true } - end - }, - merge_access_levels: { - roles: ProtectedBranch::MergeAccessLevel.human_access_levels.map do |id, text| - { id: id, text: text, before_divider: true } - end - } + create_access_levels: levels_for_dropdown(ProtectedTag::CreateAccessLevel), + push_access_levels: levels_for_dropdown(ProtectedBranch::PushAccessLevel), + merge_access_levels: levels_for_dropdown(ProtectedBranch::MergeAccessLevel) } end - def open_branches - branches = @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } - { open_branches: branches } + def levels_for_dropdown(access_level_type) + roles = access_level_type.human_access_levels.map do |id, text| + { id: id, text: text, before_divider: true } + end + { roles: roles } + end + + def protectable_tags_for_dropdown + { open_tags: ProtectableDropdown.new(@project, :tags).hash } + end + + def protectable_branches_for_dropdown + { open_branches: ProtectableDropdown.new(@project, :branches).hash } end def load_gon_index - gon.push(open_branches.merge(access_levels_options)) + gon.push(protectable_tags_for_dropdown) + gon.push(protectable_branches_for_dropdown) + gon.push(access_levels_options) end end end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index ea1a97b7cf0..3b2b0d9e502 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -1,7 +1,9 @@ class Projects::SnippetsController < Projects::ApplicationController + include RendersNotes include ToggleAwardEmoji include SpammableActions include SnippetsActions + include RendersBlob before_action :module_enabled before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam] @@ -21,12 +23,11 @@ class Projects::SnippetsController < Projects::ApplicationController respond_to :html def index - @snippets = SnippetsFinder.new.execute( + @snippets = SnippetsFinder.new( current_user, - filter: :by_project, project: @project, scope: params[:scope] - ) + ).execute @snippets = @snippets.page(params[:page]) if @snippets.out_of_range? && @snippets.total_pages != 0 redirect_to namespace_project_snippets_path(page: @snippets.total_pages) @@ -54,9 +55,23 @@ class Projects::SnippetsController < Projects::ApplicationController end def show - @note = @project.notes.new(noteable: @snippet) - @notes = Banzai::NoteRenderer.render(@snippet.notes.fresh, @project, current_user) - @noteable = @snippet + blob = @snippet.blob + override_max_blob_size(blob) + + respond_to do |format| + format.html do + @note = @project.notes.new(noteable: @snippet) + @noteable = @snippet + + @discussions = @snippet.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) + render 'show' + end + + format.json do + render_blob_json(blob) + end + end end def destroy diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index e13f0bde315..afbea3e2b40 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -38,6 +38,8 @@ class Projects::TagsController < Projects::ApplicationController redirect_to namespace_project_tag_path(@project.namespace, @project, @tag.name) else @error = result[:message] + @message = params[:message] + @release_description = params[:release_description] render action: 'new' end end @@ -48,7 +50,7 @@ class Projects::TagsController < Projects::ApplicationController respond_to do |format| if result[:status] == :success format.html do - redirect_to namespace_project_tags_path(@project.namespace, @project) + redirect_to namespace_project_tags_path(@project.namespace, @project), status: 303 end format.js @@ -57,7 +59,7 @@ class Projects::TagsController < Projects::ApplicationController format.html do redirect_to namespace_project_tags_path(@project.namespace, @project), - alert: @error + alert: @error, status: 303 end format.js do diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 637b61504d8..f8eb8e00a5d 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -24,6 +24,8 @@ class Projects::TreeController < Projects::ApplicationController end end + @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit + respond_to do |format| format.html # Disable cache so browser history works @@ -34,21 +36,21 @@ class Projects::TreeController < Projects::ApplicationController def create_dir return render_404 unless @commit_params.values.all? - update_ref + set_start_branch_to_branch_name create_commit(Files::CreateDirService, success_notice: "The directory has been successfully created.", - success_path: namespace_project_tree_path(@project.namespace, @project, File.join(@target_branch, @dir_name)), + success_path: namespace_project_tree_path(@project.namespace, @project, File.join(@branch_name, @dir_name)), failure_path: namespace_project_tree_path(@project.namespace, @project, @ref)) end private def assign_dir_vars - @target_branch = params[:target_branch] + @branch_name = params[:branch_name] @dir_name = File.join(@path, params[:dir_name]) @commit_params = { file_path: @dir_name, - commit_message: params[:commit_message], + commit_message: params[:commit_message] } end end diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index c47198c5eb6..afa56de920b 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -11,7 +11,7 @@ class Projects::TriggersController < Projects::ApplicationController end def create - @trigger = project.triggers.create(create_params.merge(owner: current_user)) + @trigger = project.triggers.create(trigger_params.merge(owner: current_user)) if @trigger.valid? flash[:notice] = 'Trigger was created successfully.' @@ -36,7 +36,7 @@ class Projects::TriggersController < Projects::ApplicationController end def update - if trigger.update(update_params) + if trigger.update(trigger_params) redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), notice: 'Trigger was successfully updated.' else render action: "edit" @@ -67,11 +67,10 @@ class Projects::TriggersController < Projects::ApplicationController @trigger ||= project.triggers.find(params[:id]) || render_404 end - def create_params - params.require(:trigger).permit(:description) - end - - def update_params - params.require(:trigger).permit(:description) + def trigger_params + params.require(:trigger).permit( + :description, + trigger_schedule_attributes: [:id, :active, :cron, :cron_timezone, :ref] + ) end end diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index 61686499bd3..6966a7c5fee 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -1,33 +1,11 @@ class Projects::UploadsController < Projects::ApplicationController + include UploadsActions + skip_before_action :project, :repository, if: -> { action_name == 'show' && image_or_video? } before_action :authorize_upload_file!, only: [:create] - def create - link_to_file = ::Projects::UploadService.new(project, params[:file]). - execute - - respond_to do |format| - if link_to_file - format.json do - render json: { link: link_to_file } - end - else - format.json do - render json: 'Invalid file.', status: :unprocessable_entity - end - end - end - end - - def show - return render_404 if uploader.nil? || !uploader.file.exists? - - disposition = uploader.image_or_video? ? 'inline' : 'attachment' - send_file uploader.file.path, disposition: disposition - end - private def uploader @@ -52,4 +30,10 @@ class Projects::UploadsController < Projects::ApplicationController def image_or_video? uploader && uploader.file.exists? && uploader.image_or_video? end + + def uploader_class + FileUploader + end + + alias_method :model, :project end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index c5e24b9e365..887d18dbec3 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -91,23 +91,20 @@ class Projects::WikisController < Projects::ApplicationController ) end - def preview_markdown - text = params[:text] + def git_access + end - ext = Gitlab::ReferenceExtractor.new(@project, current_user) - ext.analyze(text, author: current_user) + def preview_markdown + result = PreviewMarkdownService.new(@project, current_user, params).execute render json: { - body: view_context.markdown(text, pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]), + body: view_context.markdown(result[:text], pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id]), references: { - users: ext.users.map(&:username) + users: result[:users] } } end - def git_access - end - private def load_project_wiki @@ -115,7 +112,6 @@ class Projects::WikisController < Projects::ApplicationController # Call #wiki to make sure the Wiki Repo is initialized @project_wiki.wiki - @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages.first(15)) rescue ProjectWiki::CouldNotCreateWikiError flash[:notice] = "Could not create Wiki Repository at this time. Please try again later." diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 47f7e0b1b28..544715d62ea 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -216,25 +216,11 @@ class ProjectsController < Projects::ApplicationController } end - def preview_markdown - text = params[:text] - - ext = Gitlab::ReferenceExtractor.new(@project, current_user) - ext.analyze(text, author: current_user) - - render json: { - body: view_context.markdown(text), - references: { - users: ext.users.map(&:username) - } - } - end - def refs branches = BranchesFinder.new(@repository, params).execute.map(&:name) options = { - 'Branches' => branches.take(100), + 'Branches' => branches.take(100) } unless @repository.tag_count.zero? @@ -252,6 +238,18 @@ class ProjectsController < Projects::ApplicationController render json: options.to_json end + def preview_markdown + result = PreviewMarkdownService.new(@project, current_user, params).execute + + render json: { + body: view_context.markdown(result[:text]), + references: { + users: result[:users], + commands: view_context.markdown(result[:commands]) + } + } + end + private # Render project landing depending of which features are available @@ -345,7 +343,11 @@ class ProjectsController < Projects::ApplicationController end def project_view_files? - current_user && current_user.project_view == 'files' + if current_user + current_user.project_view == 'files' + else + project_view_files_allowed? + end end # Override extract_ref from ExtractsPath, which returns the branch and file path @@ -359,4 +361,15 @@ class ProjectsController < Projects::ApplicationController def get_id project.repository.root_ref end + + def project_view_files_allowed? + !project.empty_repo? && can?(current_user, :download_code, project) + end + + def build_canonical_path(project) + params[:namespace_id] = project.namespace.to_param + params[:id] = project.to_param + + url_for(params) + end end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index a49a1f50a81..3ca14dee33c 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -25,12 +25,12 @@ class RegistrationsController < Devise::RegistrationsController end def destroy - Users::DestroyService.new(current_user).execute(current_user) + DeleteUserWorker.perform_async(current_user.id, current_user.id) respond_to do |format| format.html do session.try(:destroy) - redirect_to new_user_session_path, notice: "Account successfully removed." + redirect_to new_user_session_path, notice: "Account scheduled for removal." end end end @@ -60,7 +60,7 @@ class RegistrationsController < Devise::RegistrationsController end def resource - @resource ||= Users::CreateService.new(current_user, sign_up_params).build + @resource ||= Users::BuildService.new(current_user, sign_up_params).execute end def devise_mapping diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 612d69cf557..4a579601785 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -6,45 +6,19 @@ class SearchController < ApplicationController layout 'search' def show - if params[:project_id].present? - @project = Project.find_by(id: params[:project_id]) - @project = nil unless can?(current_user, :download_code, @project) - end + search_service = SearchService.new(current_user, params) - if params[:group_id].present? - @group = Group.find_by(id: params[:group_id]) - @group = nil unless can?(current_user, :read_group, @group) - end + @project = search_service.project + @group = search_service.group return if params[:search].blank? @search_term = params[:search] - @scope = params[:scope] - @show_snippets = params[:snippets].eql? 'true' - - @search_results = - if @project - unless %w(blobs notes issues merge_requests milestones wiki_blobs - commits).include?(@scope) - @scope = 'blobs' - end - - Search::ProjectService.new(@project, current_user, params).execute - elsif @show_snippets - unless %w(snippet_blobs snippet_titles).include?(@scope) - @scope = 'snippet_blobs' - end - - Search::SnippetService.new(current_user, params).execute - else - unless %w(projects issues merge_requests milestones).include?(@scope) - @scope = 'projects' - end - Search::GlobalService.new(current_user, params).execute - end - - @search_objects = @search_results.objects(@scope, params[:page]) + @scope = search_service.scope + @show_snippets = search_service.show_snippets? + @search_results = search_service.search_results + @search_objects = search_service.search_objects check_single_commit_result end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 7d81c96262f..8c6ba4915cd 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController include Devise::Controllers::Rememberable include Recaptcha::ClientHelper - skip_before_action :check_2fa_requirement, only: [:destroy] + skip_before_action :check_two_factor_requirement, only: [:destroy] prepend_before_action :check_initial_setup, only: [:new] prepend_before_action :authenticate_with_two_factor, @@ -35,6 +35,7 @@ class SessionsController < Devise::SessionsController # hide the signed-in notification flash[:notice] = nil log_audit_event(current_user, with: authentication_method) + log_user_activity(current_user) end end @@ -79,7 +80,7 @@ class SessionsController < Devise::SessionsController if request.referer.present? && (params['redirect_to_referer'] == 'yes') referer_uri = URI(request.referer) if referer_uri.host == Gitlab.config.gitlab.host - referer_uri.path + referer_uri.request_uri else request.fullpath end @@ -123,6 +124,10 @@ class SessionsController < Devise::SessionsController for_authentication.security_event end + def log_user_activity(user) + Users::ActivityService.new(user, 'login').execute + end + def load_recaptcha Gitlab::Recaptcha.load_configurations! end diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb new file mode 100644 index 00000000000..f9496787b15 --- /dev/null +++ b/app/controllers/snippets/notes_controller.rb @@ -0,0 +1,35 @@ +class Snippets::NotesController < ApplicationController + include NotesActions + include ToggleAwardEmoji + + skip_before_action :authenticate_user!, only: [:index] + before_action :snippet + before_action :authorize_read_snippet!, only: [:show, :index, :create] + + private + + def note + @note ||= snippet.notes.find(params[:id]) + end + alias_method :awardable, :note + + def project + nil + end + + def snippet + PersonalSnippet.find_by(id: params[:snippet_id]) + end + + def note_params + super.merge(noteable_id: params[:snippet_id]) + end + + def finder_params + params.merge(last_fetched_at: last_fetched_at, target_id: snippet.id, target_type: 'personal_snippet') + end + + def authorize_read_snippet! + return render_404 unless can?(current_user, :read_personal_snippet, snippet) + end +end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index f3fd3da8b20..7445f61195d 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -1,12 +1,14 @@ class SnippetsController < ApplicationController + include RendersNotes include ToggleAwardEmoji include SpammableActions include SnippetsActions + include RendersBlob - before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :download] + before_action :snippet, only: [:show, :edit, :destroy, :update, :raw] # Allow read snippet - before_action :authorize_read_snippet!, only: [:show, :raw, :download] + before_action :authorize_read_snippet!, only: [:show, :raw] # Allow modify snippet before_action :authorize_update_snippet!, only: [:edit, :update] @@ -14,7 +16,7 @@ class SnippetsController < ApplicationController # Allow destroy snippet before_action :authorize_admin_snippet!, only: [:destroy] - skip_before_action :authenticate_user!, only: [:index, :show, :raw, :download] + skip_before_action :authenticate_user!, only: [:index, :show, :raw] layout 'snippets' respond_to :html @@ -25,12 +27,8 @@ class SnippetsController < ApplicationController return render_404 unless @user - @snippets = SnippetsFinder.new.execute(current_user, { - filter: :by_user, - user: @user, - scope: params[:scope] - }) - .page(params[:page]) + @snippets = SnippetsFinder.new(current_user, author: @user, scope: params[:scope]) + .execute.page(params[:page]) render 'index' else @@ -59,6 +57,24 @@ class SnippetsController < ApplicationController end def show + blob = @snippet.blob + override_max_blob_size(blob) + + @note = Note.new(noteable: @snippet) + @noteable = @snippet + + @discussions = @snippet.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) + + respond_to do |format| + format.html do + render 'show' + end + + format.json do + render_blob_json(blob) + end + end end def destroy @@ -69,31 +85,34 @@ class SnippetsController < ApplicationController redirect_to snippets_path end - def download - send_data( - convert_line_endings(@snippet.content), - type: 'text/plain; charset=utf-8', - filename: @snippet.sanitized_file_name - ) + def preview_markdown + result = PreviewMarkdownService.new(@project, current_user, params).execute + + render json: { + body: view_context.markdown(result[:text], skip_project_check: true), + references: { + users: result[:users] + } + } end protected def snippet - @snippet ||= if current_user - PersonalSnippet.where("author_id = ? OR visibility_level IN (?)", - current_user.id, - [Snippet::PUBLIC, Snippet::INTERNAL]). - find(params[:id]) - else - PersonalSnippet.find(params[:id]) - end + @snippet ||= PersonalSnippet.find_by(id: params[:id]) end + alias_method :awardable, :snippet alias_method :spammable, :snippet def authorize_read_snippet! - authenticate_user! unless can?(current_user, :read_personal_snippet, @snippet) + return if can?(current_user, :read_personal_snippet, @snippet) + + if current_user + render_404 + else + authenticate_user! + end end def authorize_update_snippet! diff --git a/app/controllers/unicorn_test_controller.rb b/app/controllers/unicorn_test_controller.rb new file mode 100644 index 00000000000..b7a1a046be0 --- /dev/null +++ b/app/controllers/unicorn_test_controller.rb @@ -0,0 +1,12 @@ +if Rails.env.test? + class UnicornTestController < ActionController::Base + def pid + render plain: Process.pid.to_s + end + + def kill + Process.kill(params[:signal], Process.pid) + render plain: 'Bye!' + end + end +end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index f1bfd574f04..eef53730291 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -1,50 +1,45 @@ class UploadsController < ApplicationController - skip_before_action :authenticate_user! - before_action :find_model, :authorize_access! - - def show - uploader = @model.send(upload_mount) - - unless uploader.file_storage? - return redirect_to uploader.url - end + include UploadsActions - unless uploader.file && uploader.file.exists? - return render_404 - end - - disposition = uploader.image? ? 'inline' : 'attachment' - - expires_in 0.seconds, must_revalidate: true, private: true - send_file uploader.file.path, disposition: disposition - end + skip_before_action :authenticate_user! + before_action :find_model + before_action :authorize_access!, only: [:show] + before_action :authorize_create_access!, only: [:create] private def find_model - unless upload_model && upload_mount - return render_404 - end + return render_404 unless upload_model && upload_mount @model = upload_model.find(params[:id]) end def authorize_access! authorized = - case @model - when Project - can?(current_user, :read_project, @model) - when Group - can?(current_user, :read_group, @model) + case model when Note - can?(current_user, :read_project, @model.project) - else - # No authentication required for user avatars. + can?(current_user, :read_project, model.project) + when User + true + when Appearance true + else + permission = "read_#{model.class.to_s.underscore}".to_sym + + can?(current_user, permission, model) end - return if authorized + render_unauthorized unless authorized + end + + def authorize_create_access! + # for now we support only personal snippets comments + authorized = can?(current_user, :comment_personal_snippet, model) + render_unauthorized unless authorized + end + + def render_unauthorized if current_user render_404 else @@ -58,17 +53,44 @@ class UploadsController < ApplicationController "project" => Project, "note" => Note, "group" => Group, - "appearance" => Appearance + "appearance" => Appearance, + "personal_snippet" => PersonalSnippet } upload_models[params[:model]] end def upload_mount + return true unless params[:mounted_as] + upload_mounts = %w(avatar attachment file logo header_logo) if upload_mounts.include?(params[:mounted_as]) params[:mounted_as] end end + + def uploader + return @uploader if defined?(@uploader) + + if model.is_a?(PersonalSnippet) + @uploader = PersonalFileUploader.new(model, params[:secret]) + + @uploader.retrieve_from_store!(params[:filename]) + else + @uploader = @model.send(upload_mount) + + redirect_to @uploader.url unless @uploader.file_storage? + end + + @uploader + end + + def uploader_class + PersonalFileUploader + end + + def model + @model ||= find_model + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2683614d2e8..19fc1e5de49 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,7 +1,8 @@ class UsersController < ApplicationController + include RoutableActions + skip_before_action :authenticate_user! before_action :user, except: [:exists] - before_action :authorize_read_user!, only: [:show] def show respond_to do |format| @@ -91,12 +92,8 @@ class UsersController < ApplicationController private - def authorize_read_user! - render_404 unless can?(current_user, :read_user, user) - end - def user - @user ||= User.find_by_username!(params[:username]) + @user ||= find_routable!(User, params[:username]) end def contributed_projects @@ -131,15 +128,18 @@ class UsersController < ApplicationController end def load_snippets - @snippets = SnippetsFinder.new.execute( + @snippets = SnippetsFinder.new( current_user, - filter: :by_user, - user: user, + author: user, scope: params[:scope] - ).page(params[:page]) + ).execute.page(params[:page]) end def projects_for_current_user - ProjectsFinder.new.execute(current_user) + ProjectsFinder.new(current_user: current_user).execute + end + + def build_canonical_path(user) + url_for(params.merge(username: user.to_param)) end end diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index 3b9a421b118..f043c38c6f9 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -1,42 +1,63 @@ -class GroupProjectsFinder < UnionFinder - def initialize(group, options = {}) +# GroupProjectsFinder +# +# Used to filter Projects by set of params +# +# Arguments: +# current_user - which user use +# project_ids_relation: int[] - project ids to use +# group +# options: +# only_owned: boolean +# only_shared: boolean +# params: +# sort: string +# visibility_level: int +# tags: string[] +# personal: boolean +# search: string +# non_archived: boolean +# +class GroupProjectsFinder < ProjectsFinder + attr_reader :group, :options + + def initialize(group:, params: {}, options: {}, current_user: nil, project_ids_relation: nil) + super(params: params, current_user: current_user, project_ids_relation: project_ids_relation) @group = group @options = options end - def execute(current_user = nil) - segments = group_projects(current_user) - find_union(segments, Project) - end - private - def group_projects(current_user) - only_owned = @options.fetch(:only_owned, false) - only_shared = @options.fetch(:only_shared, false) + def init_collection + only_owned = options.fetch(:only_owned, false) + only_shared = options.fetch(:only_shared, false) projects = [] if current_user - if @group.users.include?(current_user) - projects << @group.projects unless only_shared - projects << @group.shared_projects unless only_owned + if group.users.include?(current_user) + projects << group.projects unless only_shared + projects << group.shared_projects unless only_owned else unless only_shared - projects << @group.projects.visible_to_user(current_user) - projects << @group.projects.public_to_user(current_user) + projects << group.projects.visible_to_user(current_user) + projects << group.projects.public_to_user(current_user) end unless only_owned - projects << @group.shared_projects.visible_to_user(current_user) - projects << @group.shared_projects.public_to_user(current_user) + projects << group.shared_projects.visible_to_user(current_user) + projects << group.shared_projects.public_to_user(current_user) end end else - projects << @group.projects.public_only unless only_shared - projects << @group.shared_projects.public_only unless only_owned + projects << group.projects.public_only unless only_shared + projects << group.shared_projects.public_only unless only_owned end projects end + + def union(items) + find_union(items, Project) + end end diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index d932a17883f..f68610e197c 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -1,13 +1,19 @@ class GroupsFinder < UnionFinder - def execute(current_user = nil) - segments = all_groups(current_user) + def initialize(current_user = nil, params = {}) + @current_user = current_user + @params = params + end - find_union(segments, Group).with_route.order_id_desc + def execute + groups = find_union(all_groups, Group).with_route.order_id_desc + by_parent(groups) end private - def all_groups(current_user) + attr_reader :current_user, :params + + def all_groups groups = [] groups << current_user.authorized_groups if current_user @@ -15,4 +21,10 @@ class GroupsFinder < UnionFinder groups end + + def by_parent(groups) + return groups unless params[:parent] + + groups.where(parent: params[:parent]) + end end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index f7ebb1807d7..957ad875858 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -116,9 +116,9 @@ class IssuableFinder if current_user && params[:authorized_only].presence && !current_user_related? current_user.authorized_projects elsif group - GroupProjectsFinder.new(group).execute(current_user) + GroupProjectsFinder.new(group: group, current_user: current_user).execute else - projects_finder.execute(current_user, item_project_ids(items)) + ProjectsFinder.new(current_user: current_user, project_ids_relation: item_project_ids(items)).execute end @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) @@ -231,7 +231,7 @@ class IssuableFinder when 'created-by-me', 'authored' items.where(author_id: current_user.id) when 'assigned-to-me' - items.where(assignee_id: current_user.id) + items.assigned_to(current_user) else items end @@ -405,8 +405,4 @@ class IssuableFinder def current_user_related? params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me' end - - def projects_finder - @projects_finder ||= ProjectsFinder.new - end end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 08713272947..b4c074bc69c 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -9,7 +9,7 @@ # state: 'open' or 'closed' or 'all' # group_id: integer # project_id: integer -# milestone_id: integer +# milestone_title: string # assignee_id: integer # search: string # label_name: string @@ -26,17 +26,28 @@ class IssuesFinder < IssuableFinder IssuesFinder.not_restricted_by_confidentiality(current_user) end + def by_assignee(items) + if assignee + items.assigned_to(assignee) + elsif no_assignee? + items.unassigned + elsif assignee_id? || assignee_username? # assignee not found + items.none + else + items + end + end + def self.not_restricted_by_confidentiality(user) - return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank? + return Issue.where('issues.confidential IS NOT TRUE') if user.blank? return Issue.all if user.admin? Issue.where(' - issues.confidential IS NULL - OR issues.confidential IS FALSE + issues.confidential IS NOT TRUE OR (issues.confidential = TRUE AND (issues.author_id = :user_id - OR issues.assignee_id = :user_id + OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id) OR issues.project_id IN(:project_ids)))', user_id: user.id, project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) diff --git a/app/finders/labels_finder.rb b/app/finders/labels_finder.rb index e52083f86e4..042d792dada 100644 --- a/app/finders/labels_finder.rb +++ b/app/finders/labels_finder.rb @@ -83,7 +83,7 @@ class LabelsFinder < UnionFinder def projects return @projects if defined?(@projects) - @projects = skip_authorization ? Project.all : ProjectsFinder.new.execute(current_user) + @projects = skip_authorization ? Project.all : ProjectsFinder.new(current_user: current_user).execute @projects = @projects.in_namespace(params[:group_id]) if group? @projects = @projects.where(id: params[:project_ids]) if projects? @projects = @projects.reorder(nil) diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 1eec45d9cb5..2fc34f186ad 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -6,10 +6,10 @@ # current_user - which user use # params: # scope: 'created-by-me' or 'assigned-to-me' or 'all' -# state: 'open' or 'closed' or 'all' +# state: 'open', 'closed', 'merged', or 'all' # group_id: integer # project_id: integer -# milestone_id: integer +# milestone_title: string # assignee_id: integer # search: string # label_name: string diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 6630c6384f2..02eb983bf55 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -17,29 +17,46 @@ class NotesFinder @project = project @current_user = current_user @params = params - init_collection end def execute - @notes = since_fetch_at(@params[:last_fetched_at]) if @params[:last_fetched_at] - @notes + notes = init_collection + notes = since_fetch_at(notes) + notes.fresh end - private + def target + return @target if defined?(@target) - def init_collection - @notes = - if @params[:target_id] - on_target(@params[:target_type], @params[:target_id]) + target_type = @params[:target_type] + target_id = @params[:target_id] + + return @target = nil unless target_type && target_id + + @target = + if target_type == "commit" + if Ability.allowed?(@current_user, :download_code, @project) + @project.commit(target_id) + end else - notes_of_any_type + noteables_for_type(target_type).find(target_id) end end + private + + def init_collection + if target + notes_on_target + else + notes_of_any_type + end + end + def notes_of_any_type types = %w(commit issue merge_request snippet) note_relations = types.map { |t| notes_for_type(t) } - note_relations.map!{ |notes| search(@params[:search], notes) } if @params[:search] + note_relations.map! { |notes| search(notes) } UnionFinder.new.find_union(note_relations, Note) end @@ -50,7 +67,9 @@ class NotesFinder when "merge_request" MergeRequestsFinder.new(@current_user, project_id: @project.id).execute when "snippet", "project_snippet" - SnippetsFinder.new.execute(@current_user, filter: :by_project, project: @project) + SnippetsFinder.new(@current_user, project: @project).execute + when "personal_snippet" + PersonalSnippet.all else raise 'invalid target_type' end @@ -69,17 +88,11 @@ class NotesFinder end end - def on_target(target_type, target_id) - if target_type == "commit" - notes_for_type('commit').for_commit_id(target_id) + def notes_on_target + if target.respond_to?(:related_notes) + target.related_notes else - target = noteables_for_type(target_type).find(target_id) - - if target.respond_to?(:related_notes) - target.related_notes - else - target.notes - end + target.notes end end @@ -87,17 +100,21 @@ class NotesFinder # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # - def search(query, notes_relation = @notes) + def search(notes) + query = @params[:search] + return notes unless query + pattern = "%#{query}%" - notes_relation.where(Note.arel_table[:note].matches(pattern)) + notes.where(Note.arel_table[:note].matches(pattern)) end # Notes changed since last fetch # Uses overlapping intervals to avoid worrying about race conditions - def since_fetch_at(fetch_time) + def since_fetch_at(notes) + return notes unless @params[:last_fetched_at] + # Default to 0 to remain compatible with old clients last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i) - - @notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh + notes.updated_after(last_fetched_at - FETCH_OVERLAP) end end diff --git a/app/finders/pipeline_schedules_finder.rb b/app/finders/pipeline_schedules_finder.rb new file mode 100644 index 00000000000..2ac4289fbbe --- /dev/null +++ b/app/finders/pipeline_schedules_finder.rb @@ -0,0 +1,22 @@ +class PipelineSchedulesFinder + attr_reader :project, :pipeline_schedules + + def initialize(project) + @project = project + @pipeline_schedules = project.pipeline_schedules + end + + def execute(scope: nil) + scoped_schedules = + case scope + when 'active' + pipeline_schedules.active + when 'inactive' + pipeline_schedules.inactive + else + pipeline_schedules + end + + scoped_schedules.order(id: :desc) + end +end diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index a9172f6767f..f187a3b61fe 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -1,29 +1,23 @@ class PipelinesFinder - attr_reader :project, :pipelines + attr_reader :project, :pipelines, :params - def initialize(project) + ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze + + def initialize(project, params = {}) @project = project @pipelines = project.pipelines + @params = params end - def execute(scope: nil) - scoped_pipelines = - case scope - when 'running' - pipelines.running - when 'pending' - pipelines.pending - when 'finished' - pipelines.finished - when 'branches' - from_ids(ids_for_ref(branches)) - when 'tags' - from_ids(ids_for_ref(tags)) - else - pipelines - end - - scoped_pipelines.order(id: :desc) + def execute + items = pipelines + items = by_scope(items) + items = by_status(items) + items = by_ref(items) + items = by_name(items) + items = by_username(items) + items = by_yaml_errors(items) + sort_items(items) end private @@ -43,4 +37,78 @@ class PipelinesFinder def tags project.repository.tag_names end + + def by_scope(items) + case params[:scope] + when 'running' + items.running + when 'pending' + items.pending + when 'finished' + items.finished + when 'branches' + from_ids(ids_for_ref(branches)) + when 'tags' + from_ids(ids_for_ref(tags)) + else + items + end + end + + def by_status(items) + return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status]) + + items.where(status: params[:status]) + end + + def by_ref(items) + if params[:ref].present? + items.where(ref: params[:ref]) + else + items + end + end + + def by_name(items) + if params[:name].present? + items.joins(:user).where(users: { name: params[:name] }) + else + items + end + end + + def by_username(items) + if params[:username].present? + items.joins(:user).where(users: { username: params[:username] }) + else + items + end + end + + def by_yaml_errors(items) + case Gitlab::Utils.to_boolean(params[:yaml_errors]) + when true + items.where("yaml_errors IS NOT NULL") + when false + items.where("yaml_errors IS NULL") + else + items + end + end + + def sort_items(items) + order_by = if ALLOWED_INDEXED_COLUMNS.include?(params[:order_by]) + params[:order_by] + else + :id + end + + sort = if params[:sort] =~ /\A(ASC|DESC)\z/i + params[:sort] + else + :desc + end + + items.order(order_by => sort) + end end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 18ec45f300d..f6d8226bf3f 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -1,19 +1,93 @@ +# ProjectsFinder +# +# Used to filter Projects by set of params +# +# Arguments: +# current_user - which user use +# project_ids_relation: int[] - project ids to use +# params: +# trending: boolean +# non_public: boolean +# starred: boolean +# sort: string +# visibility_level: int +# tags: string[] +# personal: boolean +# search: string +# non_archived: boolean +# class ProjectsFinder < UnionFinder - def execute(current_user = nil, project_ids_relation = nil) - segments = all_projects(current_user) - segments.map! { |s| s.where(id: project_ids_relation) } if project_ids_relation + attr_accessor :params + attr_reader :current_user, :project_ids_relation - find_union(segments, Project).with_route + def initialize(params: {}, current_user: nil, project_ids_relation: nil) + @params = params + @current_user = current_user + @project_ids_relation = project_ids_relation + end + + def execute + items = init_collection + items = by_ids(items) + items = union(items) + items = by_personal(items) + items = by_visibilty_level(items) + items = by_tags(items) + items = by_search(items) + items = by_archived(items) + sort(items) end private - def all_projects(current_user) + def init_collection projects = [] - projects << current_user.authorized_projects if current_user - projects << Project.unscoped.public_to_user(current_user) + if params[:trending].present? + projects << Project.trending + elsif params[:starred].present? && current_user + projects << current_user.viewable_starred_projects + else + projects << current_user.authorized_projects if current_user + projects << Project.unscoped.public_to_user(current_user) unless params[:non_public].present? + end projects end + + def by_ids(items) + project_ids_relation ? items.map { |item| item.where(id: project_ids_relation) } : items + end + + def union(items) + find_union(items, Project).with_route + end + + def by_personal(items) + (params[:personal].present? && current_user) ? items.personal(current_user) : items + end + + def by_visibilty_level(items) + params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items + end + + def by_tags(items) + params[:tag].present? ? items.tagged_with(params[:tag]) : items + end + + def by_search(items) + params[:search] ||= params[:name] + params[:search].present? ? items.search(params[:search]) : items + end + + def sort(items) + params[:sort].present? ? items.sort(params[:sort]) : items + end + + def by_archived(projects) + # Back-compatibility with the places where `params[:archived]` can be set explicitly to `false` + params[:non_archived] = !Gitlab::Utils.to_boolean(params[:archived]) if params.key?(:archived) + + params[:non_archived] ? projects.non_archived : projects + end end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index da6e6e87a6f..c04f61de79c 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -1,66 +1,74 @@ -class SnippetsFinder - def execute(current_user, params = {}) - filter = params[:filter] - user = params.fetch(:user, current_user) - - case filter - when :all then - snippets(current_user).fresh - when :public then - Snippet.are_public.fresh - when :by_user then - by_user(current_user, user, params[:scope]) - when :by_project - by_project(current_user, params[:project], params[:scope]) - end +class SnippetsFinder < UnionFinder + attr_accessor :current_user, :params + + def initialize(current_user, params = {}) + @current_user = current_user + @params = params + end + + def execute + items = init_collection + items = by_project(items) + items = by_author(items) + items = by_visibility(items) + + items.fresh end private - def snippets(current_user) - if current_user - Snippet.public_and_internal - else - # Not authenticated - # - # Return only: - # public snippets - Snippet.are_public - end + def init_collection + items = Snippet.all + + accessible(items) end - def by_user(current_user, user, scope) - snippets = user.snippets.fresh + def accessible(items) + segments = [] + segments << items.public_to_user(current_user) + segments << authorized_to_user(items) if current_user - if current_user - include_private = user == current_user - by_scope(snippets, scope, include_private) - else - snippets.are_public - end + find_union(segments, Snippet) end - def by_project(current_user, project, scope) - snippets = project.snippets.fresh + def authorized_to_user(items) + items.where( + 'author_id = :author_id + OR project_id IN (:project_ids)', + author_id: current_user.id, + project_ids: current_user.authorized_projects.select(:id)) + end - if current_user - include_private = project.team.member?(current_user) || current_user.admin? - by_scope(snippets, scope, include_private) - else - snippets.are_public - end + def by_visibility(items) + visibility = params[:visibility] || visibility_from_scope + + return items unless visibility + + items.where(visibility_level: visibility) + end + + def by_author(items) + return items unless params[:author] + + items.where(author_id: params[:author].id) + end + + def by_project(items) + return items unless params[:project] + + items.where(project_id: params[:project].id) end - def by_scope(snippets, scope = nil, include_private = false) - case scope.to_s + def visibility_from_scope + case params[:scope].to_s when 'are_private' - include_private ? snippets.are_private : Snippet.none + Snippet::PRIVATE when 'are_internal' - snippets.are_internal + Snippet::INTERNAL when 'are_public' - snippets.are_public + Snippet::PUBLIC else - include_private ? snippets : snippets.public_and_internal + nil end end end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index b7f091f334d..dc13386184e 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -95,7 +95,7 @@ class TodosFinder def projects(items) item_project_ids = items.reorder(nil).select(:project_id) - ProjectsFinder.new.execute(current_user, item_project_ids) + ProjectsFinder.new(current_user: current_user, project_ids_relation: item_project_ids).execute end def type? diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb new file mode 100644 index 00000000000..dbd50d1db7c --- /dev/null +++ b/app/finders/users_finder.rb @@ -0,0 +1,74 @@ +# UsersFinder +# +# Used to filter users by set of params +# +# Arguments: +# current_user - which user use +# params: +# username: string +# extern_uid: string +# provider: string +# search: string +# active: boolean +# blocked: boolean +# external: boolean +# +class UsersFinder + attr_accessor :current_user, :params + + def initialize(current_user, params = {}) + @current_user = current_user + @params = params + end + + def execute + users = User.all + users = by_username(users) + users = by_search(users) + users = by_blocked(users) + users = by_active(users) + users = by_external_identity(users) + users = by_external(users) + + users + end + + private + + def by_username(users) + return users unless params[:username] + + users.where(username: params[:username]) + end + + def by_search(users) + return users unless params[:search].present? + + users.search(params[:search]) + end + + def by_blocked(users) + return users unless params[:blocked] + + users.blocked + end + + def by_active(users) + return users unless params[:active] + + users.active + end + + def by_external_identity(users) + return users unless current_user.admin? && params[:extern_uid] && params[:provider] + + users.joins(:identities).merge(Identity.with_extern_uid(params[:provider], params[:extern_uid])) + end + + def by_external(users) + return users = users.where.not(external: true) unless current_user.admin? + return users unless params[:external] + + users.external + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e5b811f3300..e5e64650708 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -77,7 +77,7 @@ module ApplicationHelper end if user - user.avatar_url(size) || default_avatar + user.avatar_url(size: size) || default_avatar else gravatar_icon(user_or_email, size, scale) end @@ -180,54 +180,22 @@ module ApplicationHelper element end - def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false) - return if object.updated_at == object.created_at + def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) + return if object.last_edited_at == object.created_at || object.last_edited_at.blank? - content_tag :small, class: "edited-text" do - output = content_tag(:span, "Edited ") - output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) + content_tag :small, class: 'edited-text' do + output = content_tag(:span, 'Edited ') + output << time_ago_with_tooltip(object.last_edited_at, placement: placement, html_class: html_class) - if include_author && object.updated_by && object.updated_by != object.author - output << content_tag(:span, " by ") - output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) + if !exclude_author && object.last_edited_by + output << content_tag(:span, ' by ') + output << link_to_member(object.project, object.last_edited_by, avatar: false, author_class: nil) end output end end - def render_markup(file_name, file_content) - if gitlab_markdown?(file_name) - Hamlit::RailsHelpers.preserve(markdown(file_content)) - elsif asciidoc?(file_name) - asciidoc(file_content) - elsif plain?(file_name) - content_tag :pre, class: 'plain-readme' do - file_content - end - else - other_markup(file_name, file_content) - end - rescue RuntimeError - simple_format(file_content) - end - - def plain?(filename) - Gitlab::MarkupHelper.plain?(filename) - end - - def markup?(filename) - Gitlab::MarkupHelper.markup?(filename) - end - - def gitlab_markdown?(filename) - Gitlab::MarkupHelper.gitlab_markdown?(filename) - end - - def asciidoc?(filename) - Gitlab::MarkupHelper.asciidoc?(filename) - end - def promo_host 'about.gitlab.com' end @@ -310,4 +278,22 @@ module ApplicationHelper def show_user_callout? cookies[:user_callout_dismissed] == 'true' end + + def linkedin_url(user) + name = user.linkedin + if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/} + name + else + "https://www.linkedin.com/in/#{name}" + end + end + + def twitter_url(user) + name = user.twitter + if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/} + name + else + "https://www.twitter.com/#{name}" + end + end end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 1ee6c1d3afa..9c71d6c7f4c 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -64,16 +64,8 @@ module AuthHelper current_user.identities.exists?(provider: provider.to_s) end - def two_factor_skippable? - current_application_settings.require_two_factor_authentication && - !current_user.two_factor_enabled? && - current_application_settings.two_factor_grace_period && - !two_factor_grace_period_expired? - end - - def two_factor_grace_period_expired? - current_user.otp_grace_period_started_at && - (current_user.otp_grace_period_started_at + current_application_settings.two_factor_grace_period.hours) < Time.current + def unlink_allowed?(provider) + %w(saml cas3).exclude?(provider.to_s) end extend self diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb index 167b09e678f..024cf38469e 100644 --- a/app/helpers/award_emoji_helper.rb +++ b/app/helpers/award_emoji_helper.rb @@ -1,10 +1,14 @@ module AwardEmojiHelper def toggle_award_url(awardable) - return url_for([:toggle_award_emoji, awardable]) unless @project + return url_for([:toggle_award_emoji, awardable]) unless @project || awardable.is_a?(Note) if awardable.is_a?(Note) # We render a list of notes very frequently and calling the specific method is a lot faster than the generic one (4.5x) - toggle_award_emoji_namespace_project_note_url(@project.namespace, @project, awardable.id) + if awardable.for_personal_snippet? + toggle_award_emoji_snippet_note_path(awardable.noteable, awardable) + else + toggle_award_emoji_namespace_project_note_path(@project.namespace, @project, awardable.id) + end else url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable]) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 8631bc54509..622e14e21ff 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -8,31 +8,36 @@ module BlobHelper %w(credits changelog news copying copyright license authors) end - def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) - return unless current_user + def edit_path(project = @project, ref = @ref, path = @path, options = {}) + namespace_project_edit_blob_path(project.namespace, project, + tree_join(ref, path), + options[:link_opts]) + end + def edit_blob_link(project = @project, ref = @ref, path = @path, options = {}) blob = options.delete(:blob) blob ||= project.repository.blob_at(ref, path) rescue nil - return unless blob + return unless blob && blob.readable_text? - edit_path = namespace_project_edit_blob_path(project.namespace, project, - tree_join(ref, path), - options[:link_opts]) + common_classes = "btn js-edit-blob #{options[:extra_class]}" if !on_top_of_branch?(project, ref) - button_tag "Edit", class: "btn disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' } - elsif can_edit_blob?(blob, project, ref) - link_to "Edit", edit_path, class: 'btn btn-sm' - elsif can?(current_user, :fork_project, project) + button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' } + # This condition applies to anonymous or users who can edit directly + elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) + link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" + elsif current_user && can?(current_user, :fork_project, project) continue_params = { - to: edit_path, + to: edit_path(project, ref, path, options), notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params) - link_to "Edit", fork_path, class: 'btn', method: :post + button_tag 'Edit', + class: "#{common_classes} js-edit-blob-link-fork-toggler", + data: { action: 'edit', fork_path: fork_path } end end @@ -43,21 +48,25 @@ module BlobHelper return unless blob + common_classes = "btn btn-#{btn_class}" + if !on_top_of_branch?(project, ref) - button_tag label, class: "btn btn-#{btn_class} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' } - elsif blob.lfs_pointer? - button_tag label, class: "btn btn-#{btn_class} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' } - elsif can_edit_blob?(blob, project, ref) - button_tag label, class: "btn btn-#{btn_class}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' + button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' } + elsif blob.stored_externally? + button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' } + elsif can_modify_blob?(blob, project, ref) + button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' elsif can?(current_user, :fork_project, project) continue_params = { - to: request.fullpath, + to: request.fullpath, notice: edit_in_new_fork_notice + " Try to #{action} this file again.", notice_now: edit_in_new_fork_notice_now } fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params) - link_to label, fork_path, class: "btn btn-#{btn_class}", method: :post + button_tag label, + class: "#{common_classes} js-edit-blob-link-fork-toggler", + data: { action: action, fork_path: fork_path } end end @@ -85,8 +94,8 @@ module BlobHelper ) end - def can_edit_blob?(blob, project = @project, ref = @ref) - !blob.lfs_pointer? && can_edit_tree?(project, ref) + def can_modify_blob?(blob, project = @project, ref = @ref) + !blob.stored_externally? && can_edit_tree?(project, ref) end def leave_edit_message @@ -97,7 +106,7 @@ module BlobHelper if Gitlab::MarkupHelper.previewable?(filename) 'Preview' else - 'Preview Changes' + 'Preview changes' end end @@ -109,24 +118,25 @@ module BlobHelper icon("#{file_type_icon_class('file', mode, name)} fw") end - def blob_text_viewable?(blob) - blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw? - end - - def blob_size(blob) - if blob.lfs_pointer? - blob.lfs_size - else - blob.size + def blob_raw_url + if @build && @entry + raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + elsif @snippet + if @snippet.project_id + raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) + else + raw_snippet_path(@snippet) + end + elsif @blob + namespace_project_raw_path(@project.namespace, @project, @id) end end # SVGs can contain malicious JavaScript; only include whitelisted # elements and attributes. Note that this whitelist is by no means complete # and may omit some elements. - def sanitize_svg(blob) - blob.data = Gitlab::Sanitizers::SVG.clean(blob.data) - blob + def sanitize_svg_data(data) + Gitlab::Sanitizers::SVG.clean(data) end # If we blindly set the 'real' content type when serving a Git blob we @@ -205,16 +215,82 @@ module BlobHelper end def copy_file_path_button(file_path) - clipboard_button(clipboard_text: file_path, class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') + clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') + end + + def copy_blob_source_button(blob) + return unless blob.rendered_as_text?(ignore_errors: false) + + clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: "Copy source to clipboard") + end + + def open_raw_blob_button(blob) + return if blob.empty? + + if blob.raw_binary? || blob.stored_externally? + icon = icon('download') + title = 'Download' + else + icon = icon('file-code-o') + title = 'Open raw' + end + + link_to icon, blob_raw_url, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } + end + + def blob_render_error_reason(viewer) + case viewer.render_error + when :too_large + max_size = + if viewer.can_override_max_size? + viewer.overridable_max_size + else + viewer.max_size + end + "it is larger than #{number_to_human_size(max_size)}" + when :server_side_but_stored_externally + case viewer.blob.external_storage + when :lfs + 'it is stored in LFS' + when :build_artifact + 'it is stored as a job artifact' + else + 'it is stored externally' + end + end end - def copy_blob_content_button(blob) - return if markup?(blob.name) + def blob_render_error_options(viewer) + error = viewer.render_error + options = [] - clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard") + if error == :too_large && viewer.can_override_max_size? + options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil))) + end + + # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error, + # so don't bother switching. + if viewer.rich? && viewer.blob.rendered_as_text? && error != :server_side_but_stored_externally + options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' }) + end + + options << link_to('download it', blob_raw_url, target: '_blank', rel: 'noopener noreferrer') + + options end - def open_raw_file_button(path) - link_to icon('file-code-o'), path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw', data: { container: 'body' } + def contribution_options(project) + options = [] + + if can?(current_user, :create_issue, project) + options << link_to("submit an issue", new_namespace_project_issue_path(project.namespace, project)) + end + + merge_project = can?(current_user, :create_merge_request, project) ? project : (current_user && current_user.fork_of(project)) + if merge_project + options << link_to("create a merge request", new_namespace_project_merge_request_path(project.namespace, project)) + end + + options end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index f43827da446..e2df52e3833 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -9,6 +9,7 @@ module BoardsHelper issue_link_base: namespace_project_issues_path(@project.namespace, @project), root_path: root_path, bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project), + default_avatar: image_path(default_avatar) } end end diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index 3fc85dc6b2b..59519c1335b 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -1,14 +1,4 @@ module BranchesHelper - def can_remove_branch?(project, branch_name) - if project.protected_branch? branch_name - false - elsif branch_name == project.repository.root_ref - false - else - can?(current_user, :push_code, project) - end - end - def filter_branches_path(options = {}) exist_opts = { search: params[:search], @@ -29,4 +19,8 @@ module BranchesHelper def project_branches options_for_select(@project.repository.branch_names, @project.default_branch) end + + def protected_branch?(project, branch) + ProtectedBranch.protected?(project, branch.name) + end end diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 2fcb7a59fc3..2eb2c6c7389 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -1,4 +1,16 @@ module BuildsHelper + def build_summary(build, skip: false) + if build.has_trace? + if skip + link_to "View job trace", pipeline_build_url(build.pipeline, build) + else + build.trace.html(last_lines: 10).html_safe + end + else + "No job trace" + end + end + def sidebar_build_class(build, current_build) build_class = '' build_class += ' active' if build.id === current_build.id diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 0b30471f2ae..206d0753f08 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -1,29 +1,51 @@ module ButtonHelper # Output a "Copy to Clipboard" button # - # data - Data attributes passed to `content_tag` + # data - Data attributes passed to `content_tag` (default: {}): + # :text - Text to copy (optional) + # :gfm - GitLab Flavored Markdown to copy, if different from `text` (optional) + # :target - Selector for target element to copy from (optional) # # Examples: # # # Define the clipboard's text - # clipboard_button(clipboard_text: "Foo") + # clipboard_button(text: "Foo") # # => "<button class='...' data-clipboard-text='Foo'>...</button>" # # # Define the target element - # clipboard_button(clipboard_target: "div#foo") + # clipboard_button(target: "div#foo") # # => "<button class='...' data-clipboard-target='div#foo'>...</button>" # # See http://clipboardjs.com/#usage def clipboard_button(data = {}) css_class = data[:class] || 'btn-clipboard btn-transparent' title = data[:title] || 'Copy to clipboard' + + # This supports code in app/assets/javascripts/copy_to_clipboard.js that + # works around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM. + if text = data.delete(:text) + data[:clipboard_text] = + if gfm = data.delete(:gfm) + { text: text, gfm: gfm } + else + text + end + end + + target = data.delete(:target) + data[:clipboard_target] = target if target + data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data) + content_tag :button, icon('clipboard', 'aria-hidden': 'true'), class: "btn #{css_class}", data: data, type: :button, - title: title + title: title, + aria: { + label: title + } end def http_clone_button(project, placement = 'right', append_link: true) diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 2de9e0de310..32b1e7822af 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -1,10 +1,16 @@ +## +# DEPRECATED +# +# These helpers are deprecated in favor of detailed CI/CD statuses. +# +# See 'detailed_status?` method and `Gitlab::Ci::Status` module. +# module CiStatusHelper def ci_status_path(pipeline) project = pipeline.project namespace_project_pipeline_path(project.namespace, project, pipeline) end - # Is used by Commit and Merge Request Widget def ci_label_for_status(status) if detailed_status?(status) return status.label @@ -22,6 +28,23 @@ module CiStatusHelper end end + def ci_text_for_status(status) + if detailed_status?(status) + return status.text + end + + case status + when 'success' + 'passed' + when 'success_with_warnings' + 'passed' + when 'manual' + 'blocked' + else + status + end + end + def ci_status_for_statuseable(subject) status = subject.try(:status) || 'not found' status.humanize diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index cef624430da..d59d51905a6 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -74,12 +74,8 @@ module CommitsHelper # Returns the sorted alphabetically links to branches, separated by a comma def commit_branches_links(project, branches) branches.sort.map do |branch| - link_to( - namespace_project_tree_path(project.namespace, project, branch) - ) do - content_tag :span, class: 'label label-gray' do - icon('code-fork') + ' ' + branch - end + link_to(project_ref_path(project, branch), class: "label label-gray ref-name") do + icon('code-fork') + " #{branch}" end end.join(" ").html_safe end @@ -88,29 +84,22 @@ module CommitsHelper def commit_tags_links(project, tags) sorted = VersionSorter.rsort(tags) sorted.map do |tag| - link_to( - namespace_project_commits_path(project.namespace, project, - project.repository.find_tag(tag).name) - ) do - content_tag :span, class: 'label label-gray' do - icon('tag') + ' ' + tag - end + link_to(project_ref_path(project, tag), class: "label label-gray ref-name") do + icon('tag') + " #{tag}" end end.join(" ").html_safe end def link_to_browse_code(project, commit) + return unless current_controller?(:commits) + if @path.blank? return link_to( "Browse Files", namespace_project_tree_path(project.namespace, project, commit), class: "btn btn-default" ) - end - - return unless current_controller?(:projects, :commits) - - if @repo.blob_at(commit.id, @path) + elsif @repo.blob_at(commit.id, @path) return link_to( "Browse File", namespace_project_blob_path(project.namespace, project, @@ -200,8 +189,8 @@ module CommitsHelper tree_join(commit_sha, diff_new_path)), class: 'btn view-file js-view-file' ) do - raw('View file @') + content_tag(:span, commit_sha[0..6], - class: 'commit-short-id') + raw('View file @ ') + content_tag(:span, Commit.truncate_sha(commit_sha), + class: 'commit-sha') end end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index aed1d7c839f..4a06ee653ee 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -62,19 +62,21 @@ module DiffHelper end def parallel_diff_discussions(left, right, diff_file) - discussion_left = discussion_right = nil + return unless @grouped_diff_discussions + + discussions_left = discussions_right = nil if left && (left.unchanged? || left.removed?) line_code = diff_file.line_code(left) - discussion_left = @grouped_diff_discussions[line_code] + discussions_left = @grouped_diff_discussions[line_code] end if right && right.added? line_code = diff_file.line_code(right) - discussion_right = @grouped_diff_discussions[line_code] + discussions_right = @grouped_diff_discussions[line_code] end - [discussion_left, discussion_right] + [discussions_left, discussions_right] end def inline_diff_btn @@ -96,7 +98,7 @@ module DiffHelper [ content_tag(:span, link_to(truncate(blob.name, length: 40), tree)), '@', - content_tag(:span, commit_id, class: 'monospace'), + content_tag(:span, commit_id, class: 'commit-sha') ].join(' ').html_safe end diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 81e0b6bb5ae..8ed99642c7a 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -1,6 +1,6 @@ module DropdownsHelper def dropdown_tag(toggle_text, options: {}, &block) - content_tag :div, class: "dropdown" do + content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do data_attr = { toggle: "dropdown" } if options.has_key?(:data) @@ -20,7 +20,7 @@ module DropdownsHelper output << dropdown_filter(options[:placeholder]) end - output << content_tag(:div, class: "dropdown-content") do + output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do capture(&block) if block && !options.has_key?(:footer_content) end diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index f927cfc998f..3b24f183785 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -12,7 +12,7 @@ module EmailsHelper "action" => { "@type" => "ViewAction", "name" => name, - "url" => url, + "url" => url } } diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index fb872a13f74..751d61955b7 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,9 +1,21 @@ module EventsHelper - def link_to_author(event) + ICON_NAMES_BY_EVENT_TYPE = { + 'pushed to' => 'icon_commit', + 'pushed new' => 'icon_commit', + 'created' => 'icon_status_open', + 'opened' => 'icon_status_open', + 'closed' => 'icon_status_closed', + 'accepted' => 'icon_code_fork', + 'commented on' => 'icon_comment_o', + 'deleted' => 'icon_trash_o' + }.freeze + + def link_to_author(event, self_added: false) author = event.author if author - link_to author.name, user_path(author.username), title: author.name + name = self_added ? 'You' : author.name + link_to name, user_path(author.username), title: name else event.author_name end @@ -29,7 +41,7 @@ module EventsHelper link_opts = { class: "event-filter-link", id: "#{key}_event_filter", - title: "Filter by #{tooltip.downcase}", + title: "Filter by #{tooltip.downcase}" } content_tag :li, class: active do @@ -152,9 +164,14 @@ module EventsHelper def event_note_title_html(event) if event.note_target - link_to(event_note_target_path(event), title: event.target_title, class: 'has-tooltip') do - "#{event.note_target_type} #{event.note_target_reference}" - end + text = raw("#{event.note_target_type} ") + + if event.commit_note? + content_tag(:span, event.note_target_reference, class: 'commit-sha') + else + event.note_target_reference + end + + link_to(text, event_note_target_path(event), title: event.target_title, class: 'has-tooltip') else content_tag(:strong, '(deleted)') end @@ -183,4 +200,21 @@ module EventsHelper "event-inline" end end + + def icon_for_event(note) + icon_name = ICON_NAMES_BY_EVENT_TYPE[note] + custom_icon(icon_name) if icon_name + end + + def icon_for_profile_event(event) + if current_path?('users#show') + content_tag :div, class: "system-note-image #{event.action_name.parameterize}-icon" do + icon_for_event(event.action_name) + end + else + content_tag :div, class: 'system-note-image user-avatar' do + author_avatar(event, size: 32) + end + end + end end diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index 7bd212a3ef9..b981a1e8242 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -10,7 +10,7 @@ module ExploreHelper personal: params[:personal], archived: params[:archived], shared: params[:shared], - namespace_id: params[:namespace_id], + namespace_id: params[:namespace_id] } options = exist_opts.merge(options).delete_if { |key, value| value.blank? } diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 1182939f656..53962b84618 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -15,4 +15,36 @@ module FormHelper end end end + + def issue_dropdown_options(issuable, has_multiple_assignees = true) + options = { + toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data', + title: 'Select assignee', + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', + placeholder: 'Search users', + data: { + first_user: current_user&.username, + null_user: true, + current_user: true, + project_id: issuable.project.try(:id), + field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]", + default_label: 'Assignee', + 'max-select': 1, + 'dropdown-header': 'Assignee', + multi_select: true, + 'input-meta': 'name', + 'always-show-selectbox': true, + current_user_info: current_user.to_json(only: [:id, :name]) + } + } + + if has_multiple_assignees + options[:title] = 'Select assignee(s)' + options[:data][:'dropdown-header'] = 'Assignee(s)' + options[:data].delete(:'max-select') + end + + options + end end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index e9b7cbbad6a..fc308b3960e 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -54,6 +54,10 @@ module GitlabRoutingHelper namespace_project_builds_path(project.namespace, project, *args) end + def project_ref_path(project, ref_name, *args) + namespace_project_commits_path(project.namespace, project, ref_name, *args) + end + def project_container_registry_path(project, *args) namespace_project_container_registry_index_path(project.namespace, project, *args) end @@ -122,6 +126,14 @@ module GitlabRoutingHelper namespace_project_snippet_url(entity.project.namespace, entity.project, entity, *args) end + def preview_markdown_path(project, *args) + if @snippet.is_a?(PersonalSnippet) + preview_markdown_snippet_path(@snippet) + else + preview_markdown_namespace_project_path(project.namespace, project, *args) + end + end + def toggle_subscription_path(entity, *args) if entity.is_a?(Issue) toggle_subscription_namespace_project_issue_path(entity.project.namespace, entity.project, entity) @@ -208,9 +220,31 @@ module GitlabRoutingHelper browse_namespace_project_build_artifacts_path(*args) when 'file' file_namespace_project_build_artifacts_path(*args) + when 'raw' + raw_namespace_project_build_artifacts_path(*args) end end + # Pipeline Schedules + def pipeline_schedules_path(project, *args) + namespace_project_pipeline_schedules_path(project.namespace, project, *args) + end + + def pipeline_schedule_path(schedule, *args) + project = schedule.project + namespace_project_pipeline_schedule_path(project.namespace, project, schedule, *args) + end + + def edit_pipeline_schedule_path(schedule) + project = schedule.project + edit_namespace_project_pipeline_schedule_path(project.namespace, project, schedule) + end + + def take_ownership_pipeline_schedule_path(schedule, *args) + project = schedule.project + take_ownership_namespace_project_pipeline_schedule_path(project.namespace, project, schedule, *args) + end + # Settings def project_settings_integrations_path(project, *args) namespace_project_settings_integrations_path(project.namespace, project, *args) diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index ab3ef454e1c..f29faeca22d 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -7,6 +7,12 @@ module IconsHelper # font-awesome-rails gem, but should we ever use a different icon pack in the # future we won't have to change hundreds of method calls. def icon(names, options = {}) + if (options.keys & %w[aria-hidden aria-label data-hidden]).empty? + # Add 'aria-hidden' and 'data-hidden' if they are not set in options. + options['aria-hidden'] = true + options['data-hidden'] = true + end + options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) end @@ -14,6 +20,8 @@ module IconsHelper case names when "standard" names = "key" + when "two-factor" + names = "key" end options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index ec57fec4f99..9290e4ec133 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -37,7 +37,10 @@ module IssuablesHelper when Issue IssueSerializer.new.represent(issuable).to_json when MergeRequest - MergeRequestSerializer.new.represent(issuable).to_json + MergeRequestSerializer + .new(current_user: current_user, project: issuable.project) + .represent(issuable) + .to_json end end @@ -63,6 +66,17 @@ module IssuablesHelper end end + def users_dropdown_label(selected_users) + case selected_users.length + when 0 + "Unassigned" + when 1 + selected_users[0].name + else + "#{selected_users[0].name} + #{selected_users.length - 1} more" + end + end + def user_dropdown_label(user_id, default_label) return default_label if user_id.nil? return "Unassigned" if user_id == "0" @@ -123,11 +137,9 @@ module IssuablesHelper author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "hidden-sm hidden-md hidden-lg") end - if issuable.tasks? - output << " ".html_safe - output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") - output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") - end + output << " ".html_safe + output << content_tag(:span, issuable.task_status, id: "task_status", class: "hidden-xs hidden-sm") + output << content_tag(:span, issuable.task_status_short, id: "task_status_short", class: "hidden-md hidden-lg") output end @@ -165,11 +177,8 @@ module IssuablesHelper html.html_safe end - def cached_assigned_issuables_count(assignee, issuable_type, state) - cache_key = hexdigest(['assigned_issuables_count', assignee.id, issuable_type, state].join('-')) - Rails.cache.fetch(cache_key, expires_in: 2.minutes) do - assigned_issuables_count(assignee, issuable_type, state) - end + def assigned_issuables_count(issuable_type) + current_user.public_send("assigned_open_#{issuable_type}_count") end def issuable_filter_params @@ -192,10 +201,6 @@ module IssuablesHelper private - def assigned_issuables_count(assignee, issuable_type, state) - assignee.public_send("assigned_#{issuable_type}").public_send(state).count - end - def sidebar_gutter_collapsed? cookies[:collapsed_gutter] == 'true' end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 6978b0c89fd..82288f1da35 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -110,6 +110,14 @@ module IssuesHelper end end + def award_user_authored_class(award) + if award == 'thumbsdown' || award == 'thumbsup' + 'user-authored js-user-authored' + else + '' + end + end + def awards_sort(awards) awards.sort_by do |award, notes| if award == "thumbsup" diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb index 68c09c922a6..d5e77c7e271 100644 --- a/app/helpers/javascript_helper.rb +++ b/app/helpers/javascript_helper.rb @@ -3,7 +3,8 @@ module JavascriptHelper javascript_include_tag asset_path(js) end - def page_specific_javascript_bundle_tag(js) - javascript_include_tag(*webpack_asset_paths(js)) + # deprecated; use webpack_bundle_tag directly instead + def page_specific_javascript_bundle_tag(bundle) + webpack_bundle_tag(bundle) end end diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/markup_helper.rb index cd442237086..941cfce8370 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/markup_helper.rb @@ -1,6 +1,25 @@ require 'nokogiri' -module GitlabMarkdownHelper +module MarkupHelper + include ActionView::Helpers::TagHelper + include ActionView::Context + + def plain?(filename) + Gitlab::MarkupHelper.plain?(filename) + end + + def markup?(filename) + Gitlab::MarkupHelper.markup?(filename) + end + + def gitlab_markdown?(filename) + Gitlab::MarkupHelper.gitlab_markdown?(filename) + end + + def asciidoc?(filename) + Gitlab::MarkupHelper.asciidoc?(filename) + end + # Use this in places where you would normally use link_to(gfm(...), ...). # # It solves a problem occurring with nested links (i.e. @@ -11,12 +30,12 @@ module GitlabMarkdownHelper # explicitly produce the correct linking behavior (i.e. # "<a>outer text </a><a>gfm ref</a><a> more outer text</a>"). def link_to_gfm(body, url, html_options = {}) - return "" if body.blank? + return '' if body.blank? context = { project: @project, current_user: (current_user if defined?(current_user)), - pipeline: :single_line, + pipeline: :single_line } gfm_body = Banzai.render(body, context) @@ -43,71 +62,73 @@ module GitlabMarkdownHelper fragment.to_html.html_safe end + # Return the first line of +text+, up to +max_chars+, after parsing the line + # as Markdown. HTML tags in the parsed output are not counted toward the + # +max_chars+ limit. If the length limit falls within a tag's contents, then + # the tag contents are truncated without removing the closing tag. + def first_line_in_markdown(text, max_chars = nil, options = {}) + md = markdown(text, options).strip + + truncate_visible(md, max_chars || md.length) if md.present? + end + def markdown(text, context = {}) - return "" unless text.present? + return '' unless text.present? context[:project] ||= @project - - html = Banzai.render(text, context) - banzai_postprocess(html, context) + html = markdown_unsafe(text, context) + prepare_for_rendering(html, context) end def markdown_field(object, field) object = object.for_display if object.respond_to?(:for_display) - return "" unless object.present? + return '' unless object.present? html = Banzai.render_field(object, field) - banzai_postprocess(html, object.banzai_render_context(field)) + prepare_for_rendering(html, object.banzai_render_context(field)) end - def asciidoc(text) - Gitlab::Asciidoc.render( - text, - project: @project, - current_user: (current_user if defined?(current_user)), - - # RelativeLinkFilter - project_wiki: @project_wiki, - requested_path: @path, - ref: @ref, - commit: @commit - ) + def markup(file_name, text, context = {}) + context[:project] ||= @project + html = context.delete(:rendered) || markup_unsafe(file_name, text, context) + prepare_for_rendering(html, context) end - def other_markup(file_name, text) - Gitlab::OtherMarkup.render( - file_name, - text, - project: @project, - current_user: (current_user if defined?(current_user)), + def render_wiki_content(wiki_page) + text = wiki_page.content + return '' unless text.present? + + context = { pipeline: :wiki, project: @project, project_wiki: @project_wiki, page_slug: wiki_page.slug } + + html = + case wiki_page.format + when :markdown + markdown_unsafe(text, context) + when :asciidoc + asciidoc_unsafe(text) + else + wiki_page.formatted_content.html_safe + end - # RelativeLinkFilter - project_wiki: @project_wiki, - requested_path: @path, - ref: @ref, - commit: @commit - ) + prepare_for_rendering(html, context) end - # Return the first line of +text+, up to +max_chars+, after parsing the line - # as Markdown. HTML tags in the parsed output are not counted toward the - # +max_chars+ limit. If the length limit falls within a tag's contents, then - # the tag contents are truncated without removing the closing tag. - def first_line_in_markdown(text, max_chars = nil, options = {}) - md = markdown(text, options).strip + def markup_unsafe(file_name, text, context = {}) + return '' unless text.present? - truncate_visible(md, max_chars || md.length) if md.present? - end - - def render_wiki_content(wiki_page) - case wiki_page.format - when :markdown - markdown(wiki_page.content, pipeline: :wiki, project_wiki: @project_wiki, page_slug: wiki_page.slug) - when :asciidoc - asciidoc(wiki_page.content) + if gitlab_markdown?(file_name) + markdown_unsafe(text, context) + elsif asciidoc?(file_name) + asciidoc_unsafe(text, context) + elsif plain?(file_name) + content_tag :pre, class: 'plain-readme' do + text + end else - wiki_page.formatted_content.html_safe + other_markup_unsafe(file_name, text, context) end + rescue RuntimeError + simple_format(text) end # Returns the text necessary to reference `entity` across projects @@ -183,10 +204,10 @@ module GitlabMarkdownHelper end def markdown_toolbar_button(options = {}) - data = options[:data].merge({ container: "body" }) + data = options[:data].merge({ container: 'body' }) content_tag :button, - type: "button", - class: "toolbar-btn js-md has-tooltip hidden-xs", + type: 'button', + class: 'toolbar-btn js-md has-tooltip hidden-xs', tabindex: -1, data: data, title: options[:title], @@ -195,17 +216,35 @@ module GitlabMarkdownHelper end end - # Calls Banzai.post_process with some common context options - def banzai_postprocess(html, context) + def markdown_unsafe(text, context = {}) + Banzai.render(text, context) + end + + def asciidoc_unsafe(text, context = {}) + Gitlab::Asciidoc.render(text, context) + end + + def other_markup_unsafe(file_name, text, context = {}) + Gitlab::OtherMarkup.render(file_name, text, context) + end + + def prepare_for_rendering(html, context = {}) + return '' unless html.present? + context.merge!( current_user: (current_user if defined?(current_user)), # RelativeLinkFilter - requested_path: @path, + commit: @commit, project_wiki: @project_wiki, - ref: @ref + ref: @ref, + requested_path: @path ) - Banzai.post_process(html, context) + html = Banzai.post_process(html, context) + + Hamlit::RailsHelpers.preserve(html) end + + extend self end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 38be073c8dc..39d30631646 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -1,6 +1,6 @@ module MergeRequestsHelper def new_mr_path_from_push_event(event) - target_project = event.project.forked_from_project || event.project + target_project = event.project.default_merge_request_target new_namespace_project_merge_request_path( event.project.namespace, event.project, @@ -19,14 +19,6 @@ module MergeRequestsHelper } end - def mr_widget_refresh_url(mr) - if mr && mr.target_project - merge_widget_refresh_namespace_project_merge_request_url(mr.target_project.namespace, mr.target_project, mr) - else - '' - end - end - def mr_css_classes(mr) classes = "merge-request" classes << " closed" if mr.closed? @@ -55,22 +47,6 @@ module MergeRequestsHelper end end - def issues_sentence(issues) - # Sorting based on the `#123` or `group/project#123` reference will sort - # local issues first. - issues.map do |issue| - issue.to_reference(@project) - end.sort.to_sentence - end - - def mr_closes_issues - @mr_closes_issues ||= @merge_request.closes_issues(current_user) - end - - def mr_issues_mentioned_but_not_closing - @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing(current_user) - end - def mr_change_branches_path(merge_request) new_namespace_project_merge_request_path( @project.namespace, @project, @@ -78,41 +54,12 @@ module MergeRequestsHelper source_project_id: merge_request.source_project_id, target_project_id: merge_request.target_project_id, source_branch: merge_request.source_branch, - target_branch: merge_request.target_branch, + target_branch: merge_request.target_branch }, change_branches: true ) end - def mr_assign_issues_link - issues = MergeRequests::AssignIssuesService.new(@project, - current_user, - merge_request: @merge_request, - closes_issues: mr_closes_issues - ).assignable_issues - path = assign_related_issues_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - if issues.present? - pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" - link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post - end - end - - def source_branch_with_namespace(merge_request) - namespace = merge_request.source_project_namespace - branch = merge_request.source_branch - - if merge_request.source_branch_exists? - namespace = link_to(namespace, project_path(merge_request.source_project)) - branch = link_to(branch, namespace_project_commits_path(merge_request.source_project.namespace, merge_request.source_project, merge_request.source_branch)) - end - - if merge_request.for_fork? - namespace + ":" + branch - else - branch - end - end - def format_mr_branch_names(merge_request) source_path = merge_request.source_project_path target_path = merge_request.target_project_path @@ -126,6 +73,10 @@ module MergeRequestsHelper end end + def target_projects(project) + [project, project.default_merge_request_target].uniq + end + def merge_request_button_visibility(merge_request, closed) return 'hidden' if merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork? end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index c9e70faa52e..c515774140c 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -115,4 +115,28 @@ module MilestonesHelper end end end + + def milestone_merge_request_tab_path(milestone) + if @project + merge_requests_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end + + def milestone_participants_tab_path(milestone) + if @project + participants_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end + + def milestone_labels_tab_path(milestone) + if @project + labels_namespace_project_milestone_path(@project.namespace, @project, milestone, format: :json) + elsif @group + labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + end + end end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index b0331f36a2f..375110b77e2 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -19,62 +19,29 @@ module NotesHelper id: noteable.id, class: noteable.class.name, resources: noteable.class.table_name, - project_id: noteable.project.id, + project_id: noteable.project.id }.to_json end def diff_view_data - return {} unless @comments_target + return {} unless @new_diff_note_attrs - @comments_target.slice(:noteable_id, :noteable_type, :commit_id) + @new_diff_note_attrs.slice(:noteable_id, :noteable_type, :commit_id) end def diff_view_line_data(line_code, position, line_type) return if @diff_notes_disabled - use_legacy_diff_note = @use_legacy_diff_notes - # If the controller doesn't force the use of legacy diff notes, we - # determine this on a line-by-line basis by seeing if there already exist - # active legacy diff notes at this line, in which case newly created notes - # will use the legacy technology as well. - # We do this because the discussion_id values of legacy and "new" diff - # notes, which are used to group notes on the merge request discussion tab, - # are incompatible. - # If we didn't, diff notes that would show for the same line on the changes - # tab, would show in different discussions on the discussion tab. - use_legacy_diff_note ||= begin - discussion = @grouped_diff_discussions[line_code] - discussion && discussion.legacy_diff_discussion? - end - data = { line_code: line_code, - line_type: line_type, + line_type: line_type } - if use_legacy_diff_note - discussion_id = LegacyDiffNote.discussion_id( - @comments_target[:noteable_type], - @comments_target[:noteable_id] || @comments_target[:commit_id], - line_code - ) - - data.merge!( - note_type: LegacyDiffNote.name, - discussion_id: discussion_id - ) + if @use_legacy_diff_notes + data[:note_type] = LegacyDiffNote.name else - discussion_id = DiffNote.discussion_id( - @comments_target[:noteable_type], - @comments_target[:noteable_id] || @comments_target[:commit_id], - position - ) - - data.merge!( - position: position.to_json, - note_type: DiffNote.name, - discussion_id: discussion_id - ) + data[:note_type] = DiffNote.name + data[:position] = position.to_json end data @@ -83,32 +50,73 @@ module NotesHelper def link_to_reply_discussion(discussion, line_type = nil) return unless current_user - data = discussion.reply_attributes.merge(line_type: line_type) + data = { discussion_id: discussion.id, line_type: line_type } button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', data: data, title: 'Add a reply' end - def preload_max_access_for_authors(notes, project) - user_ids = notes.map(&:author_id) - project.team.max_member_access_for_user_ids(user_ids) + def note_max_access_for_user(note) + note.project.team.human_max_access(note.author_id) end - def preload_noteable_for_regular_notes(notes) - ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable) + def discussion_path(discussion) + if discussion.for_merge_request? + return unless discussion.diff_discussion? + + version_params = discussion.merge_request_version_params + return unless version_params + + path_params = version_params.merge(anchor: discussion.line_code) + + diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, path_params) + elsif discussion.for_commit? + anchor = discussion.line_code if discussion.diff_discussion? + + namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor) + end end - def note_max_access_for_user(note) - note.project.team.human_max_access(note.author_id) + def notes_url + if @snippet.is_a?(PersonalSnippet) + snippet_notes_path(@snippet) + else + namespace_project_noteable_notes_path( + namespace_id: @project.namespace, + project_id: @project, + target_id: @noteable.id, + target_type: @noteable.class.name.underscore + ) + end end - def discussion_diff_path(discussion) - return unless discussion.diff_discussion? + def note_url(note) + if note.noteable.is_a?(PersonalSnippet) + snippet_note_path(note.noteable, note) + else + namespace_project_note_path(@project.namespace, @project, note) + end + end - if discussion.for_merge_request? && discussion.active? - diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) - elsif discussion.for_commit? - namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) + def form_resources + if @snippet.is_a?(PersonalSnippet) + [@note] + else + [@project.namespace.becomes(Namespace), @project, @note] + end + end + + def new_form_url + return nil unless @snippet.is_a?(PersonalSnippet) + + snippet_notes_path(@snippet) + end + + def can_create_note? + if @snippet.is_a?(PersonalSnippet) + can?(current_user, :comment_personal_snippet, @snippet) + else + can?(current_user, :create_note, @project) end end end diff --git a/app/helpers/pipeline_schedules_helper.rb b/app/helpers/pipeline_schedules_helper.rb new file mode 100644 index 00000000000..fee1edc2a1b --- /dev/null +++ b/app/helpers/pipeline_schedules_helper.rb @@ -0,0 +1,11 @@ +module PipelineSchedulesHelper + def timezone_data + ActiveSupport::TimeZone.all.map do |timezone| + { + name: timezone.name, + offset: timezone.utc_offset, + identifier: timezone.tzinfo.identifier + } + end + end +end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 243ef39ef61..de959f13713 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -63,6 +63,10 @@ module PreferencesHelper end def anonymous_project_view - @project.empty_repo? || !can?(current_user, :download_code, @project) ? 'activity' : 'readme' + if !@project.empty_repo? && can?(current_user, :download_code, @project) + 'files' + else + 'activity' + end end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index bd0c2cd661e..98bbcfaaba5 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -24,7 +24,7 @@ module ProjectsHelper return "(deleted)" unless author - author_html = "" + author_html = "" # Build avatar image tag author_html << image_tag(avatar_icon(author, opts[:size]), width: opts[:size], class: "avatar avatar-inline #{"s#{opts[:size]}" if opts[:size]} #{opts[:avatar_class] if opts[:avatar_class]}", alt: '') if opts[:avatar] @@ -45,7 +45,7 @@ module ProjectsHelper link_to(author_html, user_path(author), class: "author_link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe else title = opts[:title].sub(":name", sanitize(author.name)) - link_to(author_html, user_path(author), class: "author_link has-tooltip", title: title, data: { container: 'body' } ).html_safe + link_to(author_html, user_path(author), class: "author_link has-tooltip", title: title, data: { container: 'body' }).html_safe end end @@ -110,11 +110,8 @@ module ProjectsHelper end def license_short_name(project) - return 'LICENSE' if project.repository.license_key.nil? - - license = Licensee::License.new(project.repository.license_key) - - license.nickname || license.name + license = project.repository.license + license&.nickname || license&.name || 'LICENSE' end def last_push_event @@ -160,12 +157,25 @@ module ProjectsHelper end def project_list_cache_key(project) - key = [project.namespace.cache_key, project.cache_key, controller.controller_name, controller.action_name, current_application_settings.cache_key, 'v2.3'] + key = [ + project.route.cache_key, + project.cache_key, + controller.controller_name, + controller.action_name, + current_application_settings.cache_key, + 'v2.4' + ] + key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status? key end + def load_pipeline_status(projects) + Gitlab::Cache::Ci::ProjectPipelineStatus. + load_in_batch_for_projects(projects) + end + private def repo_children_classes(field) @@ -272,14 +282,14 @@ module ProjectsHelper end end - def add_special_file_path(project, file_name:, commit_message: nil, target_branch: nil, context: nil) + def add_special_file_path(project, file_name:, commit_message: nil, branch_name: nil, context: nil) namespace_project_new_blob_path( project.namespace, project, project.default_branch || 'master', file_name: file_name, commit_message: commit_message || "Add #{file_name.downcase}", - target_branch: target_branch, + branch_name: branch_name, context: context ) end @@ -407,7 +417,10 @@ module ProjectsHelper def sanitize_repo_path(project, message) return '' unless message.present? - message.strip.gsub(project.repository_storage_path.chomp('/'), "[REPOS PATH]") + exports_path = File.join(Settings.shared['path'], 'tmp/project_exports') + filtered_message = message.strip.gsub(exports_path, "[REPO EXPORT PATH]") + + filtered_message.gsub(project.repository_storage_path.chomp('/'), "[REPOS PATH]") end def project_feature_options @@ -427,13 +440,22 @@ module ProjectsHelper end def visibility_select_options(project, selected_level) - levels_options_array = Gitlab::VisibilityLevel.values.map do |level| - [ + level_options = Gitlab::VisibilityLevel.values.each_with_object([]) do |level, level_options| + next if restricted_levels.include?(level) + + level_options << [ visibility_level_label(level), { data: { description: visibility_level_description(level, project) } }, level ] end - options_for_select(levels_options_array, selected_level) + + options_for_select(level_options, selected_level) + end + + def restricted_levels + return [] if current_user.admin? + + current_application_settings.restricted_visibility_levels || [] end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 8ff8db16514..9c46035057f 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -42,7 +42,7 @@ module SearchHelper { category: "Settings", label: "User settings", url: profile_path }, { category: "Settings", label: "SSH Keys", url: profile_keys_path }, { category: "Settings", label: "Dashboard", url: root_path }, - { category: "Settings", label: "Admin Section", url: admin_root_path }, + { category: "Settings", label: "Admin Section", url: admin_root_path } ] end @@ -57,7 +57,7 @@ module SearchHelper { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") }, { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") }, { category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") }, - { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") }, + { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") } ] end @@ -76,7 +76,7 @@ module SearchHelper { category: "Current Project", label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, { category: "Current Project", label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, { category: "Current Project", label: "Members", url: namespace_project_settings_members_path(@project.namespace, @project) }, - { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, + { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) } ] else [] diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 8706876ae4a..a7d1fe4aa47 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -67,7 +67,7 @@ module SelectsHelper current_user: opts[:current_user] || false, "push-code-to-protected-branches" => opts[:push_code_to_protected_branches], author_id: opts[:author_id] || '', - skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil, + skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil } end end diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 715e5893a2c..3707bb5ba36 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -13,8 +13,8 @@ module ServicesHelper "Event will be triggered when a confidential issue is created/updated/closed" when "merge_request", "merge_request_events" "Event will be triggered when a merge request is created/updated/merged" - when "build", "build_events" - "Event will be triggered when a build status changes" + when "pipeline", "pipeline_events" + "Event will be triggered when a pipeline status changes" when "wiki_page", "wiki_page_events" "Event will be triggered when a wiki page is created/updated" when "commit", "commit_events" diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 8c02b4061ca..2fd64b3441e 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -8,6 +8,14 @@ module SnippetsHelper end end + def download_snippet_path(snippet) + if snippet.project_id + raw_namespace_project_snippet_path(@project.namespace, @project, snippet, inline: false) + else + raw_snippet_path(snippet, inline: false) + end + end + # Return the path of a snippets index for a user or for a project # # @returns String, path to snippet index @@ -42,7 +50,7 @@ module SnippetsHelper 0, lined_content.size, surrounding_lines - ) if line.include?(query) + ) if line.downcase.include?(query.downcase) end used_lines.uniq.sort diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 5c89cbea3fc..b408ec0c6a4 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -25,8 +25,8 @@ module SortingHelper def projects_sort_options_hash options = { sort_value_name => sort_title_name, - sort_value_recently_updated => sort_title_recently_updated, - sort_value_oldest_updated => sort_title_oldest_updated, + sort_value_latest_activity => sort_title_latest_activity, + sort_value_oldest_activity => sort_title_oldest_activity, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created } @@ -58,7 +58,23 @@ module SortingHelper sort_value_due_date_soon => sort_title_due_date_soon, sort_value_due_date_later => sort_title_due_date_later, sort_value_start_date_soon => sort_title_start_date_soon, - sort_value_start_date_later => sort_title_start_date_later, + sort_value_start_date_later => sort_title_start_date_later + } + end + + def branches_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated + } + end + + def tags_sort_options_hash + { + sort_value_name => sort_title_name, + sort_value_recently_updated => sort_title_recently_updated, + sort_value_oldest_updated => sort_title_oldest_updated } end @@ -78,6 +94,14 @@ module SortingHelper 'Last updated' end + def sort_title_oldest_activity + 'Oldest updated' + end + + def sort_title_latest_activity + 'Last updated' + end + def sort_title_oldest_created 'Oldest created' end @@ -198,6 +222,14 @@ module SortingHelper 'updated_desc' end + def sort_value_oldest_activity + 'latest_activity_asc' + end + + def sort_value_latest_activity + 'latest_activity_desc' + end + def sort_value_oldest_created 'created_asc' end diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index fb95f2b565e..09b73eee8cf 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -1,28 +1,34 @@ module SubmoduleHelper include Gitlab::ShellAdapter + VALID_SUBMODULE_PROTOCOLS = %w[http https git ssh].freeze + # links to files listing for submodule if submodule is a project on this server def submodule_links(submodule_item, ref = nil, repository = @repository) url = repository.submodule_url_for(ref, submodule_item.path) - return url, nil unless url =~ /([^\/:]+)\/([^\/]+\.git)\Z/ - - namespace = $1 - project = $2 - project.chomp!('.git') - - if self_url?(url, namespace, project) - return namespace_project_path(namespace, project), - namespace_project_tree_path(namespace, project, - submodule_item.id) - elsif relative_self_url?(url) - relative_self_links(url, submodule_item.id) - elsif github_dot_com_url?(url) - standard_links('github.com', namespace, project, submodule_item.id) - elsif gitlab_dot_com_url?(url) - standard_links('gitlab.com', namespace, project, submodule_item.id) + if url == '.' || url == './' + url = File.join(Gitlab.config.gitlab.url, @project.full_path) + end + + if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/ + namespace, project = $1, $2 + project.sub!(/\.git\z/, '') + + if self_url?(url, namespace, project) + [namespace_project_path(namespace, project), + namespace_project_tree_path(namespace, project, submodule_item.id)] + elsif relative_self_url?(url) + relative_self_links(url, submodule_item.id) + elsif github_dot_com_url?(url) + standard_links('github.com', namespace, project, submodule_item.id) + elsif gitlab_dot_com_url?(url) + standard_links('gitlab.com', namespace, project, submodule_item.id) + else + [sanitize_submodule_url(url), nil] + end else - return url, nil + [sanitize_submodule_url(url), nil] end end @@ -37,14 +43,16 @@ module SubmoduleHelper end def self_url?(url, namespace, project) - return true if url == [Gitlab.config.gitlab.url, '/', namespace, '/', - project, '.git'].join('') - url == gitlab_shell.url_to_repo([namespace, '/', project].join('')) + url_no_dotgit = url.chomp('.git') + return true if url_no_dotgit == [Gitlab.config.gitlab.url, '/', namespace, '/', + project].join('') + url_with_dotgit = url_no_dotgit + '.git' + url_with_dotgit == gitlab_shell.url_to_repo([namespace, '/', project].join('')) end def relative_self_url?(url) # (./)?(../repo.git) || (./)?(../../project/repo.git) ) - url =~ /\A((\.\/)?(\.\.\/))(?!(\.\.)|(.*\/)).*\.git\z/ || url =~ /\A((\.\/)?(\.\.\/){2})(?!(\.\.))([^\/]*)\/(?!(\.\.)|(.*\/)).*\.git\z/ + url =~ /\A((\.\/)?(\.\.\/))(?!(\.\.)|(.*\/)).*(\.git)?\z/ || url =~ /\A((\.\/)?(\.\.\/){2})(?!(\.\.))([^\/]*)\/(?!(\.\.)|(.*\/)).*(\.git)?\z/ end def standard_links(host, namespace, project, commit) @@ -71,4 +79,16 @@ module SubmoduleHelper namespace_project_tree_path(namespace, base, commit) ] end + + def sanitize_submodule_url(url) + uri = URI.parse(url) + + if uri.scheme.in?(VALID_SUBMODULE_PROTOCOLS) + uri.to_s + else + nil + end + rescue URI::InvalidURIError + nil + end end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb new file mode 100644 index 00000000000..d889d141101 --- /dev/null +++ b/app/helpers/system_note_helper.rb @@ -0,0 +1,27 @@ +module SystemNoteHelper + ICON_NAMES_BY_ACTION = { + 'commit' => 'icon_commit', + 'description' => 'icon_edit', + 'merge' => 'icon_merge', + 'merged' => 'icon_merged', + 'opened' => 'icon_status_open', + 'closed' => 'icon_status_closed', + 'time_tracking' => 'icon_stopwatch', + 'assignee' => 'icon_user', + 'title' => 'icon_edit', + 'task' => 'icon_check_square_o', + 'label' => 'icon_tags', + 'cross_reference' => 'icon_random', + 'branch' => 'icon_code_fork', + 'confidential' => 'icon_eye_slash', + 'visible' => 'icon_eye', + 'milestone' => 'icon_clock_o', + 'discussion' => 'icon_comment_o', + 'moved' => 'icon_arrow_circle_o_right' + }.freeze + + def icon_for_system_note(note) + icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] + custom_icon(icon_name) if icon_name + end +end diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index c0ec1634cdb..31aaf9e5607 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -21,4 +21,8 @@ module TagsHelper html.html_safe end + + def protected_tag?(project, tag) + ProtectedTag.protected?(project, tag.name) + end end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 4f5adf623f2..19286fadb19 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -13,21 +13,24 @@ module TodosHelper def todo_action_name(todo) case todo.action - when Todo::ASSIGNED then 'assigned you' - when Todo::MENTIONED then 'mentioned you on' + when Todo::ASSIGNED then todo.self_added? ? 'assigned' : 'assigned you' + when Todo::MENTIONED then "mentioned #{todo_action_subject(todo)} on" when Todo::BUILD_FAILED then 'The build failed for' when Todo::MARKED then 'added a todo for' - when Todo::APPROVAL_REQUIRED then 'set you as an approver for' + when Todo::APPROVAL_REQUIRED then "set #{todo_action_subject(todo)} as an approver for" when Todo::UNMERGEABLE then 'Could not merge' - when Todo::DIRECTLY_ADDRESSED then 'directly addressed you on' + when Todo::DIRECTLY_ADDRESSED then "directly addressed #{todo_action_subject(todo)} on" end end def todo_target_link(todo) - target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target_reference}", todo_target_path(todo), - class: 'has-tooltip', - title: todo.target.title + text = raw("#{todo.target_type.titleize.downcase} ") + + if todo.for_commit? + content_tag(:span, todo.target_reference, class: 'commit-sha') + else + todo.target_reference + end + link_to text, todo_target_path(todo), class: 'has-tooltip', title: todo.target.title end def todo_target_path(todo) @@ -63,7 +66,7 @@ module TodosHelper project_id: params[:project_id], author_id: params[:author_id], type: params[:type], - action_id: params[:action_id], + action_id: params[:action_id] } end @@ -148,6 +151,10 @@ module TodosHelper private + def todo_action_subject(todo) + todo.self_added? ? 'yourself' : 'you' + end + def show_todo_state?(todo) (todo.target.is_a?(MergeRequest) || todo.target.is_a?(Issue)) && %w(closed merged).include?(todo.target.state) end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 4a76c679bad..e0d3e9b88f3 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -12,10 +12,6 @@ module TreeHelper tree.html_safe end - def render_readme(readme) - render_markup(readme.name, readme.data) - end - # Return an image icon depending on the file type and mode # # type - String type of the tree item; either 'folder' or 'file' @@ -35,7 +31,7 @@ module TreeHelper end def on_top_of_branch?(project = @project, ref = @ref) - project.repository.branch_names.include?(ref) + project.repository.branch_exists?(ref) end def can_edit_tree?(project = nil, ref = nil) @@ -80,19 +76,19 @@ module TreeHelper "A new branch will be created in your fork and a new merge request will be started." end - def tree_breadcrumbs(tree, max_links = 2) + def path_breadcrumbs(max_links = 6) if @path.present? part_path = "" parts = @path.split('/') - yield('..', nil) if parts.count > max_links + yield('..', File.join(*parts.first(parts.count - 2))) if parts.count > max_links parts.each do |part| part_path = File.join(part_path, part) unless part_path.empty? part_path = part if part_path.empty? next if parts.count > max_links && !parts.last(2).include?(part) - yield(part, tree_join(@ref, part_path)) + yield(part, part_path) end end end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 169cedeb796..b4aaf498068 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -85,7 +85,7 @@ module VisibilityLevelHelper end def restricted_visibility_levels(show_all = false) - return [] if current_user.is_admin? && !show_all + return [] if current_user.admin? && !show_all current_application_settings.restricted_visibility_levels || [] end diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb new file mode 100644 index 00000000000..6bacda9fe75 --- /dev/null +++ b/app/helpers/webpack_helper.rb @@ -0,0 +1,30 @@ +require 'webpack/rails/manifest' + +module WebpackHelper + def webpack_bundle_tag(bundle) + javascript_include_tag(*gitlab_webpack_asset_paths(bundle)) + end + + # override webpack-rails gem helper until changes can make it upstream + def gitlab_webpack_asset_paths(source, extension: nil) + return "" unless source.present? + + paths = Webpack::Rails::Manifest.asset_paths(source) + if extension + paths = paths.select { |p| p.ends_with? ".#{extension}" } + end + + # include full webpack-dev-server url for rspec tests running locally + if Rails.env.test? && Rails.configuration.webpack.dev_server.enabled + host = Rails.configuration.webpack.dev_server.host + port = Rails.configuration.webpack.dev_server.port + protocol = Rails.configuration.webpack.dev_server.https ? 'https' : 'http' + + paths.map! do |p| + "#{protocol}://#{host}:#{port}#{p}" + end + end + + paths + end +end diff --git a/app/mailers/base_mailer.rb b/app/mailers/base_mailer.rb index 79c3c2e62c5..d2980db218a 100644 --- a/app/mailers/base_mailer.rb +++ b/app/mailers/base_mailer.rb @@ -1,12 +1,12 @@ class BaseMailer < ActionMailer::Base helper ApplicationHelper - helper GitlabMarkdownHelper + helper MarkupHelper attr_accessor :current_user helper_method :current_user, :can? - default from: Proc.new { default_sender_address.format } - default reply_to: Proc.new { default_reply_to_address.format } + default from: proc { default_sender_address.format } + default reply_to: proc { default_reply_to_address.format } def can? Ability.allowed?(current_user, action, subject) diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index d64e48f774b..0f847841295 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -11,10 +11,12 @@ module Emails mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end - def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id) + def reassigned_issue_email(recipient_id, issue_id, previous_assignee_ids, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) - @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id + @previous_assignees = [] + @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any? + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 46fa6fd9f6d..00707a0023e 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -4,13 +4,8 @@ module Emails setup_note_mail(note_id, recipient_id) @commit = @note.noteable - @discussion = @note.to_discussion if @note.diff_note? @target_url = namespace_project_commit_url(*note_target_url_options) - - mail_answer_thread(@commit, - from: sender(@note.author_id), - to: recipient(recipient_id), - subject: subject("#{@commit.title} (#{@commit.short_id})")) + mail_answer_thread(@commit, note_thread_options(recipient_id)) end def note_issue_email(recipient_id, note_id) @@ -25,7 +20,6 @@ module Emails setup_note_mail(note_id, recipient_id) @merge_request = @note.noteable - @discussion = @note.to_discussion if @note.diff_note? @target_url = namespace_project_merge_request_url(*note_target_url_options) mail_answer_thread(@merge_request, note_thread_options(recipient_id)) end @@ -56,15 +50,18 @@ module Emails { from: sender(@note.author_id), to: recipient(recipient_id), - subject: subject("#{@note.noteable.title} (#{@note.noteable.to_reference})") + subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})") } end def setup_note_mail(note_id, recipient_id) - @note = Note.find(note_id) + # `note_id` is a `Note` when originating in `NotifyPreview` + @note = note_id.is_a?(Note) ? note_id : Note.find(note_id) @project = @note.project - @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) + if @project && @note.persisted? + @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) + end end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 14df6f8f0a3..f315e38bcaa 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -111,7 +111,7 @@ class Notify < BaseMailer headers["X-GitLab-#{model.class.name}-ID"] = model.id headers['X-GitLab-Reply-Key'] = reply_key - if Gitlab::IncomingEmail.enabled? + if Gitlab::IncomingEmail.enabled? && @sent_notification address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)) address.display_name = @project.name_with_namespace @@ -176,6 +176,6 @@ class Notify < BaseMailer end headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',') - @sent_notification_url = unsubscribe_sent_notification_url(@sent_notification) + @unsubscribe_url = unsubscribe_sent_notification_url(@sent_notification) end end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 2340453831e..0d7c2d20029 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -16,7 +16,7 @@ class AbuseReport < ActiveRecord::Base def remove_user(deleted_by:) user.block - DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true) + DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true) end def notify diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 671a0fe98cc..043f57241a3 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -28,6 +28,8 @@ class ApplicationSetting < ActiveRecord::Base attr_accessor :domain_whitelist_raw, :domain_blacklist_raw + validates :uuid, presence: true + validates :session_expire_delay, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } @@ -60,6 +62,10 @@ class ApplicationSetting < ActiveRecord::Base presence: true, if: :sentry_enabled + validates :clientside_sentry_dsn, + presence: true, + if: :clientside_sentry_enabled + validates :akismet_api_key, presence: true, if: :akismet_enabled @@ -131,6 +137,10 @@ class ApplicationSetting < ActiveRecord::Base presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :polling_interval_multiplier, + presence: true, + numericality: { greater_than_or_equal_to: 0 } + validates_each :restricted_visibility_levels do |record, attr, value| value&.each do |level| unless Gitlab::VisibilityLevel.options.has_value?(level) @@ -155,6 +165,7 @@ class ApplicationSetting < ActiveRecord::Base end end + before_validation :ensure_uuid! before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -233,7 +244,9 @@ class ApplicationSetting < ActiveRecord::Base signup_enabled: Settings.gitlab['signup_enabled'], terminal_max_session_time: 0, two_factor_grace_period: 48, - user_default_external: false + user_default_external: false, + polling_interval_multiplier: 1, + usage_ping_enabled: Settings.gitlab['usage_ping_enabled'] } end @@ -336,8 +349,22 @@ class ApplicationSetting < ActiveRecord::Base sidekiq_throttling_enabled end + def usage_ping_can_be_configured? + Settings.gitlab.usage_ping_enabled + end + + def usage_ping_enabled + usage_ping_can_be_configured? && super + end + private + def ensure_uuid! + return if uuid? + + self.uuid = SecureRandom.uuid + end + def check_repository_storages invalid = repository_storages - Gitlab.config.repositories.storages.keys errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 6937ad3bdd9..6ada6fae4eb 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base UPVOTE_NAME = "thumbsup".freeze include Participable + include GhostUser belongs_to :awardable, polymorphic: true belongs_to :user validates :awardable, :user, presence: true validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names } - validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] } + validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user? participant :user diff --git a/app/models/blob.rb b/app/models/blob.rb index 95d2111a992..e75926241ba 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -3,8 +3,62 @@ class Blob < SimpleDelegator CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour - # The maximum size of an SVG that can be displayed. - MAXIMUM_SVG_SIZE = 2.megabytes + MAXIMUM_TEXT_HIGHLIGHT_SIZE = 1.megabyte + + # Finding a viewer for a blob happens based only on extension and whether the + # blob is binary or text, which means 1 blob should only be matched by 1 viewer, + # and the order of these viewers doesn't really matter. + # + # However, when the blob is an LFS pointer, we cannot know for sure whether the + # file being pointed to is binary or text. In this case, we match only on + # extension, preferring binary viewers over text ones if both exist, since the + # large files referred to in "Large File Storage" are much more likely to be + # binary than text. + # + # `.stl` files, for example, exist in both binary and text forms, and are + # handled by different viewers (`BinarySTL` and `TextSTL`) depending on blob + # type. LFS pointers to `.stl` files are assumed to always be the binary kind, + # and use the `BinarySTL` viewer. + RICH_VIEWERS = [ + BlobViewer::Markup, + BlobViewer::Notebook, + BlobViewer::SVG, + + BlobViewer::Image, + BlobViewer::Sketch, + BlobViewer::Balsamiq, + + BlobViewer::Video, + + BlobViewer::PDF, + + BlobViewer::BinarySTL, + BlobViewer::TextSTL + ].sort_by { |v| v.binary? ? 0 : 1 }.freeze + + AUXILIARY_VIEWERS = [ + BlobViewer::GitlabCiYml, + BlobViewer::RouteMap, + + BlobViewer::Readme, + BlobViewer::License, + BlobViewer::Contributing, + BlobViewer::Changelog, + + BlobViewer::Cartfile, + BlobViewer::ComposerJson, + BlobViewer::Gemfile, + BlobViewer::Gemspec, + BlobViewer::GodepsJson, + BlobViewer::PackageJson, + BlobViewer::Podfile, + BlobViewer::Podspec, + BlobViewer::PodspecJson, + BlobViewer::RequirementsTxt, + BlobViewer::YarnLock + ].freeze + + attr_reader :project # Wrap a Gitlab::Git::Blob object, or return nil when given nil # @@ -16,10 +70,16 @@ class Blob < SimpleDelegator # # blob = Blob.decorate(nil) # puts "truthy" if blob # No output - def self.decorate(blob) + def self.decorate(blob, project = nil) return if blob.nil? - new(blob) + new(blob, project) + end + + def initialize(blob, project = nil) + @project = project + + super(blob) end # Returns the data of the blob. @@ -35,44 +95,128 @@ class Blob < SimpleDelegator end def no_highlighting? - size && size > 1.megabyte + raw_size && raw_size > MAXIMUM_TEXT_HIGHLIGHT_SIZE end - def only_display_raw? - size && truncated? + def empty? + raw_size == 0 end - def svg? - text? && language && language.name == 'SVG' + def too_large? + size && truncated? end - def ipython_notebook? - text? && language&.name == 'Jupyter Notebook' + def external_storage_error? + if external_storage == :lfs + !project&.lfs_enabled? + else + false + end end - def size_within_svg_limits? - size <= MAXIMUM_SVG_SIZE + def stored_externally? + return @stored_externally if defined?(@stored_externally) + + @stored_externally = external_storage && !external_storage_error? end - def video? - UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) + # Returns the size of the file that this blob represents. If this blob is an + # LFS pointer, this is the size of the file stored in LFS. Otherwise, this is + # the size of the blob itself. + def raw_size + if stored_externally? + external_size + else + size + end end - def to_partial_path(project) - if lfs_pointer? - if project.lfs_enabled? - 'download' + # Returns whether the file that this blob represents is binary. If this blob is + # an LFS pointer, we assume the file stored in LFS is binary, unless a + # text-based rich blob viewer matched on the file's extension. Otherwise, this + # depends on the type of the blob itself. + def raw_binary? + if stored_externally? + if rich_viewer + rich_viewer.binary? + elsif Linguist::Language.find_by_filename(name).any? + false + elsif _mime_type + _mime_type.binary? else - 'text' + true end - elsif image? || svg? - 'image' - elsif ipython_notebook? - 'notebook' - elsif text? - 'text' else - 'download' + binary? end end + + def extension + @extension ||= extname.downcase.delete('.') + end + + def video? + UploaderHelper::VIDEO_EXT.include?(extension) + end + + def readable_text? + text? && !stored_externally? && !too_large? + end + + def simple_viewer + @simple_viewer ||= simple_viewer_class.new(self) + end + + def rich_viewer + return @rich_viewer if defined?(@rich_viewer) + + @rich_viewer = rich_viewer_class&.new(self) + end + + def auxiliary_viewer + return @auxiliary_viewer if defined?(@auxiliary_viewer) + + @auxiliary_viewer = auxiliary_viewer_class&.new(self) + end + + def rendered_as_text?(ignore_errors: true) + simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?) + end + + def show_viewer_switcher? + rendered_as_text? && rich_viewer + end + + def override_max_size! + simple_viewer&.override_max_size = true + rich_viewer&.override_max_size = true + end + + private + + def simple_viewer_class + if empty? + BlobViewer::Empty + elsif raw_binary? + BlobViewer::Download + else # text + BlobViewer::Text + end + end + + def rich_viewer_class + viewer_class_from(RICH_VIEWERS) + end + + def auxiliary_viewer_class + viewer_class_from(AUXILIARY_VIEWERS) + end + + def viewer_class_from(classes) + return if empty? || external_storage_error? + + verify_binary = !stored_externally? + + classes.find { |viewer_class| viewer_class.can_render?(self, verify_binary: verify_binary) } + end end diff --git a/app/models/blob_viewer/auxiliary.rb b/app/models/blob_viewer/auxiliary.rb new file mode 100644 index 00000000000..07a207730cf --- /dev/null +++ b/app/models/blob_viewer/auxiliary.rb @@ -0,0 +1,18 @@ +module BlobViewer + module Auxiliary + extend ActiveSupport::Concern + + include Gitlab::Allowable + + included do + self.loading_partial_name = 'loading_auxiliary' + self.type = :auxiliary + self.overridable_max_size = 100.kilobytes + self.max_size = 100.kilobytes + end + + def visible_to?(current_user) + true + end + end +end diff --git a/app/models/blob_viewer/balsamiq.rb b/app/models/blob_viewer/balsamiq.rb new file mode 100644 index 00000000000..f982521db99 --- /dev/null +++ b/app/models/blob_viewer/balsamiq.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Balsamiq < Base + include Rich + include ClientSide + + self.partial_name = 'balsamiq' + self.extensions = %w(bmpr) + self.binary = true + self.switcher_icon = 'file-image-o' + self.switcher_title = 'preview' + end +end diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb new file mode 100644 index 00000000000..26a3778c2a3 --- /dev/null +++ b/app/models/blob_viewer/base.rb @@ -0,0 +1,105 @@ +module BlobViewer + class Base + PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze + + class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :overridable_max_size, :max_size + + self.loading_partial_name = 'loading' + + delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class + + attr_reader :blob + attr_accessor :override_max_size + + delegate :project, to: :blob + + def initialize(blob) + @blob = blob + end + + def self.partial_path + File.join(PARTIAL_PATH_PREFIX, partial_name) + end + + def self.loading_partial_path + File.join(PARTIAL_PATH_PREFIX, loading_partial_name) + end + + def self.rich? + type == :rich + end + + def self.simple? + type == :simple + end + + def self.auxiliary? + type == :auxiliary + end + + def self.load_async? + load_async + end + + def self.binary? + binary + end + + def self.text? + !binary? + end + + def self.can_render?(blob, verify_binary: true) + return false if verify_binary && binary? != blob.binary? + return true if extensions&.include?(blob.extension) + return true if file_types&.include?(Gitlab::FileDetector.type_of(blob.path)) + + false + end + + def load_async? + self.class.load_async? && render_error.nil? + end + + def exceeds_overridable_max_size? + overridable_max_size && blob.raw_size > overridable_max_size + end + + def exceeds_max_size? + max_size && blob.raw_size > max_size + end + + def can_override_max_size? + exceeds_overridable_max_size? && !exceeds_max_size? + end + + def too_large? + if override_max_size + exceeds_max_size? + else + exceeds_overridable_max_size? + end + end + + # This method is used on the server side to check whether we can attempt to + # render the blob at all. Human-readable error messages are found in the + # `BlobHelper#blob_render_error_reason` helper. + # + # This method does not and should not load the entire blob contents into + # memory, and should not be overridden to do so in order to validate the + # format of the blob. + # + # Prefer to implement a client-side viewer, where the JS component loads the + # binary from `blob_raw_url` and does its own format validation and error + # rendering, especially for potentially large binary formats. + def render_error + if too_large? + :too_large + end + end + + def prepare! + # To be overridden by subclasses + end + end +end diff --git a/app/models/blob_viewer/binary_stl.rb b/app/models/blob_viewer/binary_stl.rb new file mode 100644 index 00000000000..80393471ef2 --- /dev/null +++ b/app/models/blob_viewer/binary_stl.rb @@ -0,0 +1,10 @@ +module BlobViewer + class BinarySTL < Base + include Rich + include ClientSide + + self.partial_name = 'stl' + self.extensions = %w(stl) + self.binary = true + end +end diff --git a/app/models/blob_viewer/cartfile.rb b/app/models/blob_viewer/cartfile.rb new file mode 100644 index 00000000000..d8471bc33c0 --- /dev/null +++ b/app/models/blob_viewer/cartfile.rb @@ -0,0 +1,15 @@ +module BlobViewer + class Cartfile < DependencyManager + include Static + + self.file_types = %i(cartfile) + + def manager_name + 'Carthage' + end + + def manager_url + 'https://github.com/Carthage/Carthage' + end + end +end diff --git a/app/models/blob_viewer/changelog.rb b/app/models/blob_viewer/changelog.rb new file mode 100644 index 00000000000..0464ae27f71 --- /dev/null +++ b/app/models/blob_viewer/changelog.rb @@ -0,0 +1,16 @@ +module BlobViewer + class Changelog < Base + include Auxiliary + include Static + + self.partial_name = 'changelog' + self.file_types = %i(changelog) + self.binary = false + + def render_error + return if project.repository.tag_count > 0 + + :no_tags + end + end +end diff --git a/app/models/blob_viewer/client_side.rb b/app/models/blob_viewer/client_side.rb new file mode 100644 index 00000000000..cc68236f92b --- /dev/null +++ b/app/models/blob_viewer/client_side.rb @@ -0,0 +1,11 @@ +module BlobViewer + module ClientSide + extend ActiveSupport::Concern + + included do + self.load_async = false + self.overridable_max_size = 10.megabytes + self.max_size = 50.megabytes + end + end +end diff --git a/app/models/blob_viewer/composer_json.rb b/app/models/blob_viewer/composer_json.rb new file mode 100644 index 00000000000..ef8b4aef8e8 --- /dev/null +++ b/app/models/blob_viewer/composer_json.rb @@ -0,0 +1,23 @@ +module BlobViewer + class ComposerJson < DependencyManager + include ServerSide + + self.file_types = %i(composer_json) + + def manager_name + 'Composer' + end + + def manager_url + 'https://getcomposer.com/' + end + + def package_name + @package_name ||= package_name_from_json('name') + end + + def package_url + "https://packagist.org/packages/#{package_name}" + end + end +end diff --git a/app/models/blob_viewer/contributing.rb b/app/models/blob_viewer/contributing.rb new file mode 100644 index 00000000000..fbd1dd48697 --- /dev/null +++ b/app/models/blob_viewer/contributing.rb @@ -0,0 +1,10 @@ +module BlobViewer + class Contributing < Base + include Auxiliary + include Static + + self.partial_name = 'contributing' + self.file_types = %i(contributing) + self.binary = false + end +end diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb new file mode 100644 index 00000000000..a8d9be945dc --- /dev/null +++ b/app/models/blob_viewer/dependency_manager.rb @@ -0,0 +1,43 @@ +module BlobViewer + class DependencyManager < Base + include Auxiliary + + self.partial_name = 'dependency_manager' + self.binary = false + + def manager_name + raise NotImplementedError + end + + def manager_url + raise NotImplementedError + end + + def package_type + 'package' + end + + def package_name + nil + end + + def package_url + nil + end + + private + + def package_name_from_json(key) + prepare! + + JSON.parse(blob.data)[key] rescue nil + end + + def package_name_from_method_call(name) + prepare! + + match = blob.data.match(/#{name}\s*=\s*["'](?<name>[^"']+)["']/) + match[:name] if match + end + end +end diff --git a/app/models/blob_viewer/download.rb b/app/models/blob_viewer/download.rb new file mode 100644 index 00000000000..074e7204814 --- /dev/null +++ b/app/models/blob_viewer/download.rb @@ -0,0 +1,9 @@ +module BlobViewer + class Download < Base + include Simple + include Static + + self.partial_name = 'download' + self.binary = true + end +end diff --git a/app/models/blob_viewer/empty.rb b/app/models/blob_viewer/empty.rb new file mode 100644 index 00000000000..d9d128eb273 --- /dev/null +++ b/app/models/blob_viewer/empty.rb @@ -0,0 +1,9 @@ +module BlobViewer + class Empty < Base + include Simple + include ServerSide + + self.partial_name = 'empty' + self.binary = true + end +end diff --git a/app/models/blob_viewer/gemfile.rb b/app/models/blob_viewer/gemfile.rb new file mode 100644 index 00000000000..fae8c8df23f --- /dev/null +++ b/app/models/blob_viewer/gemfile.rb @@ -0,0 +1,15 @@ +module BlobViewer + class Gemfile < DependencyManager + include Static + + self.file_types = %i(gemfile gemfile_lock) + + def manager_name + 'Bundler' + end + + def manager_url + 'http://bundler.io/' + end + end +end diff --git a/app/models/blob_viewer/gemspec.rb b/app/models/blob_viewer/gemspec.rb new file mode 100644 index 00000000000..7802edeb754 --- /dev/null +++ b/app/models/blob_viewer/gemspec.rb @@ -0,0 +1,27 @@ +module BlobViewer + class Gemspec < DependencyManager + include ServerSide + + self.file_types = %i(gemspec) + + def manager_name + 'RubyGems' + end + + def manager_url + 'https://rubygems.org/' + end + + def package_type + 'gem' + end + + def package_name + @package_name ||= package_name_from_method_call('name') + end + + def package_url + "https://rubygems.org/gems/#{package_name}" + end + end +end diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb new file mode 100644 index 00000000000..7267c3965d3 --- /dev/null +++ b/app/models/blob_viewer/gitlab_ci_yml.rb @@ -0,0 +1,23 @@ +module BlobViewer + class GitlabCiYml < Base + include ServerSide + include Auxiliary + + self.partial_name = 'gitlab_ci_yml' + self.loading_partial_name = 'gitlab_ci_yml_loading' + self.file_types = %i(gitlab_ci) + self.binary = false + + def validation_message + return @validation_message if defined?(@validation_message) + + prepare! + + @validation_message = Ci::GitlabCiYamlProcessor.validation_message(blob.data) + end + + def valid? + validation_message.blank? + end + end +end diff --git a/app/models/blob_viewer/godeps_json.rb b/app/models/blob_viewer/godeps_json.rb new file mode 100644 index 00000000000..e19a602603b --- /dev/null +++ b/app/models/blob_viewer/godeps_json.rb @@ -0,0 +1,15 @@ +module BlobViewer + class GodepsJson < DependencyManager + include Static + + self.file_types = %i(godeps_json) + + def manager_name + 'godep' + end + + def manager_url + 'https://github.com/tools/godep' + end + end +end diff --git a/app/models/blob_viewer/image.rb b/app/models/blob_viewer/image.rb new file mode 100644 index 00000000000..c4eae5c79c2 --- /dev/null +++ b/app/models/blob_viewer/image.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Image < Base + include Rich + include ClientSide + + self.partial_name = 'image' + self.extensions = UploaderHelper::IMAGE_EXT + self.binary = true + self.switcher_icon = 'picture-o' + self.switcher_title = 'image' + end +end diff --git a/app/models/blob_viewer/license.rb b/app/models/blob_viewer/license.rb new file mode 100644 index 00000000000..57355f2c3aa --- /dev/null +++ b/app/models/blob_viewer/license.rb @@ -0,0 +1,20 @@ +module BlobViewer + class License < Base + include Auxiliary + include Static + + self.partial_name = 'license' + self.file_types = %i(license) + self.binary = false + + def license + project.repository.license + end + + def render_error + return if license + + :unknown_license + end + end +end diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb new file mode 100644 index 00000000000..33b59c4f512 --- /dev/null +++ b/app/models/blob_viewer/markup.rb @@ -0,0 +1,11 @@ +module BlobViewer + class Markup < Base + include Rich + include ServerSide + + self.partial_name = 'markup' + self.extensions = Gitlab::MarkupHelper::EXTENSIONS + self.file_types = %i(readme) + self.binary = false + end +end diff --git a/app/models/blob_viewer/notebook.rb b/app/models/blob_viewer/notebook.rb new file mode 100644 index 00000000000..8632b8a9885 --- /dev/null +++ b/app/models/blob_viewer/notebook.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Notebook < Base + include Rich + include ClientSide + + self.partial_name = 'notebook' + self.extensions = %w(ipynb) + self.binary = false + self.switcher_icon = 'file-text-o' + self.switcher_title = 'notebook' + end +end diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb new file mode 100644 index 00000000000..09221efb56c --- /dev/null +++ b/app/models/blob_viewer/package_json.rb @@ -0,0 +1,23 @@ +module BlobViewer + class PackageJson < DependencyManager + include ServerSide + + self.file_types = %i(package_json) + + def manager_name + 'npm' + end + + def manager_url + 'https://www.npmjs.com/' + end + + def package_name + @package_name ||= package_name_from_json('name') + end + + def package_url + "https://www.npmjs.com/package/#{package_name}" + end + end +end diff --git a/app/models/blob_viewer/pdf.rb b/app/models/blob_viewer/pdf.rb new file mode 100644 index 00000000000..65805f5f388 --- /dev/null +++ b/app/models/blob_viewer/pdf.rb @@ -0,0 +1,12 @@ +module BlobViewer + class PDF < Base + include Rich + include ClientSide + + self.partial_name = 'pdf' + self.extensions = %w(pdf) + self.binary = true + self.switcher_icon = 'file-pdf-o' + self.switcher_title = 'PDF' + end +end diff --git a/app/models/blob_viewer/podfile.rb b/app/models/blob_viewer/podfile.rb new file mode 100644 index 00000000000..507bc734cb4 --- /dev/null +++ b/app/models/blob_viewer/podfile.rb @@ -0,0 +1,15 @@ +module BlobViewer + class Podfile < DependencyManager + include Static + + self.file_types = %i(podfile) + + def manager_name + 'CocoaPods' + end + + def manager_url + 'https://cocoapods.org/' + end + end +end diff --git a/app/models/blob_viewer/podspec.rb b/app/models/blob_viewer/podspec.rb new file mode 100644 index 00000000000..a4c242db3a9 --- /dev/null +++ b/app/models/blob_viewer/podspec.rb @@ -0,0 +1,27 @@ +module BlobViewer + class Podspec < DependencyManager + include ServerSide + + self.file_types = %i(podspec) + + def manager_name + 'CocoaPods' + end + + def manager_url + 'https://cocoapods.org/' + end + + def package_type + 'pod' + end + + def package_name + @package_name ||= package_name_from_method_call('name') + end + + def package_url + "https://cocoapods.org/pods/#{package_name}" + end + end +end diff --git a/app/models/blob_viewer/podspec_json.rb b/app/models/blob_viewer/podspec_json.rb new file mode 100644 index 00000000000..602f4a51fd9 --- /dev/null +++ b/app/models/blob_viewer/podspec_json.rb @@ -0,0 +1,9 @@ +module BlobViewer + class PodspecJson < Podspec + self.file_types = %i(podspec_json) + + def package_name + @package_name ||= package_name_from_json('name') + end + end +end diff --git a/app/models/blob_viewer/readme.rb b/app/models/blob_viewer/readme.rb new file mode 100644 index 00000000000..75c373a03bb --- /dev/null +++ b/app/models/blob_viewer/readme.rb @@ -0,0 +1,14 @@ +module BlobViewer + class Readme < Base + include Auxiliary + include Static + + self.partial_name = 'readme' + self.file_types = %i(readme) + self.binary = false + + def visible_to?(current_user) + can?(current_user, :read_wiki, project) + end + end +end diff --git a/app/models/blob_viewer/requirements_txt.rb b/app/models/blob_viewer/requirements_txt.rb new file mode 100644 index 00000000000..83ac55f61d0 --- /dev/null +++ b/app/models/blob_viewer/requirements_txt.rb @@ -0,0 +1,15 @@ +module BlobViewer + class RequirementsTxt < DependencyManager + include Static + + self.file_types = %i(requirements_txt) + + def manager_name + 'pip' + end + + def manager_url + 'https://pip.pypa.io/' + end + end +end diff --git a/app/models/blob_viewer/rich.rb b/app/models/blob_viewer/rich.rb new file mode 100644 index 00000000000..be373dbc948 --- /dev/null +++ b/app/models/blob_viewer/rich.rb @@ -0,0 +1,11 @@ +module BlobViewer + module Rich + extend ActiveSupport::Concern + + included do + self.type = :rich + self.switcher_icon = 'file-text-o' + self.switcher_title = 'rendered file' + end + end +end diff --git a/app/models/blob_viewer/route_map.rb b/app/models/blob_viewer/route_map.rb new file mode 100644 index 00000000000..153b4eeb2c9 --- /dev/null +++ b/app/models/blob_viewer/route_map.rb @@ -0,0 +1,30 @@ +module BlobViewer + class RouteMap < Base + include ServerSide + include Auxiliary + + self.partial_name = 'route_map' + self.loading_partial_name = 'route_map_loading' + self.file_types = %i(route_map) + self.binary = false + + def validation_message + return @validation_message if defined?(@validation_message) + + prepare! + + @validation_message = + begin + Gitlab::RouteMap.new(blob.data) + + nil + rescue Gitlab::RouteMap::FormatError => e + e.message + end + end + + def valid? + validation_message.blank? + end + end +end diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb new file mode 100644 index 00000000000..87884dcd6bf --- /dev/null +++ b/app/models/blob_viewer/server_side.rb @@ -0,0 +1,30 @@ +module BlobViewer + module ServerSide + extend ActiveSupport::Concern + + included do + self.load_async = true + self.overridable_max_size = 2.megabytes + self.max_size = 5.megabytes + end + + def prepare! + if blob.project + blob.load_all_data!(blob.project.repository) + end + end + + def render_error + if blob.stored_externally? + # Files that are not stored in the repository, like LFS files and + # build artifacts, can only be rendered using a client-side viewer, + # since we do not want to read large amounts of data into memory on the + # server side. Client-side viewers use JS and can fetch the file from + # `blob_raw_url` using AJAX. + return :server_side_but_stored_externally + end + + super + end + end +end diff --git a/app/models/blob_viewer/simple.rb b/app/models/blob_viewer/simple.rb new file mode 100644 index 00000000000..454a20495fc --- /dev/null +++ b/app/models/blob_viewer/simple.rb @@ -0,0 +1,11 @@ +module BlobViewer + module Simple + extend ActiveSupport::Concern + + included do + self.type = :simple + self.switcher_icon = 'code' + self.switcher_title = 'source' + end + end +end diff --git a/app/models/blob_viewer/sketch.rb b/app/models/blob_viewer/sketch.rb new file mode 100644 index 00000000000..818456778e1 --- /dev/null +++ b/app/models/blob_viewer/sketch.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Sketch < Base + include Rich + include ClientSide + + self.partial_name = 'sketch' + self.extensions = %w(sketch) + self.binary = true + self.switcher_icon = 'file-image-o' + self.switcher_title = 'preview' + end +end diff --git a/app/models/blob_viewer/static.rb b/app/models/blob_viewer/static.rb new file mode 100644 index 00000000000..c9e257e5388 --- /dev/null +++ b/app/models/blob_viewer/static.rb @@ -0,0 +1,14 @@ +module BlobViewer + module Static + extend ActiveSupport::Concern + + included do + self.load_async = false + end + + # We can always render a static viewer, even if the blob is too large. + def render_error + nil + end + end +end diff --git a/app/models/blob_viewer/svg.rb b/app/models/blob_viewer/svg.rb new file mode 100644 index 00000000000..b7e5cd71e6b --- /dev/null +++ b/app/models/blob_viewer/svg.rb @@ -0,0 +1,12 @@ +module BlobViewer + class SVG < Base + include Rich + include ServerSide + + self.partial_name = 'svg' + self.extensions = %w(svg) + self.binary = false + self.switcher_icon = 'picture-o' + self.switcher_title = 'image' + end +end diff --git a/app/models/blob_viewer/text.rb b/app/models/blob_viewer/text.rb new file mode 100644 index 00000000000..eddca50b4d4 --- /dev/null +++ b/app/models/blob_viewer/text.rb @@ -0,0 +1,11 @@ +module BlobViewer + class Text < Base + include Simple + include ServerSide + + self.partial_name = 'text' + self.binary = false + self.overridable_max_size = 1.megabyte + self.max_size = 10.megabytes + end +end diff --git a/app/models/blob_viewer/text_stl.rb b/app/models/blob_viewer/text_stl.rb new file mode 100644 index 00000000000..8184dc0104c --- /dev/null +++ b/app/models/blob_viewer/text_stl.rb @@ -0,0 +1,5 @@ +module BlobViewer + class TextSTL < BinarySTL + self.binary = false + end +end diff --git a/app/models/blob_viewer/video.rb b/app/models/blob_viewer/video.rb new file mode 100644 index 00000000000..057f9fe516f --- /dev/null +++ b/app/models/blob_viewer/video.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Video < Base + include Rich + include ClientSide + + self.partial_name = 'video' + self.extensions = UploaderHelper::VIDEO_EXT + self.binary = true + self.switcher_icon = 'film' + self.switcher_title = 'video' + end +end diff --git a/app/models/blob_viewer/yarn_lock.rb b/app/models/blob_viewer/yarn_lock.rb new file mode 100644 index 00000000000..31588ddcbab --- /dev/null +++ b/app/models/blob_viewer/yarn_lock.rb @@ -0,0 +1,15 @@ +module BlobViewer + class YarnLock < DependencyManager + include Static + + self.file_types = %i(yarn_lock) + + def manager_name + 'Yarn' + end + + def manager_url + 'https://yarnpkg.com/' + end + end +end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb new file mode 100644 index 00000000000..b35febc9ac5 --- /dev/null +++ b/app/models/ci/artifact_blob.rb @@ -0,0 +1,35 @@ +module Ci + class ArtifactBlob + include BlobLike + + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + delegate :name, :path, to: :entry + + def id + Digest::SHA1.hexdigest(path) + end + + def size + entry.metadata[:size] + end + + def data + "Build artifact #{path}" + end + + def mode + entry.metadata[:mode] + end + + def external_storage + :build_artifact + end + + alias_method :external_size, :size + end +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index dbe4a2bf43f..1581ba9e55d 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -103,27 +103,17 @@ module Ci end def playable? - project.builds_enabled? && has_commands? && - action? && manual? + action? && manual? end def action? self.when == 'manual' end - def has_commands? - commands.present? - end - def play(current_user) - # Try to queue a current build - if self.enqueue - self.update(user: current_user) - self - else - # Otherwise we need to create a duplicate - Ci::Build.retry(self, current_user) - end + Ci::PlayBuildService + .new(project, current_user) + .execute(self) end def cancelable? @@ -131,12 +121,11 @@ module Ci end def retryable? - project.builds_enabled? && has_commands? && - (success? || failed? || canceled?) + success? || failed? || canceled? end - def retried? - !self.pipeline.statuses.latest.include?(self) + def latest? + !retried? end def expanded_environment_name @@ -171,19 +160,6 @@ module Ci latest_builds.where('stage_idx < ?', stage_idx) end - def trace_html(**args) - trace_with_state(**args)[:html] || '' - end - - def trace_with_state(state: nil, last_lines: nil) - trace_ansi = trace(last_lines: last_lines) - if trace_ansi.present? - Ci::Ansi2html.convert(trace_ansi, state) - else - {} - end - end - def timeout project.build_timeout end @@ -244,136 +220,35 @@ module Ci end def update_coverage - coverage = extract_coverage(trace, coverage_regex) + coverage = trace.extract_coverage(coverage_regex) update_attributes(coverage: coverage) if coverage.present? end - def extract_coverage(text, regex) - return unless regex - - matches = text.scan(Regexp.new(regex)).last - matches = matches.last if matches.is_a?(Array) - coverage = matches.gsub(/\d+(\.\d+)?/).first - - if coverage.present? - coverage.to_f - end - rescue - # if bad regex or something goes wrong we dont want to interrupt transition - # so we just silentrly ignore error for now - end - - def has_trace_file? - File.exist?(path_to_trace) || has_old_trace_file? + def trace + Gitlab::Ci::Trace.new(self) end def has_trace? - raw_trace.present? - end - - def raw_trace(last_lines: nil) - if File.exist?(trace_file_path) - Gitlab::Ci::TraceReader.new(trace_file_path). - read(last_lines: last_lines) - else - # backward compatibility - read_attribute :trace - end - end - - ## - # Deprecated - # - # This is a hotfix for CI build data integrity, see #4246 - def has_old_trace_file? - project.ci_id && File.exist?(old_path_to_trace) - end - - def trace(last_lines: nil) - hide_secrets(raw_trace(last_lines: last_lines)) - end - - def trace_length - if raw_trace - raw_trace.bytesize - else - 0 - end + trace.exist? end - def trace=(trace) - recreate_trace_dir - trace = hide_secrets(trace) - File.write(path_to_trace, trace) + def trace=(data) + raise NotImplementedError end - def recreate_trace_dir - unless Dir.exist?(dir_to_trace) - FileUtils.mkdir_p(dir_to_trace) - end + def old_trace + read_attribute(:trace) end - private :recreate_trace_dir - - def append_trace(trace_part, offset) - recreate_trace_dir - touch if needs_touch? - - trace_part = hide_secrets(trace_part) - File.truncate(path_to_trace, offset) if File.exist?(path_to_trace) - File.open(path_to_trace, 'ab') do |f| - f.write(trace_part) - end + def erase_old_trace! + write_attribute(:trace, nil) + save end def needs_touch? Time.now - updated_at > 15.minutes.to_i end - def trace_file_path - if has_old_trace_file? - old_path_to_trace - else - path_to_trace - end - end - - def dir_to_trace - File.join( - Settings.gitlab_ci.builds_path, - created_at.utc.strftime("%Y_%m"), - project.id.to_s - ) - end - - def path_to_trace - "#{dir_to_trace}/#{id}.log" - end - - ## - # Deprecated - # - # This is a hotfix for CI build data integrity, see #4246 - # Should be removed in 8.4, after CI files migration has been done. - # - def old_dir_to_trace - File.join( - Settings.gitlab_ci.builds_path, - created_at.utc.strftime("%Y_%m"), - project.ci_id.to_s - ) - end - - ## - # Deprecated - # - # This is a hotfix for CI build data integrity, see #4246 - # Should be removed in 8.4, after CI files migration has been done. - # - def old_path_to_trace - "#{old_dir_to_trace}/#{id}.log" - end - ## # Deprecated # @@ -425,8 +300,8 @@ module Ci def execute_hooks return unless project build_data = Gitlab::DataBuilder::Build.build(self) - project.execute_hooks(build_data.dup, :build_hooks) - project.execute_services(build_data.dup, :build_hooks) + project.execute_hooks(build_data.dup, :job_hooks) + project.execute_services(build_data.dup, :job_hooks) PagesService.new(build_data).execute project.running_or_pending_build_count(force: true) end @@ -540,6 +415,8 @@ module Ci end def dependencies + return [] if empty_dependencies? + depended_jobs = depends_on_builds return depended_jobs unless options[:dependencies].present? @@ -549,6 +426,19 @@ module Ci end end + def empty_dependencies? + options[:dependencies]&.empty? + end + + def hide_secrets(trace) + return unless trace + + trace = trace.dup + Ci::MaskSecret.mask!(trace, project.runners_token) if project + Ci::MaskSecret.mask!(trace, token) + trace + end + private def update_artifacts_size @@ -560,7 +450,7 @@ module Ci end def erase_trace! - self.trace = nil + trace.erase! end def update_erased!(user = nil) @@ -622,15 +512,6 @@ module Ci pipeline.config_processor.build_attributes(name) end - def hide_secrets(trace) - return unless trace - - trace = trace.dup - Ci::MaskSecret.mask!(trace, project.runners_token) if project - Ci::MaskSecret.mask!(trace, token) - trace - end - def update_project_statistics return unless project diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb new file mode 100644 index 00000000000..87898b086c6 --- /dev/null +++ b/app/models/ci/group.rb @@ -0,0 +1,40 @@ +module Ci + ## + # This domain model is a representation of a group of jobs that are related + # to each other, like `rspec 0 1`, `rspec 0 2`. + # + # It is not persisted in the database. + # + class Group + include StaticModel + + attr_reader :stage, :name, :jobs + + delegate :size, to: :jobs + + def initialize(stage, name:, jobs:) + @stage = stage + @name = name + @jobs = jobs + end + + def status + @status ||= commit_statuses.status + end + + def detailed_status(current_user) + if jobs.one? + jobs.first.detailed_status(current_user) + else + Gitlab::Ci::Status::Group::Factory + .new(self, current_user).fabricate! + end + end + + private + + def commit_statuses + @commit_statuses ||= CommitStatus.where(id: jobs.map(&:id)) + end + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index e079072a23f..fa1312154ca 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -4,14 +4,30 @@ module Ci include HasStatus include Importable include AfterCommitQueue + include Presentable belongs_to :project belongs_to :user + belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' + belongs_to :pipeline_schedule, class_name: 'Ci::PipelineSchedule' + + has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' + has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id has_many :builds, foreign_key: :commit_id has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id + # Merge requests for which the current pipeline is running against + # the merge request's latest commit. + has_many :merge_requests, foreign_key: "head_pipeline_id" + + has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build' + has_many :retryable_builds, -> { latest.failed_or_canceled }, foreign_key: :commit_id, class_name: 'Ci::Build' + has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus' + has_many :manual_actions, -> { latest.manual_actions }, foreign_key: :commit_id, class_name: 'Ci::Build' + has_many :artifacts, -> { latest.with_artifacts_not_expired }, foreign_key: :commit_id, class_name: 'Ci::Build' + delegate :id, to: :project, prefix: true validates :sha, presence: { unless: :importing? } @@ -20,7 +36,6 @@ module Ci validate :valid_commit_sha, unless: :importing? after_create :keep_around_commits, unless: :importing? - after_create :refresh_build_status_cache state_machine :status, initial: :created do event :enqueue do @@ -65,23 +80,32 @@ module Ci pipeline.update_duration end + before_transition any => [:manual] do |pipeline| + pipeline.update_duration + end + + before_transition canceled: any - [:canceled] do |pipeline| + pipeline.auto_canceled_by = nil + end + after_transition [:created, :pending] => :running do |pipeline| - pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) } + pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end after_transition any => [:success] do |pipeline| - pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) } + pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end after_transition [:created, :pending, :running] => :success do |pipeline| - pipeline.run_after_commit { PipelineSuccessWorker.perform_async(id) } + pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) } end after_transition do |pipeline, transition| next if transition.loopback? pipeline.run_after_commit do - PipelineHooksWorker.perform_async(id) + PipelineHooksWorker.perform_async(pipeline.id) + ExpirePipelineCacheWorker.perform_async(pipeline.id) end end @@ -247,11 +271,37 @@ module Ci statuses_with(status: HasStatus::CANCELABLE_STATUSES).any? end + def stuck? + pending_builds.any?(&:stuck?) + end + + def retryable? + retryable_builds.any? + end + + def cancelable? + cancelable_statuses.any? + end + + def auto_canceled? + canceled? && auto_canceled_by_id? + end + def cancel_running - Gitlab::OptimisticLocking.retry_lock( - statuses.cancelable) do |cancelable| - cancelable.find_each(&:cancel) + Gitlab::OptimisticLocking.retry_lock(cancelable_statuses) do |cancelable| + cancelable.find_each do |job| + yield(job) if block_given? + job.cancel end + end + end + + def auto_cancel_running(pipeline) + update(auto_canceled_by: pipeline) + + cancel_running do |job| + job.auto_canceled_by = pipeline + end end def retry_failed(current_user) @@ -359,7 +409,6 @@ module Ci when 'manual' then block end end - refresh_build_status_cache end def predefined_variables @@ -387,12 +436,9 @@ module Ci project.execute_services(data, :pipeline_hooks) end - # Merge requests for which the current pipeline is running against - # the merge request's latest commit. - def merge_requests - @merge_requests ||= project.merge_requests - .where(source_branch: self.ref) - .select { |merge_request| merge_request.head_pipeline.try(:id) == self.id } + # All the merge requests for which the current pipeline runs/ran against + def all_merge_requests + @all_merge_requests ||= project.merge_requests.where(source_branch: ref) end def detailed_status(current_user) @@ -401,10 +447,6 @@ module Ci .fabricate! end - def refresh_build_status_cache - Ci::PipelineStatus.new(project, sha: sha, status: status).store_in_cache_if_needed - end - private def pipeline_data diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb new file mode 100644 index 00000000000..cf6e53c4ca4 --- /dev/null +++ b/app/models/ci/pipeline_schedule.rb @@ -0,0 +1,60 @@ +module Ci + class PipelineSchedule < ActiveRecord::Base + extend Ci::Model + include Importable + + acts_as_paranoid + + belongs_to :project + belongs_to :owner, class_name: 'User' + has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline' + has_many :pipelines + + validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? } + validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? } + validates :ref, presence: { unless: :importing_or_inactive? } + validates :description, presence: true + + before_save :set_next_run_at + + scope :active, -> { where(active: true) } + scope :inactive, -> { where(active: false) } + + def owned_by?(current_user) + owner == current_user + end + + def inactive? + !active? + end + + def deactivate! + update_attribute(:active, false) + end + + def importing_or_inactive? + importing? || inactive? + end + + def runnable_by_owner? + Ability.allowed?(owner, :create_pipeline, project) + end + + def set_next_run_at + self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) + end + + def schedule_next_run! + save! # with set_next_run_at + rescue ActiveRecord::RecordInvalid + update_attribute(:next_run_at, nil) # update without validation + end + + def real_next_run( + worker_cron: Settings.cron_jobs['pipeline_schedule_worker']['cron'], + worker_time_zone: Time.zone.name) + Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone) + .next_time_from(next_run_at) + end + end +end diff --git a/app/models/ci/pipeline_status.rb b/app/models/ci/pipeline_status.rb deleted file mode 100644 index 048047d0e34..00000000000 --- a/app/models/ci/pipeline_status.rb +++ /dev/null @@ -1,86 +0,0 @@ -# This class is not backed by a table in the main database. -# It loads the latest Pipeline for the HEAD of a repository, and caches that -# in Redis. -module Ci - class PipelineStatus - attr_accessor :sha, :status, :project, :loaded - - delegate :commit, to: :project - - def self.load_for_project(project) - new(project).tap do |status| - status.load_status - end - end - - def initialize(project, sha: nil, status: nil) - @project = project - @sha = sha - @status = status - end - - def has_status? - loaded? && sha.present? && status.present? - end - - def load_status - return if loaded? - - if has_cache? - load_from_cache - else - load_from_commit - store_in_cache - end - - self.loaded = true - end - - def load_from_commit - return unless commit - - self.sha = commit.sha - self.status = commit.status - end - - # We only cache the status for the HEAD commit of a project - # This status is rendered in project lists - def store_in_cache_if_needed - return unless sha - return delete_from_cache unless commit - store_in_cache if commit.sha == self.sha - end - - def load_from_cache - Gitlab::Redis.with do |redis| - self.sha, self.status = redis.hmget(cache_key, :sha, :status) - end - end - - def store_in_cache - Gitlab::Redis.with do |redis| - redis.mapped_hmset(cache_key, { sha: sha, status: status }) - end - end - - def delete_from_cache - Gitlab::Redis.with do |redis| - redis.del(cache_key) - end - end - - def has_cache? - Gitlab::Redis.with do |redis| - redis.exists(cache_key) - end - end - - def loaded? - self.loaded - end - - def cache_key - "projects/#{project.id}/build_status" - end - end -end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index e7d6b17d445..9bda3186c30 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -15,6 +15,14 @@ module Ci @warnings = warnings end + def groups + @groups ||= statuses.ordered.latest + .sort_by(&:sortable_name).group_by(&:group_name) + .map do |group_name, grouped_statuses| + Ci::Group.new(self, name: group_name, jobs: grouped_statuses) + end + end + def to_param name end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index cba1d81a861..6df41a3f301 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -7,7 +7,7 @@ module Ci belongs_to :project belongs_to :owner, class_name: "User" - has_many :trigger_requests, dependent: :destroy + has_many :trigger_requests validates :token, presence: true, uniqueness: true diff --git a/app/models/commit.rb b/app/models/commit.rb index e71f1769255..2b8a6fdd4ab 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -2,6 +2,7 @@ class Commit extend ActiveModel::Naming include ActiveModel::Conversion + include Noteable include Participable include Mentionable include Referable @@ -48,7 +49,7 @@ class Commit def max_diff_options { max_files: DIFF_HARD_LIMIT_FILES, - max_lines: DIFF_HARD_LIMIT_LINES, + max_lines: DIFF_HARD_LIMIT_LINES } end @@ -200,6 +201,10 @@ class Commit project.notes.for_commit_id(self.id) end + def discussion_notes + notes.non_diff_notes + end + def notes_with_associations notes.includes(:author) end @@ -228,8 +233,8 @@ class Commit project.pipelines.where(sha: sha) end - def latest_pipeline - pipelines.last + def last_pipeline + @last_pipeline ||= pipelines.last end def status(ref = nil) @@ -308,7 +313,7 @@ class Commit def uri_type(path) entry = @raw.tree.path(path) if entry[:type] == :blob - blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name])) + blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project) blob.image? || blob.video? ? :raw : :blob else entry[:type] @@ -318,16 +323,23 @@ class Commit end def raw_diffs(*args) - use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) - deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only] - - if use_gitaly && !deltas_only - Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) + if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + Gitlab::GitalyClient::Commit.new(project.repository).diff_from_parent(self, *args) else raw.diffs(*args) end end + def raw_deltas + @deltas ||= Gitlab::GitalyClient.migrate(:commit_deltas) do |is_enabled| + if is_enabled + Gitlab::GitalyClient::Commit.new(project.repository).commit_deltas(self) + else + raw.deltas + end + end + end + def diffs(diff_options = nil) Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options) end @@ -383,7 +395,7 @@ class Commit def repo_changes changes = { added: [], modified: [], removed: [] } - raw_diffs(deltas_only: true).each do |diff| + raw_deltas.each do |diff| if diff.deleted_file changes[:removed] << diff.old_path elsif diff.renamed_file || diff.new_file diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 17b322b5ae3..ffafc678968 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -7,6 +7,7 @@ class CommitStatus < ActiveRecord::Base belongs_to :project belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id + belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' belongs_to :user delegate :commit, to: :pipeline @@ -17,13 +18,7 @@ class CommitStatus < ActiveRecord::Base validates :name, presence: true alias_attribute :author, :user - - scope :latest, -> do - max_id = unscope(:select).select("max(#{quoted_table_name}.id)") - - where(id: max_id.group(:name, :commit_id)) - end - + scope :failed_but_allowed, -> do where(allow_failure: true, status: [:failed, :canceled]) end @@ -36,7 +31,8 @@ class CommitStatus < ActiveRecord::Base false, all_state_names - [:failed, :canceled, :manual]) end - scope :retried, -> { where.not(id: latest) } + scope :latest, -> { where(retried: [false, nil]) } + scope :retried, -> { where(retried: true) } scope :ordered, -> { order(:name) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } @@ -137,10 +133,8 @@ class CommitStatus < ActiveRecord::Base false end - # Added in 9.0 to keep backward compatibility for projects exported in 8.17 - # and prior. - def gl_project_id - 'dummy' + def auto_canceled? + canceled? && auto_canceled_by_id? end def detailed_status(current_user) diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb new file mode 100644 index 00000000000..8fbfed11bdf --- /dev/null +++ b/app/models/concerns/avatarable.rb @@ -0,0 +1,18 @@ +module Avatarable + extend ActiveSupport::Concern + + def avatar_path(only_path: true) + return unless self[:avatar].present? + + # If only_path is true then use the relative path of avatar. + # Otherwise use full path (including host). + asset_host = ActionController::Base.asset_host + gitlab_host = only_path ? gitlab_config.relative_url_root : gitlab_config.url + + # If asset_host is set then it is expected that assets are handled by a standalone host. + # That means we do not want to get GitLab's relative_url_root option anymore. + host = asset_host.present? ? asset_host : gitlab_host + + [host, avatar.url].join + end +end diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb new file mode 100644 index 00000000000..adb81561000 --- /dev/null +++ b/app/models/concerns/blob_like.rb @@ -0,0 +1,48 @@ +module BlobLike + extend ActiveSupport::Concern + include Linguist::BlobHelper + + def id + raise NotImplementedError + end + + def name + raise NotImplementedError + end + + def path + raise NotImplementedError + end + + def size + 0 + end + + def data + nil + end + + def mode + nil + end + + def binary? + false + end + + def load_all_data!(repository) + # No-op + end + + def truncated? + false + end + + def external_storage + nil + end + + def external_size + nil + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 8ea95beed79..eb32bf3d32a 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -8,6 +8,14 @@ # # Corresponding foo_html, bar_html and baz_html fields should exist. module CacheMarkdownField + extend ActiveSupport::Concern + + # Increment this number every time the renderer changes its output + CACHE_VERSION = 1 + + # changes to these attributes cause the cache to be invalidates + INVALIDATED_BY = %w[author project].freeze + # Knows about the relationship between markdown and html field names, and # stores the rendering contexts for the latter class FieldData @@ -30,60 +38,74 @@ module CacheMarkdownField end end - # Dynamic registries don't really work in Rails as it's not guaranteed that - # every class will be loaded, so hardcode the list. - CACHING_CLASSES = %w[ - AbuseReport - Appearance - ApplicationSetting - BroadcastMessage - Issue - Label - MergeRequest - Milestone - Namespace - Note - Project - Release - Snippet - ].freeze - - def self.caching_classes - CACHING_CLASSES.map(&:constantize) - end - def skip_project_check? false end - extend ActiveSupport::Concern + # Returns the default Banzai render context for the cached markdown field. + def banzai_render_context(field) + raise ArgumentError.new("Unknown field: #{field.inspect}") unless + cached_markdown_fields.markdown_fields.include?(field) - included do - cattr_reader :cached_markdown_fields do - FieldData.new - end + # Always include a project key, or Banzai complains + project = self.project if self.respond_to?(:project) + context = cached_markdown_fields[field].merge(project: project) - # Returns the default Banzai render context for the cached markdown field. - def banzai_render_context(field) - raise ArgumentError.new("Unknown field: #{field.inspect}") unless - cached_markdown_fields.markdown_fields.include?(field) + # Banzai is less strict about authors, so don't always have an author key + context[:author] = self.author if self.respond_to?(:author) - # Always include a project key, or Banzai complains - project = self.project if self.respond_to?(:project) - context = cached_markdown_fields[field].merge(project: project) + context + end - # Banzai is less strict about authors, so don't always have an author key - context[:author] = self.author if self.respond_to?(:author) + # Update every column in a row if any one is invalidated, as we only store + # one version per row + def refresh_markdown_cache!(do_update: false) + options = { skip_project_check: skip_project_check? } - context - end + updates = cached_markdown_fields.markdown_fields.map do |markdown_field| + [ + cached_markdown_fields.html_field(markdown_field), + Banzai::Renderer.cacheless_render_field(self, markdown_field, options) + ] + end.to_h + updates['cached_markdown_version'] = CacheMarkdownField::CACHE_VERSION + + updates.each {|html_field, data| write_attribute(html_field, data) } + + update_columns(updates) if persisted? && do_update + end + + def cached_html_up_to_date?(markdown_field) + html_field = cached_markdown_fields.html_field(markdown_field) + + cached = !cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil? + return false unless cached - # Allow callers to look up the cache field name, rather than hardcoding it - def markdown_cache_field_for(field) - raise ArgumentError.new("Unknown field: #{field}") unless - cached_markdown_fields.markdown_fields.include?(field) + markdown_changed = attribute_changed?(markdown_field) || false + html_changed = attribute_changed?(html_field) || false - cached_markdown_fields.html_field(field) + CacheMarkdownField::CACHE_VERSION == cached_markdown_version && + (html_changed || markdown_changed == html_changed) + end + + def invalidated_markdown_cache? + cached_markdown_fields.html_fields.any? {|html_field| attribute_invalidated?(html_field) } + end + + def attribute_invalidated?(attr) + __send__("#{attr}_invalidated?") + end + + def cached_html_for(markdown_field) + raise ArgumentError.new("Unknown field: #{field}") unless + cached_markdown_fields.markdown_fields.include?(markdown_field) + + __send__(cached_markdown_fields.html_field(markdown_field)) + end + + included do + cattr_reader :cached_markdown_fields do + FieldData.new end # Always exclude _html fields from attributes (including serialization). @@ -92,12 +114,18 @@ module CacheMarkdownField def attributes attrs = attributes_before_markdown_cache + attrs.delete('cached_markdown_version') + cached_markdown_fields.html_fields.each do |field| attrs.delete(field) end attrs end + + # Using before_update here conflicts with elasticsearch-model somehow + before_create :refresh_markdown_cache!, if: :invalidated_markdown_cache? + before_update :refresh_markdown_cache!, if: :invalidated_markdown_cache? end class_methods do @@ -107,31 +135,18 @@ module CacheMarkdownField # a corresponding _html field. Any custom rendering options may be provided # as a context. def cache_markdown_field(markdown_field, context = {}) - raise "Add #{self} to CacheMarkdownField::CACHING_CLASSES" unless - CacheMarkdownField::CACHING_CLASSES.include?(self.to_s) - cached_markdown_fields[markdown_field] = context html_field = cached_markdown_fields.html_field(markdown_field) - cache_method = "#{markdown_field}_cache_refresh".to_sym invalidation_method = "#{html_field}_invalidated?".to_sym - define_method(cache_method) do - options = { skip_project_check: skip_project_check? } - html = Banzai::Renderer.cacheless_render_field(self, markdown_field, options) - __send__("#{html_field}=", html) - true - end - # The HTML becomes invalid if any dependent fields change. For now, assume # author and project invalidate the cache in all circumstances. define_method(invalidation_method) do changed_fields = changed_attributes.keys - invalidations = changed_fields & [markdown_field.to_s, "author", "project"] - !invalidations.empty? + invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY] + !invalidations.empty? || !cached_html_up_to_date?(markdown_field) end - - before_save cache_method, if: invalidation_method end end end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb new file mode 100644 index 00000000000..a7bdf5587b2 --- /dev/null +++ b/app/models/concerns/discussion_on_diff.rb @@ -0,0 +1,50 @@ +# Contains functionality shared between `DiffDiscussion` and `LegacyDiffDiscussion`. +module DiscussionOnDiff + extend ActiveSupport::Concern + + NUMBER_OF_TRUNCATED_DIFF_LINES = 16 + + included do + delegate :line_code, + :original_line_code, + :diff_file, + :diff_line, + :for_line?, + :active?, + :created_at_diff?, + + to: :first_note + + delegate :file_path, + :blob, + :highlighted_diff_lines, + :diff_lines, + + to: :diff_file, + allow_nil: true + end + + def diff_discussion? + true + end + + # Returns an array of at most 16 highlighted lines above a diff note + def truncated_diff_lines(highlight: true) + lines = highlight ? highlighted_diff_lines : diff_lines + prev_lines = [] + + lines.each do |line| + if line.meta? + prev_lines.clear + else + prev_lines << line + + break if for_line?(line) + + prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES + end + end + + prev_lines + end +end diff --git a/app/models/concerns/ghost_user.rb b/app/models/concerns/ghost_user.rb new file mode 100644 index 00000000000..da696127a80 --- /dev/null +++ b/app/models/concerns/ghost_user.rb @@ -0,0 +1,7 @@ +module GhostUser + extend ActiveSupport::Concern + + def ghost_user? + user && user.ghost? + end +end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index f5f5e64bcbe..ebfffe82510 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -8,7 +8,7 @@ module HasStatus ACTIVE_STATUSES = %w[pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze ORDERED_STATUSES = %w[failed pending running manual canceled success skipped created].freeze - CANCELABLE_STATUSES = %w[running pending created manual].freeze + CANCELABLE_STATUSES = %w[running pending created].freeze class_methods do def status_sql @@ -69,7 +69,7 @@ module HasStatus end scope :created, -> { where(status: 'created') } - scope :relevant, -> { where.not(status: 'created') } + scope :relevant, -> { where(status: AVAILABLE_STATUSES - ['created']) } scope :running, -> { where(status: 'running') } scope :pending, -> { where(status: 'pending') } scope :success, -> { where(status: 'success') } @@ -77,6 +77,7 @@ module HasStatus scope :canceled, -> { where(status: 'canceled') } scope :skipped, -> { where(status: 'skipped') } scope :manual, -> { where(status: 'manual') } + scope :created_or_pending, -> { where(status: [:created, :pending]) } scope :running_or_pending, -> { where(status: [:running, :pending]) } scope :finished, -> { where(status: [:success, :failed, :canceled]) } scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) } diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb new file mode 100644 index 00000000000..eb9f3423e48 --- /dev/null +++ b/app/models/concerns/ignorable_column.rb @@ -0,0 +1,28 @@ +# Module that can be included into a model to make it easier to ignore database +# columns. +# +# Example: +# +# class User < ActiveRecord::Base +# include IgnorableColumn +# +# ignore_column :updated_at +# end +# +module IgnorableColumn + extend ActiveSupport::Concern + + module ClassMethods + def columns + super.reject { |column| ignored_columns.include?(column.name) } + end + + def ignored_columns + @ignored_columns ||= Set.new + end + + def ignore_column(name) + ignored_columns << name.to_s + end + end +end diff --git a/app/models/concerns/importable.rb b/app/models/concerns/importable.rb index 019ef755849..c9331eaf4cc 100644 --- a/app/models/concerns/importable.rb +++ b/app/models/concerns/importable.rb @@ -3,4 +3,7 @@ module Importable attr_accessor :importing alias_method :importing?, :importing + + attr_accessor :imported + alias_method :imported?, :imported end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 4d54426b79e..075ec575f9d 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -14,6 +14,7 @@ module Issuable include Awardable include Taskable include TimeTrackable + include Importable # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests @@ -22,11 +23,11 @@ module Issuable included do cache_markdown_field :title, pipeline: :single_line - cache_markdown_field :description + cache_markdown_field :description, issuable_state_filter_enabled: true belongs_to :author, class_name: "User" - belongs_to :assignee, class_name: "User" belongs_to :updated_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do def authors_loaded? @@ -64,11 +65,8 @@ module Issuable validates :title, presence: true, length: { maximum: 255 } scope :authored, ->(user) { where(author_id: user) } - scope :assigned_to, ->(u) { where(assignee_id: u.id)} scope :recent, -> { reorder(id: :desc) } scope :order_position_asc, -> { reorder(position: :asc) } - scope :assigned, -> { where("assignee_id IS NOT NULL") } - scope :unassigned, -> { where("assignee_id IS NULL") } scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_milestones, ->(ids) { where(milestone_id: ids) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } @@ -91,22 +89,13 @@ module Issuable attr_mentionable :description participant :author - participant :assignee participant :notes_with_associations strip_attributes :title acts_as_paranoid - after_save :update_assignee_cache_counts, if: :assignee_id_changed? - after_save :record_metrics - - def update_assignee_cache_counts - # make sure we flush the cache for both the old *and* new assignees(if they exist) - previous_assignee = User.find_by_id(assignee_id_was) if assignee_id_was - previous_assignee&.update_cache_counts - assignee&.update_cache_counts - end + after_save :record_metrics, unless: :imported? # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html @@ -236,10 +225,6 @@ module Issuable today? && created_at == updated_at end - def is_being_reassigned? - assignee_id_changed? - end - def open? opened? || reopened? end @@ -268,7 +253,11 @@ module Issuable # DEPRECATED repository: project.hook_attrs.slice(:name, :url, :description, :homepage) } - hook_data[:assignee] = assignee.hook_attrs if assignee + if self.is_a?(Issue) + hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any? + else + hook_data[:assignee] = assignee.hook_attrs if assignee + end hook_data end @@ -291,17 +280,6 @@ module Issuable self.class.to_ability_name end - # Convert this Issuable class name to a format usable by notifications. - # - # Examples: - # - # issuable.class # => MergeRequest - # issuable.human_class_name # => "merge request" - - def human_class_name - @human_class_name ||= self.class.name.titleize.downcase - end - # Returns a Hash of attributes to be used for Twitter card metadata def card_attributes { @@ -341,11 +319,6 @@ module Issuable false end - def assignee_or_author?(user) - # We're comparing IDs here so we don't need to load any associations. - author_id == user.id || assignee_id == user.id - end - def record_metrics metrics = self.metrics || create_metrics metrics.record! diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 7e56e371b27..c034bf9cbc0 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -44,14 +44,15 @@ module Mentionable end def all_references(current_user = nil, extractor: nil) + @extractors ||= {} + # Use custom extractor if it's passed in the function parameters. if extractor - @extractor = extractor + @extractors[current_user] = extractor else - @extractor ||= Gitlab::ReferenceExtractor. - new(project, current_user) + extractor = @extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user) - @extractor.reset_memoized_values + extractor.reset_memoized_values end self.class.mentionable_attrs.each do |attr, options| @@ -62,10 +63,10 @@ module Mentionable skip_project_check: skip_project_check? ) - @extractor.analyze(text, options) + extractor.analyze(text, options) end - @extractor + extractor end def mentioned_users(current_user = nil) @@ -78,6 +79,8 @@ module Mentionable # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference. def referenced_mentionables(current_user = self.author) + return [] unless matches_cross_reference_regex? + refs = all_references(current_user) refs = (refs.issues + refs.merge_requests + refs.commits) @@ -87,6 +90,20 @@ module Mentionable refs.reject { |ref| ref == local_reference } end + # Uses regex to quickly determine if mentionables might be referenced + # Allows heavy processing to be skipped + def matches_cross_reference_regex? + reference_pattern = if !project || project.default_issues_tracker? + ReferenceRegexes::DEFAULT_PATTERN + else + ReferenceRegexes::EXTERNAL_PATTERN + end + + self.class.mentionable_attrs.any? do |attr, _| + __send__(attr) =~ reference_pattern + end + end + # Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+. def create_cross_references!(author = self.author, without = []) refs = referenced_mentionables(author) diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb new file mode 100644 index 00000000000..1848230ec7e --- /dev/null +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -0,0 +1,22 @@ +module Mentionable + module ReferenceRegexes + def self.reference_pattern(link_patterns, issue_pattern) + Regexp.union(link_patterns, + issue_pattern, + Commit.reference_pattern, + MergeRequest.reference_pattern) + end + + DEFAULT_PATTERN = begin + issue_pattern = Issue.reference_pattern + link_patterns = Regexp.union([Issue, Commit, MergeRequest].map(&:link_reference_pattern)) + reference_pattern(link_patterns, issue_pattern) + end + + EXTERNAL_PATTERN = begin + issue_pattern = ExternalIssue.reference_pattern + link_patterns = URI.regexp(%w(http https)) + reference_pattern(link_patterns, issue_pattern) + end + end +end diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index f449229864d..a3472af5c55 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -40,7 +40,7 @@ module Milestoneish def issues_visible_to_user(user) memoize_per_user(user, :issues_visible_to_user) do IssuesFinder.new(user, issues_finder_params) - .execute.where(milestone_id: milestoneish_ids) + .execute.includes(:assignees).where(milestone_id: milestoneish_ids) end end diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index b8dd27a7afe..6359f7596b1 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -1,3 +1,4 @@ +# Contains functionality shared between `DiffNote` and `LegacyDiffNote`. module NoteOnDiff extend ActiveSupport::Concern @@ -25,11 +26,21 @@ module NoteOnDiff raise NotImplementedError end - def can_be_award_emoji? + def active?(diff_refs = nil) + raise NotImplementedError + end + + def created_at_diff?(diff_refs) false end - def to_discussion - Discussion.new([self]) + private + + def noteable_diff_refs + if noteable.respond_to?(:diff_sha_refs) + noteable.diff_sha_refs + else + noteable.diff_refs + end end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb new file mode 100644 index 00000000000..dd1e6630642 --- /dev/null +++ b/app/models/concerns/noteable.rb @@ -0,0 +1,68 @@ +module Noteable + # Names of all implementers of `Noteable` that support resolvable notes. + RESOLVABLE_TYPES = %w(MergeRequest).freeze + + def base_class_name + self.class.base_class.name + end + + # Convert this Noteable class name to a format usable by notifications. + # + # Examples: + # + # noteable.class # => MergeRequest + # noteable.human_class_name # => "merge request" + def human_class_name + @human_class_name ||= base_class_name.titleize.downcase + end + + def supports_resolvable_notes? + RESOLVABLE_TYPES.include?(base_class_name) + end + + def supports_discussions? + DiscussionNote::NOTEABLE_TYPES.include?(base_class_name) + end + + def discussion_notes + notes + end + + delegate :find_discussion, to: :discussion_notes + + def discussions + @discussions ||= discussion_notes + .inc_relations_for_view + .discussions(self) + end + + def grouped_diff_discussions(*args) + # Doesn't use `discussion_notes`, because this may include commit diff notes + # besides MR diff notes, that we do no want to display on the MR Changes tab. + notes.inc_relations_for_view.grouped_diff_discussions(*args) + end + + def resolvable_discussions + @resolvable_discussions ||= discussion_notes.resolvable.discussions(self) + end + + def discussions_resolvable? + resolvable_discussions.any?(&:resolvable?) + end + + def discussions_resolved? + discussions_resolvable? && resolvable_discussions.none?(&:to_be_resolved?) + end + + def discussions_to_be_resolved? + discussions_resolvable? && !discussions_resolved? + end + + def discussions_to_be_resolved + @discussions_to_be_resolved ||= resolvable_discussions.select(&:to_be_resolved?) + end + + def discussions_can_be_resolved_by?(user) + discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) } + end +end diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb index 9dd4d9c6f24..a40148a4394 100644 --- a/app/models/concerns/protected_branch_access.rb +++ b/app/models/concerns/protected_branch_access.rb @@ -2,20 +2,32 @@ module ProtectedBranchAccess extend ActiveSupport::Concern included do + include ProtectedRefAccess + belongs_to :protected_branch + delegate :project, to: :protected_branch - scope :master, -> { where(access_level: Gitlab::Access::MASTER) } - scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) } - end + validates :access_level, presence: true, inclusion: { + in: [ + Gitlab::Access::MASTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::NO_ACCESS + ] + } - def humanize - self.class.human_access_levels[self.access_level] - end + def self.human_access_levels + { + Gitlab::Access::MASTER => "Masters", + Gitlab::Access::DEVELOPER => "Developers + Masters", + Gitlab::Access::NO_ACCESS => "No one" + }.with_indifferent_access + end - def check_access(user) - return true if user.is_admin? + def check_access(user) + return false if access_level == Gitlab::Access::NO_ACCESS - project.team.max_member_access(user.id) >= access_level + super + end end end diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb new file mode 100644 index 00000000000..62eaec2407f --- /dev/null +++ b/app/models/concerns/protected_ref.rb @@ -0,0 +1,42 @@ +module ProtectedRef + extend ActiveSupport::Concern + + included do + belongs_to :project + + validates :name, presence: true + validates :project, presence: true + + delegate :matching, :matches?, :wildcard?, to: :ref_matcher + + def self.protected_ref_accessible_to?(ref, user, action:) + access_levels_for_ref(ref, action: action).any? do |access_level| + access_level.check_access(user) + end + end + + def self.developers_can?(action, ref) + access_levels_for_ref(ref, action: action).any? do |access_level| + access_level.access_level == Gitlab::Access::DEVELOPER + end + end + + def self.access_levels_for_ref(ref, action:) + self.matching(ref).map(&:"#{action}_access_levels").flatten + end + + def self.matching(ref_name, protected_refs: nil) + ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs) + end + end + + def commit + project.commit(self.name) + end + + private + + def ref_matcher + @ref_matcher ||= ProtectedRefMatcher.new(self) + end +end diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb new file mode 100644 index 00000000000..c4f158e569a --- /dev/null +++ b/app/models/concerns/protected_ref_access.rb @@ -0,0 +1,18 @@ +module ProtectedRefAccess + extend ActiveSupport::Concern + + included do + scope :master, -> { where(access_level: Gitlab::Access::MASTER) } + scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) } + end + + def humanize + self.class.human_access_levels[self.access_level] + end + + def check_access(user) + return true if user.admin? + + project.team.max_member_access(user.id) >= access_level + end +end diff --git a/app/models/concerns/protected_tag_access.rb b/app/models/concerns/protected_tag_access.rb new file mode 100644 index 00000000000..ee65de24dd8 --- /dev/null +++ b/app/models/concerns/protected_tag_access.rb @@ -0,0 +1,11 @@ +module ProtectedTagAccess + extend ActiveSupport::Concern + + included do + include ProtectedRefAccess + + belongs_to :protected_tag + + delegate :project, to: :protected_tag + end +end diff --git a/app/models/concerns/repository_mirroring.rb b/app/models/concerns/repository_mirroring.rb new file mode 100644 index 00000000000..fed336c29d6 --- /dev/null +++ b/app/models/concerns/repository_mirroring.rb @@ -0,0 +1,17 @@ +module RepositoryMirroring + def set_remote_as_mirror(name) + config = raw_repository.rugged.config + + # This is used to define repository as equivalent as "git clone --mirror" + config["remote.#{name}.fetch"] = 'refs/*:refs/*' + config["remote.#{name}.mirror"] = true + config["remote.#{name}.prune"] = true + end + + def fetch_mirror(remote, url) + add_remote(remote, url) + set_remote_as_mirror(remote) + fetch_remote(remote, forced: true) + remove_remote(remote) + end +end diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb new file mode 100644 index 00000000000..dd979e7bb17 --- /dev/null +++ b/app/models/concerns/resolvable_discussion.rb @@ -0,0 +1,103 @@ +module ResolvableDiscussion + extend ActiveSupport::Concern + + included do + # A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized. + # When this discussion is resolved or unresolved, the values of these properties potentially change. + # To make sure all memoized values are reset when this happens, `update` resets all instance variables with names in + # `memoized_variables`. If you add a memoized method in `ResolvableDiscussion` or any `Discussion` subclass, + # please make sure the instance variable name is added to `memoized_values`, like below. + cattr_accessor :memoized_values, instance_accessor: false do + [] + end + + memoized_values.push( + :resolvable, + :resolved, + :first_note, + :first_note_to_resolve, + :last_resolved_note, + :last_note + ) + + delegate :potentially_resolvable?, to: :first_note + + delegate :resolved_at, + :resolved_by, + + to: :last_resolved_note, + allow_nil: true + end + + def resolvable? + return @resolvable if @resolvable.present? + + @resolvable = potentially_resolvable? && notes.any?(&:resolvable?) + end + + def resolved? + return @resolved if @resolved.present? + + @resolved = resolvable? && notes.none?(&:to_be_resolved?) + end + + def first_note + @first_note ||= notes.first + end + + def first_note_to_resolve + return unless resolvable? + + @first_note_to_resolve ||= notes.find(&:to_be_resolved?) + end + + def last_resolved_note + return unless resolved? + + @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + end + + def resolved_notes + notes.select(&:resolved?) + end + + def to_be_resolved? + resolvable? && !resolved? + end + + def can_resolve?(current_user) + return false unless current_user + return false unless resolvable? + + current_user == self.noteable.author || + current_user.can?(:resolve_note, self.project) + end + + def resolve!(current_user) + return unless resolvable? + + update { |notes| notes.resolve!(current_user) } + end + + def unresolve! + return unless resolvable? + + update { |notes| notes.unresolve! } + end + + private + + def update + # Do not select `Note.resolvable`, so that system notes remain in the collection + notes_relation = Note.where(id: notes.map(&:id)) + + yield(notes_relation) + + # Set the notes array to the updated notes + @notes = notes_relation.fresh.to_a + + self.class.memoized_values.each do |var| + instance_variable_set(:"@#{var}", nil) + end + end +end diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb new file mode 100644 index 00000000000..05eb6f86704 --- /dev/null +++ b/app/models/concerns/resolvable_note.rb @@ -0,0 +1,72 @@ +module ResolvableNote + extend ActiveSupport::Concern + + # Names of all subclasses of `Note` that can be resolvable. + RESOLVABLE_TYPES = %w(DiffNote DiscussionNote).freeze + + included do + belongs_to :resolved_by, class_name: "User" + + validates :resolved_by, presence: true, if: :resolved? + + # Keep this scope in sync with `#potentially_resolvable?` + scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) } + # Keep this scope in sync with `#resolvable?` + scope :resolvable, -> { potentially_resolvable.user } + + scope :resolved, -> { resolvable.where.not(resolved_at: nil) } + scope :unresolved, -> { resolvable.where(resolved_at: nil) } + end + + module ClassMethods + # This method must be kept in sync with `#resolve!` + def resolve!(current_user) + unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id) + end + + # This method must be kept in sync with `#unresolve!` + def unresolve! + resolved.update_all(resolved_at: nil, resolved_by_id: nil) + end + end + + # Keep this method in sync with the `potentially_resolvable` scope + def potentially_resolvable? + RESOLVABLE_TYPES.include?(self.class.name) && noteable.supports_resolvable_notes? + end + + # Keep this method in sync with the `resolvable` scope + def resolvable? + potentially_resolvable? && !system? + end + + def resolved? + return false unless resolvable? + + self.resolved_at.present? + end + + def to_be_resolved? + resolvable? && !resolved? + end + + # If you update this method remember to also update `.resolve!` + def resolve!(current_user) + return unless resolvable? + return if resolved? + + self.resolved_at = Time.now + self.resolved_by = current_user + save! + end + + # If you update this method remember to also update `.unresolve!` + def unresolve! + return unless resolvable? + return unless resolved? + + self.resolved_at = nil + self.resolved_by = nil + save! + end +end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 529fb5ce988..c4463abdfe6 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -5,6 +5,7 @@ module Routable included do has_one :route, as: :source, autosave: true, dependent: :destroy + has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy validates_associated :route validates :route, presence: true @@ -26,16 +27,31 @@ module Routable # Klass.find_by_full_path('gitlab-org/gitlab-ce') # # Returns a single object, or nil. - def find_by_full_path(path) + def find_by_full_path(path, follow_redirects: false) # On MySQL we want to ensure the ORDER BY uses a case-sensitive match so # any literal matches come first, for this we have to use "BINARY". # Without this there's still no guarantee in what order MySQL will return # rows. + # + # Why do we do this? + # + # Even though we have Rails validation on Route for unique paths + # (case-insensitive), there are old projects in our DB (and possibly + # clients' DBs) that have the same path with different cases. + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/18603. Also note that + # our unique index is case-sensitive in Postgres. binary = Gitlab::Database.mysql? ? 'BINARY' : '' - order_sql = "(CASE WHEN #{binary} routes.path = #{connection.quote(path)} THEN 0 ELSE 1 END)" - - where_full_path_in([path]).reorder(order_sql).take + found = where_full_path_in([path]).reorder(order_sql).take + return found if found + + if follow_redirects + if Gitlab::Database.postgresql? + joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path) + else + joins(:redirect_routes).find_by(redirect_routes: { path: path }) + end + end end # Builds a relation to find multiple objects by their full paths. @@ -83,6 +99,74 @@ module Routable AND members.source_type = r2.source_type"). where('members.user_id = ?', user_id) end + + # Builds a relation to find multiple objects that are nested under user + # membership. Includes the parent, as opposed to `#member_descendants` + # which only includes the descendants. + # + # Usage: + # + # Klass.member_self_and_descendants(1) + # + # Returns an ActiveRecord::Relation. + def member_self_and_descendants(user_id) + joins(:route). + joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%') + OR routes.path = r2.path + INNER JOIN members ON members.source_id = r2.source_id + AND members.source_type = r2.source_type"). + where('members.user_id = ?', user_id) + end + + # Returns all objects in a hierarchy, where any node in the hierarchy is + # under the user membership. + # + # Usage: + # + # Klass.member_hierarchy(1) + # + # Examples: + # + # Given the following group tree... + # + # _______group_1_______ + # | | + # | | + # nested_group_1 nested_group_2 + # | | + # | | + # nested_group_1_1 nested_group_2_1 + # + # + # ... the following results are returned: + # + # * the user is a member of group 1 + # => 'group_1', + # 'nested_group_1', nested_group_1_1', + # 'nested_group_2', 'nested_group_2_1' + # + # * the user is a member of nested_group_2 + # => 'group1', + # 'nested_group_2', 'nested_group_2_1' + # + # * the user is a member of nested_group_2_1 + # => 'group1', + # 'nested_group_2', 'nested_group_2_1' + # + # Returns an ActiveRecord::Relation. + def member_hierarchy(user_id) + paths = member_self_and_descendants(user_id).pluck('routes.path') + + return none if paths.empty? + + wheres = paths.map do |path| + "#{connection.quote(path)} = routes.path + OR + #{connection.quote(path)} LIKE CONCAT(routes.path, '/%')" + end + + joins(:route).where(wheres.join(' OR ')) + end end def full_name @@ -95,7 +179,20 @@ module Routable end end + # Every time `project.namespace.becomes(Namespace)` is called for polymorphic_path, + # a new instance is instantiated, and we end up duplicating the same query to retrieve + # the route. Caching this per request ensures that even if we have multiple instances, + # we will not have to duplicate work, avoiding N+1 queries in some cases. def full_path + return uncached_full_path unless RequestStore.active? + + key = "routable/full_path/#{self.class.name}/#{self.id}" + RequestStore[key] ||= uncached_full_path + end + + private + + def uncached_full_path if route && route.path.present? @full_path ||= route.path else @@ -105,8 +202,6 @@ module Routable end end - private - def full_name_changed? name_changed? || parent_changed? end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb new file mode 100644 index 00000000000..d0c94d3b694 --- /dev/null +++ b/app/models/container_repository.rb @@ -0,0 +1,82 @@ +class ContainerRepository < ActiveRecord::Base + belongs_to :project + + validates :name, length: { minimum: 0, allow_nil: false } + validates :name, uniqueness: { scope: :project_id } + + delegate :client, to: :registry + + before_destroy :delete_tags! + + def registry + @registry ||= begin + token = Auth::ContainerRegistryAuthenticationService.full_access_token(path) + + url = Gitlab.config.registry.api_url + host_port = Gitlab.config.registry.host_port + + ContainerRegistry::Registry.new(url, token: token, path: host_port) + end + end + + def path + @path ||= [project.full_path, name] + .select(&:present?).join('/').downcase + end + + def location + File.join(registry.path, path) + end + + def tag(tag) + ContainerRegistry::Tag.new(self, tag) + end + + def manifest + @manifest ||= client.repository_tags(path) + end + + def tags + return @tags if defined?(@tags) + return [] unless manifest && manifest['tags'] + + @tags = manifest['tags'].map do |tag| + ContainerRegistry::Tag.new(self, tag) + end + end + + def blob(config) + ContainerRegistry::Blob.new(self, config) + end + + def has_tags? + tags.any? + end + + def root_repository? + name.empty? + end + + def delete_tags! + return unless has_tags? + + digests = tags.map { |tag| tag.digest }.to_set + + digests.all? do |digest| + client.delete_repository_tag(self.path, digest) + end + end + + def self.build_from_path(path) + self.new(project: path.repository_project, + name: path.repository_name) + end + + def self.create_from_path!(path) + build_from_path(path).tap(&:save!) + end + + def self.build_root_repository(project) + self.new(project: project, name: '') + end +end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index afad001d50f..216cec751e3 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -85,8 +85,8 @@ class Deployment < ActiveRecord::Base end def stop_action - return nil unless on_stop.present? - return nil unless manual_actions + return unless on_stop.present? + return unless manual_actions @stop_action ||= manual_actions.find_by(name: on_stop) end @@ -99,6 +99,16 @@ class Deployment < ActiveRecord::Base created_at.to_time.in_time_zone.to_s(:medium) end + def has_metrics? + project.monitoring_service.present? + end + + def metrics + return {} unless has_metrics? + + project.monitoring_service.deployment_metrics(self) + end + private def ref_path diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb new file mode 100644 index 00000000000..14ddd2fcc88 --- /dev/null +++ b/app/models/diff_discussion.rb @@ -0,0 +1,45 @@ +# A discussion on merge request or commit diffs consisting of `DiffNote` notes. +# +# A discussion of this type can be resolvable. +class DiffDiscussion < Discussion + include DiscussionOnDiff + + def self.note_class + DiffNote + end + + delegate :position, + :original_position, + + to: :first_note + + def legacy_diff_discussion? + false + end + + def merge_request_version_params + return unless for_merge_request? + + if active? + {} + else + diff_refs = position.diff_refs + + if diff = noteable.merge_request_diff_for(diff_refs) + { diff_id: diff.id } + elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha) + { + diff_id: diff.id, + start_sha: diff_refs.start_sha + } + end + end + end + + def reply_attributes + super.merge( + original_position: original_position.to_json, + position: position.to_json + ) + end +end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 895a91139c9..76c59199afd 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -1,6 +1,11 @@ +# A note on merge request or commit diffs +# +# A note of this type can be resolvable. class DiffNote < Note include NoteOnDiff + NOTEABLE_TYPES = %w(MergeRequest Commit).freeze + serialize :original_position, Gitlab::Diff::Position serialize :position, Gitlab::Diff::Position @@ -8,59 +13,31 @@ class DiffNote < Note validates :position, presence: true validates :diff_line, presence: true validates :line_code, presence: true, line_code: true - validates :noteable_type, inclusion: { in: %w(Commit MergeRequest) } - validates :resolved_by, presence: true, if: :resolved? + validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } validate :positions_complete validate :verify_supported - # Keep this scope in sync with the logic in `#resolvable?` - scope :resolvable, -> { user.where(noteable_type: 'MergeRequest') } - scope :resolved, -> { resolvable.where.not(resolved_at: nil) } - scope :unresolved, -> { resolvable.where(resolved_at: nil) } - - after_initialize :ensure_original_discussion_id before_validation :set_original_position, :update_position, on: :create - before_validation :set_line_code, :set_original_discussion_id - # We need to do this again, because it's already in `Note`, but is affected by - # `update_position` and needs to run after that. - before_validation :set_discussion_id + before_validation :set_line_code after_save :keep_around_commits - class << self - def build_discussion_id(noteable_type, noteable_id, position) - [super(noteable_type, noteable_id), *position.key].join("-") - end - - # This method must be kept in sync with `#resolve!` - def resolve!(current_user) - unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id) - end - - # This method must be kept in sync with `#unresolve!` - def unresolve! - resolved.update_all(resolved_at: nil, resolved_by_id: nil) - end + def discussion_class(*) + DiffDiscussion end - def new_diff_note? - true - end + %i(original_position position).each do |meth| + define_method "#{meth}=" do |new_position| + if new_position.is_a?(String) + new_position = JSON.parse(new_position) rescue nil + end - def diff_attributes - { position: position.to_json } - end + if new_position.is_a?(Hash) + new_position = new_position.with_indifferent_access + new_position = Gitlab::Diff::Position.new(new_position) + end - def position=(new_position) - if new_position.is_a?(String) - new_position = JSON.parse(new_position) rescue nil + super(new_position) end - - if new_position.is_a?(Hash) - new_position = new_position.with_indifferent_access - new_position = Gitlab::Diff::Position.new(new_position) - end - - super(new_position) end def diff_file @@ -88,41 +65,11 @@ class DiffNote < Note self.position.diff_refs == diff_refs end - # If you update this method remember to also update the scope `resolvable` - def resolvable? - !system? && for_merge_request? - end - - def resolved? - return false unless resolvable? - - self.resolved_at.present? - end - - # If you update this method remember to also update `.resolve!` - def resolve!(current_user) - return unless resolvable? - return if resolved? - - self.resolved_at = Time.now - self.resolved_by = current_user - save! - end - - # If you update this method remember to also update `.unresolve!` - def unresolve! - return unless resolvable? - return unless resolved? - - self.resolved_at = nil - self.resolved_by = nil - save! - end - - def discussion - return unless resolvable? + def created_at_diff?(diff_refs) + return false unless supported? + return true if for_commit? - self.noteable.find_diff_discussion(self.discussion_id) + self.original_position.diff_refs == diff_refs end private @@ -131,42 +78,14 @@ class DiffNote < Note for_commit? || self.noteable.has_complete_diff_refs? end - def noteable_diff_refs - if noteable.respond_to?(:diff_sha_refs) - noteable.diff_sha_refs - else - noteable.diff_refs - end - end - def set_original_position - self.original_position = self.position.dup + self.original_position = self.position.dup unless self.original_position&.complete? end def set_line_code self.line_code = self.position.line_code(self.project.repository) end - def ensure_original_discussion_id - return unless self.persisted? - return if self.original_discussion_id - - set_original_discussion_id - update_column(:original_discussion_id, self.original_discussion_id) - end - - def set_original_discussion_id - self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id) - end - - def build_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position) - end - - def build_original_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position) - end - def update_position return unless supported? return if for_commit? diff --git a/app/models/discussion.rb b/app/models/discussion.rb index bbe813db823..0b6b920ed66 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -1,7 +1,10 @@ +# A non-diff discussion on an issue, merge request, commit, or snippet, consisting of `DiscussionNote` notes. +# +# A discussion of this type can be resolvable. class Discussion - NUMBER_OF_TRUNCATED_DIFF_LINES = 16 + include ResolvableDiscussion - attr_reader :notes + attr_reader :notes, :context_noteable delegate :created_at, :project, @@ -11,43 +14,62 @@ class Discussion :for_commit?, :for_merge_request?, - :line_code, - :original_line_code, - :diff_file, - :for_line?, - :active?, - to: :first_note - delegate :resolved_at, - :resolved_by, + def self.build(notes, context_noteable = nil) + notes.first.discussion_class(context_noteable).new(notes, context_noteable) + end - to: :last_resolved_note, - allow_nil: true + def self.build_collection(notes, context_noteable = nil) + notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) } + end - delegate :blob, - :highlighted_diff_lines, - :diff_lines, + # Returns an alphanumeric discussion ID based on `build_discussion_id` + def self.discussion_id(note) + Digest::SHA1.hexdigest(build_discussion_id(note).join("-")) + end - to: :diff_file, - allow_nil: true + # Returns an array of discussion ID components + def self.build_discussion_id(note) + [*base_discussion_id(note), SecureRandom.hex] + end - def self.for_notes(notes) - notes.group_by(&:discussion_id).values.map { |notes| new(notes) } + def self.base_discussion_id(note) + noteable_id = note.noteable_id || note.commit_id + [:discussion, note.noteable_type.try(:underscore), noteable_id] end - def self.for_diff_notes(notes) - notes.group_by(&:line_code).values.map { |notes| new(notes) } + # When notes on a commit are displayed in context of a merge request that contains that commit, + # these notes are to be displayed as if they were part of one discussion, even though they were actually + # individual notes on the commit with different discussion IDs, so that it's clear that these are not + # notes on the merge request itself. + # + # To turn a list of notes into a list of discussions, they are grouped by discussion ID, so to + # get these out-of-context notes to end up in the same discussion, we need to get them to return the same + # `discussion_id` when this grouping happens. To enable this, `Note#discussion_id` calls out + # to the `override_discussion_id` method on the appropriate `Discussion` subclass, as determined by + # the `discussion_class` method on `Note` or a subclass of `Note`. + # + # If no override is necessary, return `nil`. + # For the case described above, see `OutOfContextDiscussion.override_discussion_id`. + def self.override_discussion_id(note) + nil end - def initialize(notes) - @notes = notes + def self.note_class + DiscussionNote end - def last_resolved_note - return unless resolved? + def initialize(notes, context_noteable = nil) + @notes = notes + @context_noteable = context_noteable + end - @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + def ==(other) + other.class == self.class && + other.context_noteable == self.context_noteable && + other.id == self.id && + other.notes == self.notes end def last_updated_at @@ -59,91 +81,29 @@ class Discussion end def id - first_note.discussion_id + first_note.discussion_id(context_noteable) end alias_method :to_param, :id def diff_discussion? - first_note.diff_note? - end - - def legacy_diff_discussion? - notes.any?(&:legacy_diff_note?) + false end - def resolvable? - return @resolvable if @resolvable.present? - - @resolvable = diff_discussion? && notes.any?(&:resolvable?) + def individual_note? + false end - def resolved? - return @resolved if @resolved.present? - - @resolved = resolvable? && notes.none?(&:to_be_resolved?) - end - - def first_note - @first_note ||= @notes.first - end - - def first_note_to_resolve - @first_note_to_resolve ||= notes.detect(&:to_be_resolved?) + def new_discussion? + notes.length == 1 end def last_note - @last_note ||= @notes.last - end - - def resolved_notes - notes.select(&:resolved?) - end - - def to_be_resolved? - resolvable? && !resolved? - end - - def can_resolve?(current_user) - return false unless current_user - return false unless resolvable? - - current_user == self.noteable.author || - current_user.can?(:resolve_note, self.project) - end - - def resolve!(current_user) - return unless resolvable? - - update { |notes| notes.resolve!(current_user) } - end - - def unresolve! - return unless resolvable? - - update { |notes| notes.unresolve! } - end - - def for_target?(target) - self.noteable == target && !diff_discussion? - end - - def active? - return @active if @active.present? - - @active = first_note.active? + @last_note ||= notes.last end def collapsed? - return false unless diff_discussion? - - if resolvable? - # New diff discussions only disappear once they are marked resolved - resolved? - else - # Old diff discussions disappear once they become outdated - !active? - end + resolved? end def expanded? @@ -151,52 +111,6 @@ class Discussion end def reply_attributes - data = { - noteable_type: first_note.noteable_type, - noteable_id: first_note.noteable_id, - commit_id: first_note.commit_id, - discussion_id: self.id, - } - - if diff_discussion? - data[:note_type] = first_note.type - - data.merge!(first_note.diff_attributes) - end - - data - end - - # Returns an array of at most 16 highlighted lines above a diff note - def truncated_diff_lines(highlight: true) - lines = highlight ? highlighted_diff_lines : diff_lines - prev_lines = [] - - lines.each do |line| - if line.meta? - prev_lines.clear - else - prev_lines << line - - break if for_line?(line) - - prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES - end - end - - prev_lines - end - - private - - def update - notes_relation = DiffNote.where(id: notes.map(&:id)).fresh - yield(notes_relation) - - # Set the notes array to the updated notes - @notes = notes_relation.to_a - - # Reset the memoized values - @last_resolved_note = @resolvable = @resolved = @first_note = @last_note = nil + first_note.slice(:type, :noteable_type, :noteable_id, :commit_id, :discussion_id) end end diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb new file mode 100644 index 00000000000..e660b024083 --- /dev/null +++ b/app/models/discussion_note.rb @@ -0,0 +1,13 @@ +# A note in a non-diff discussion on an issue, merge request, commit, or snippet. +# +# A note of this type can be resolvable. +class DiscussionNote < Note + # Names of all implementers of `Noteable` that support discussions. + NOTEABLE_TYPES = %w(MergeRequest Issue Commit Snippet).freeze + + validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } + + def discussion_class(*) + Discussion + end +end diff --git a/app/models/environment.rb b/app/models/environment.rb index bf33010fd21..61572d8d69a 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -62,7 +62,7 @@ class Environment < ActiveRecord::Base def predefined_variables [ { key: 'CI_ENVIRONMENT_NAME', value: name, public: true }, - { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true }, + { key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true } ] end @@ -150,7 +150,7 @@ class Environment < ActiveRecord::Base end def metrics - project.monitoring_service.metrics(self) if has_metrics? + project.monitoring_service.environment_metrics(self) if has_metrics? end # An environment name is not necessarily suitable for use in URLs, DNS diff --git a/app/models/event.rb b/app/models/event.rb index 5c34844b5d3..e6fad46077a 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -16,7 +16,7 @@ class Event < ActiveRecord::Base RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour - delegate :name, :email, :public_email, to: :author, prefix: true, allow_nil: true + delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true delegate :title, to: :issue, prefix: true, allow_nil: true delegate :title, to: :merge_request, prefix: true, allow_nil: true delegate :title, to: :note, prefix: true, allow_nil: true @@ -30,6 +30,7 @@ class Event < ActiveRecord::Base # Callbacks after_create :reset_project_activity + after_create :set_last_repository_updated_at, if: :push? # Scopes scope :recent, -> { reorder(id: :desc) } @@ -357,4 +358,9 @@ class Event < ActiveRecord::Base def recent_update? project.last_activity_at > RESET_PROJECT_ACTIVITY_INTERVAL.ago end + + def set_last_repository_updated_at + Project.unscoped.where(id: project_id). + update_all(last_repository_updated_at: created_at) + end end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 0afbca2cb32..538615130a7 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -36,7 +36,7 @@ class GlobalMilestone closed = count_by_state(milestones_by_state_and_title, 'closed') all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count - { + { opened: opened, closed: closed, all: all @@ -86,7 +86,7 @@ class GlobalMilestone end def issues - @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels) + @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels) end def merge_requests @@ -94,7 +94,7 @@ class GlobalMilestone end def participants - @participants ||= milestones.includes(:participants).map(&:participants).flatten.compact.uniq + @participants ||= milestones.map(&:participants).flatten.uniq end def labels diff --git a/app/models/group.rb b/app/models/group.rb index 60274386103..6aab477f431 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -4,6 +4,7 @@ class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel include AccessRequestable + include Avatarable include Referable include SelectForProjectAuthorization @@ -27,11 +28,14 @@ class Group < Namespace validates :avatar, file_size: { maximum: 200.kilobytes.to_i } + validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } + mount_uploader :avatar, AvatarUploader has_many :uploads, as: :model, dependent: :destroy after_create :post_create_hook after_destroy :post_destroy_hook + after_save :update_two_factor_requirement class << self # Searches for groups matching the given query. @@ -108,10 +112,10 @@ class Group < Namespace allowed_by_projects end - def avatar_url(size = nil) - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - end + def avatar_url(**args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) end def lfs_enabled? @@ -122,7 +126,7 @@ class Group < Namespace end def add_users(users, access_level, current_user: nil, expires_at: nil) - GroupMember.add_users_to_group( + GroupMember.add_users( self, users, access_level, @@ -223,4 +227,12 @@ class Group < Namespace type: public? ? 'O' : 'I' # Open vs Invite-only } end + + protected + + def update_two_factor_requirement + return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed? + + users.find_each(&:update_two_factor_requirement) + end end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index c631e7a7df5..ee6165fd32d 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -5,7 +5,7 @@ class ProjectHook < WebHook scope :confidential_issue_hooks, -> { where(confidential_issues_events: true) } scope :note_hooks, -> { where(note_events: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true) } - scope :build_hooks, -> { where(build_events: true) } + scope :job_hooks, -> { where(job_events: true) } scope :pipeline_hooks, -> { where(pipeline_events: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true) } end diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index 777bad1e724..c645805c6da 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -1,4 +1,9 @@ class SystemHook < WebHook + scope :repository_update_hooks, -> { where(repository_update_events: true) } + + default_value_for :push_events, false + default_value_for :repository_update_events, true + def async_execute(data, hook_name) Sidekiq::Client.enqueue(SystemHookWorker, id, data, hook_name) end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 595602e80fe..a165fdc312f 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -8,8 +8,9 @@ class WebHook < ActiveRecord::Base default_value_for :note_events, false default_value_for :merge_requests_events, false default_value_for :tag_push_events, false - default_value_for :build_events, false + default_value_for :job_events, false default_value_for :pipeline_events, false + default_value_for :repository_update_events, false default_value_for :enable_ssl_verification, true scope :push_hooks, -> { where(push_events: true) } @@ -31,7 +32,7 @@ class WebHook < ActiveRecord::Base post_url = url.gsub("#{parsed_url.userinfo}@", '') auth = { username: CGI.unescape(parsed_url.user), - password: CGI.unescape(parsed_url.password), + password: CGI.unescape(parsed_url.password) } response = WebHook.post(post_url, body: data.to_json, diff --git a/app/models/identity.rb b/app/models/identity.rb index 3bacc450e6e..920a25932b4 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -7,6 +7,8 @@ class Identity < ActiveRecord::Base validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider } validates :user_id, uniqueness: { scope: :provider } + scope :with_extern_uid, ->(provider, extern_uid) { where(extern_uid: extern_uid, provider: provider) } + def ldap? provider.starts_with?('ldap') end diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb new file mode 100644 index 00000000000..6be8ca45739 --- /dev/null +++ b/app/models/individual_note_discussion.rb @@ -0,0 +1,17 @@ +# A discussion to wrap a single `Note` note on the root of an issue, merge request, +# commit, or snippet, that is not displayed as a discussion. +# +# A discussion of this type is never resolvable. +class IndividualNoteDiscussion < Discussion + def self.note_class + Note + end + + def individual_note? + true + end + + def reply_attributes + super.tap { |attrs| attrs.delete(:discussion_id) } + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index 10a5d9d2a24..a88dbb3e065 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -3,6 +3,7 @@ require 'carrierwave/orm/activerecord' class Issue < ActiveRecord::Base include InternalId include Issuable + include Noteable include Referable include Sortable include Spammable @@ -23,12 +24,17 @@ class Issue < ActiveRecord::Base has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + has_many :issue_assignees + has_many :assignees, class_name: "User", through: :issue_assignees + validates :project, presence: true - scope :cared, ->(user) { where(assignee_id: user) } - scope :open_for, ->(user) { opened.assigned_to(user) } scope :in_projects, ->(project_ids) { where(project_id: project_ids) } + scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') } + scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)} + scope :without_due_date, -> { where(due_date: nil) } scope :due_before, ->(date) { where('issues.due_date < ?', date) } scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } @@ -38,11 +44,15 @@ class Issue < ActiveRecord::Base scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } - scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) } + scope :include_associations, -> { includes(:labels, project: :namespace) } + + after_save :expire_etag_cache attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true + participant :assignees + state_machine :state, initial: :opened do event :close do transition [:reopened, :opened] => :closed @@ -59,17 +69,17 @@ class Issue < ActiveRecord::Base before_transition any => :closed do |issue| issue.closed_at = Time.zone.now end - - before_transition closed: any do |issue| - issue.closed_at = nil - end end def hook_attrs + assignee_ids = self.assignee_ids + attrs = { total_time_spent: total_time_spent, human_total_time_spent: human_total_time_spent, - human_time_estimate: human_time_estimate + human_time_estimate: human_time_estimate, + assignee_ids: assignee_ids, + assignee_id: assignee_ids.first # This key is deprecated } attributes.merge!(attrs) @@ -117,6 +127,22 @@ class Issue < ActiveRecord::Base "id DESC") end + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee_list + } + end + + def assignee_or_author?(user) + author_id == user.id || assignees.exists?(user.id) + end + + def assignee_list + assignees.map(&:name).to_sentence + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -146,6 +172,14 @@ class Issue < ActiveRecord::Base branches_with_iid - branches_with_merge_request end + # Returns boolean if a related branch exists for the current issue + # ignores merge requests branchs + def has_related_branch? + project.repository.branch_names.any? do |branch| + /\A#{iid}-(?!\d+-stable)/i =~ branch + end + end + # To allow polymorphism with MergeRequest. def source_project project @@ -202,7 +236,7 @@ class Issue < ActiveRecord::Base # Returns `true` if the current issue can be viewed by either a logged in User # or an anonymous user. def visible_to_user?(user = nil) - return false unless project.feature_available?(:issues, user) + return false unless project && project.feature_available?(:issues, user) user ? readable_by?(user) : publicly_visible? end @@ -243,7 +277,7 @@ class Issue < ActiveRecord::Base true elsif confidential? author == user || - assignee == user || + assignees.include?(user) || project.team.member?(user, Gitlab::Access::REPORTER) else project.public? || @@ -256,4 +290,13 @@ class Issue < ActiveRecord::Base def publicly_visible? project.public? && !confidential? end + + def expire_etag_cache + key = Gitlab::Routing.url_helpers.realtime_changes_namespace_project_issue_path( + project.namespace, + project, + self + ) + Gitlab::EtagCaching::Store.new.touch(key) + end end diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb new file mode 100644 index 00000000000..06d760b6a89 --- /dev/null +++ b/app/models/issue_assignee.rb @@ -0,0 +1,6 @@ +class IssueAssignee < ActiveRecord::Base + extend Gitlab::CurrentSettings + + belongs_to :issue + belongs_to :assignee, class_name: "User", foreign_key: :user_id +end diff --git a/app/models/key.rb b/app/models/key.rb index 9c74ca84753..b7956052c3f 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -74,7 +74,7 @@ class Key < ActiveRecord::Base GitlabShellWorker.perform_async( :remove_key, shell_id, - key, + key ) end diff --git a/app/models/label.rb b/app/models/label.rb index 568fa6d44f5..ddddb6bdf8f 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -21,6 +21,8 @@ class Label < ActiveRecord::Base has_many :issues, through: :label_links, source: :target, source_type: 'Issue' has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest' + before_validation :strip_whitespace_from_title_and_color + validates :color, color: true, allow_blank: false # Don't allow ',' for label titles @@ -32,6 +34,7 @@ class Label < ActiveRecord::Base scope :templates, -> { where(template: true) } scope :with_title, ->(title) { where(title: title) } + scope :on_project_boards, ->(project_id) { joins(lists: :board).merge(List.movable).where(boards: { project_id: project_id }) } def self.prioritized(project) joins(:priorities) @@ -193,4 +196,8 @@ class Label < ActiveRecord::Base def sanitize_title(value) CGI.unescapeHTML(Sanitize.clean(value.to_s)) end + + def strip_whitespace_from_title_and_color + %w(color title).each { |attr| self[attr] = self[attr]&.strip } + end end diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb new file mode 100644 index 00000000000..3c1d34db5fa --- /dev/null +++ b/app/models/legacy_diff_discussion.rb @@ -0,0 +1,43 @@ +# A discussion on merge request or commit diffs consisting of `LegacyDiffNote` notes. +# +# All new diff discussions are of the type `DiffDiscussion`, but any diff discussions created +# before the introduction of the new implementation still use `LegacyDiffDiscussion`. +# +# A discussion of this type is never resolvable. +class LegacyDiffDiscussion < Discussion + include DiscussionOnDiff + + memoized_values << :active + + def self.note_class + LegacyDiffNote + end + + def legacy_diff_discussion? + true + end + + def active?(*args) + return @active if @active.present? + + @active = first_note.active?(*args) + end + + def collapsed? + !active? + end + + def merge_request_version_params + return unless for_merge_request? + + if active? + {} + else + nil + end + end + + def reply_attributes + super.merge(line_code: line_code) + end +end diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 40277a9b139..d7c627432d2 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -1,3 +1,9 @@ +# A note on merge request or commit diffs, using the legacy implementation. +# +# All new diff notes are of the type `DiffNote`, but any diff notes created +# before the introduction of the new implementation still use `LegacyDiffNote`. +# +# A note of this type is never resolvable. class LegacyDiffNote < Note include NoteOnDiff @@ -7,18 +13,8 @@ class LegacyDiffNote < Note before_create :set_diff - class << self - def build_discussion_id(noteable_type, noteable_id, line_code) - [super(noteable_type, noteable_id), line_code].join("-") - end - end - - def legacy_diff_note? - true - end - - def diff_attributes - { line_code: line_code } + def discussion_class(*) + LegacyDiffDiscussion end def project_repository @@ -60,11 +56,12 @@ class LegacyDiffNote < Note # # If the note's current diff cannot be matched in the MergeRequest's current # diff, it's considered inactive. - def active? + def active?(diff_refs = nil) return @active if defined?(@active) return true if for_commit? return true unless diff_line return false unless noteable + return false if diff_refs && diff_refs != noteable_diff_refs noteable_diff = find_noteable_diff @@ -119,8 +116,4 @@ class LegacyDiffNote < Note diffs = noteable.raw_diffs(Commit.max_diff_options) diffs.find { |d| d.new_path == self.diff.new_path } end - - def build_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code) - end end diff --git a/app/models/member.rb b/app/models/member.rb index 0545bd4eedf..7228e82e978 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -151,6 +151,27 @@ class Member < ActiveRecord::Base member end + def add_users(source, users, access_level, current_user: nil, expires_at: nil) + return [] unless users.present? + + # Collect all user ids into separate array + # so we can use single sql query to get user objects + user_ids = users.select { |user| user =~ /\A\d+\Z/ } + users = users - user_ids + User.where(id: user_ids) + + self.transaction do + users.map do |user| + add_user( + source, + user, + access_level, + current_user: current_user, + expires_at: expires_at + ) + end + end + end + def access_levels Gitlab::Access.sym_options end @@ -173,18 +194,6 @@ class Member < ActiveRecord::Base # There is no current user for bulk actions, in which case anything is allowed !current_user || current_user.can?(:"update_#{member.type.underscore}", member) end - - def add_users_to_source(source, users, access_level, current_user: nil, expires_at: nil) - users.each do |user| - add_user( - source, - user, - access_level, - current_user: current_user, - expires_at: expires_at - ) - end - end end def real_source_type diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 446f9f8f8a7..28e10bc6172 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -3,11 +3,16 @@ class GroupMember < Member belongs_to :group, foreign_key: 'source_id' + delegate :update_two_factor_requirement, to: :user + # Make sure group member points only to group as it source default_value_for :source_type, SOURCE_TYPE validates :source_type, format: { with: /\ANamespace\z/ } default_scope { where(source_type: SOURCE_TYPE) } + after_create :update_two_factor_requirement, unless: :invite? + after_destroy :update_two_factor_requirement, unless: :invite? + def self.access_level_roles Gitlab::Access.options_with_owner end @@ -16,18 +21,6 @@ class GroupMember < Member Gitlab::Access.sym_options_with_owner end - def self.add_users_to_group(group, users, access_level, current_user: nil, expires_at: nil) - self.transaction do - add_users_to_source( - group, - users, - access_level, - current_user: current_user, - expires_at: expires_at - ) - end - end - def group source end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 912820b51ac..b3a91feb091 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -16,7 +16,7 @@ class ProjectMember < Member before_destroy :delete_member_todos class << self - # Add users to project teams with passed access option + # Add users to projects with passed access option # # access can be an integer representing a access code # or symbol like :master representing role @@ -39,7 +39,7 @@ class ProjectMember < Member project_ids.each do |project_id| project = Project.find(project_id) - add_users_to_source( + add_users( project, users, access_level, diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5ff83944d8c..9be00880438 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1,9 +1,9 @@ class MergeRequest < ActiveRecord::Base include InternalId include Issuable + include Noteable include Referable include Sortable - include Importable belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" @@ -13,10 +13,14 @@ class MergeRequest < ActiveRecord::Base has_one :merge_request_diff, -> { order('merge_request_diffs.id DESC') } + belongs_to :head_pipeline, foreign_key: "head_pipeline_id", class_name: "Ci::Pipeline" + has_many :events, as: :target, dependent: :destroy has_many :merge_requests_closing_issues, class_name: 'MergeRequestsClosingIssues', dependent: :delete_all + belongs_to :assignee, class_name: "User" + serialize :merge_params, Hash after_create :ensure_merge_request_diff, unless: :importing? @@ -100,11 +104,11 @@ class MergeRequest < ActiveRecord::Base validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing? validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] validate :validate_fork, unless: :closed_without_fork? + validate :validate_target_project, on: :create scope :by_source_or_target_branch, ->(branch_name) do where("source_branch = :branch OR target_branch = :branch", branch: branch_name) end - scope :cared, ->(user) { where('assignee_id = :user OR author_id = :user', user: user.id) } scope :by_milestone, ->(milestone) { where(milestone_id: milestone) } scope :of_projects, ->(ids) { where(target_project_id: ids) } scope :from_project, ->(project) { where(source_project_id: project.id) } @@ -114,6 +118,11 @@ class MergeRequest < ActiveRecord::Base scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } + scope :assigned, -> { where("assignee_id IS NOT NULL") } + scope :unassigned, -> { where("assignee_id IS NULL") } + scope :assigned_to, ->(u) { where(assignee_id: u.id)} + + participant :assignee after_save :keep_around_commit @@ -177,6 +186,23 @@ class MergeRequest < ActiveRecord::Base work_in_progress?(title) ? title : "WIP: #{title}" end + # Returns a Hash of attributes to be used for Twitter card metadata + def card_attributes + { + 'Author' => author.try(:name), + 'Assignee' => assignee.try(:name) + } + end + + # This method is needed for compatibility with issues to not mess view and other code + def assignees + Array(assignee) + end + + def assignee_or_author?(user) + author_id == user.id || assignee_id == user.id + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" @@ -192,22 +218,23 @@ class MergeRequest < ActiveRecord::Base merge_request_diff ? merge_request_diff.raw_diffs(*args) : compare.raw_diffs(*args) end - def diffs(diff_options = nil) + def diffs(diff_options = {}) if compare - compare.diffs(diff_options) + # When saving MR diffs, `no_collapse` is implicitly added (because we need + # to save the entire contents to the DB), so add that here for + # consistency. + compare.diffs(diff_options.merge(no_collapse: true)) else merge_request_diff.diffs(diff_options) end end def diff_size - # The `#diffs` method ends up at an instance of a class inheriting from - # `Gitlab::Diff::FileCollection::Base`, so use those options as defaults - # here too, to get the same diff size without performing highlighting. - # - opts = Gitlab::Diff::FileCollection::Base.default_options.merge(diff_options || {}) + # Calling `merge_request_diff.diffs.real_size` will also perform + # highlighting, which we don't need here. + return real_size if merge_request_diff - raw_diffs(opts).size + diffs.real_size end def diff_base_commit @@ -266,6 +293,8 @@ class MergeRequest < ActiveRecord::Base attr_writer :target_branch_sha, :source_branch_sha def source_branch_head + return unless source_project + source_branch_ref = @source_branch_sha || source_branch source_project.repository.commit(source_branch_ref) if source_branch_ref end @@ -330,6 +359,12 @@ class MergeRequest < ActiveRecord::Base end end + def validate_target_project + return true if target_project.merge_requests_enabled? + + errors.add :base, 'Target project has disabled merge requests' + end + def validate_fork return true unless target_project && source_project return true if target_project == source_project @@ -367,6 +402,20 @@ class MergeRequest < ActiveRecord::Base merge_request_diff(true) end + def merge_request_diff_for(diff_refs_or_sha) + @merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha| + diffs = merge_request_diffs.viewable.select_without_diff + h[diff_refs_or_sha] = + if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs) + diffs.find_by_diff_refs(diff_refs_or_sha) + else + diffs.find_by(head_commit_sha: diff_refs_or_sha) + end + end + + @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha] + end + def reload_diff_if_branch_changed if source_branch_changed? || target_branch_changed? reload_diff @@ -443,7 +492,7 @@ class MergeRequest < ActiveRecord::Base end def can_remove_source_branch?(current_user) - !source_project.protected_branch?(source_branch) && + !ProtectedBranch.protected?(source_project, source_branch) && !source_project.root_ref?(source_branch) && Ability.allowed?(current_user, :push_code, source_project) && diff_head_commit == source_branch_head @@ -476,43 +525,7 @@ class MergeRequest < ActiveRecord::Base ) end - def discussions - @discussions ||= self.related_notes. - inc_relations_for_view. - fresh. - discussions - end - - def diff_discussions - @diff_discussions ||= self.notes.diff_notes.discussions - end - - def resolvable_discussions - @resolvable_discussions ||= diff_discussions.select(&:to_be_resolved?) - end - - def discussions_can_be_resolved_by?(user) - resolvable_discussions.all? { |discussion| discussion.can_resolve?(user) } - end - - def find_diff_discussion(discussion_id) - notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a - return if notes.empty? - - Discussion.new(notes) - end - - def discussions_resolvable? - diff_discussions.any?(&:resolvable?) - end - - def discussions_resolved? - discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?) - end - - def discussions_to_be_resolved? - discussions_resolvable? && !discussions_resolved? - end + alias_method :discussion_notes, :related_notes def mergeable_discussions_state? return true unless project.only_allow_merge_if_all_discussions_are_resolved? @@ -812,12 +825,6 @@ class MergeRequest < ActiveRecord::Base diverged_commits_count > 0 end - def head_pipeline - return unless diff_head_sha && source_project - - @head_pipeline ||= source_project.pipeline_for(source_branch, diff_head_sha) - end - def all_pipelines return Ci::Pipeline.none unless source_project @@ -847,7 +854,7 @@ class MergeRequest < ActiveRecord::Base end def can_be_cherry_picked? - merge_commit + merge_commit.present? end def has_complete_diff_refs? @@ -858,8 +865,8 @@ class MergeRequest < ActiveRecord::Base return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs - active_diff_notes = self.notes.diff_notes.select do |note| - note.new_diff_note? && note.active?(old_diff_refs) + active_diff_notes = self.notes.new_diff_notes.select do |note| + note.active?(old_diff_refs) end return if active_diff_notes.empty? @@ -886,32 +893,6 @@ class MergeRequest < ActiveRecord::Base project.repository.keep_around(self.merge_commit_sha) end - def conflicts - @conflicts ||= Gitlab::Conflict::FileCollection.new(self) - end - - def conflicts_can_be_resolved_by?(user) - access = ::Gitlab::UserAccess.new(user, project: source_project) - access.can_push_to_branch?(source_branch) - end - - def conflicts_can_be_resolved_in_ui? - return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui) - - return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged? - return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs? - - begin - # Try to parse each conflict. If the MR's mergeable status hasn't been updated, - # ensure that we don't say there are conflicts to resolve when there are no conflict - # files. - conflicts.files.each(&:lines) - @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0 - rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing - @conflicts_can_be_resolved_in_ui = false - end - end - def has_commits? merge_request_diff && commits_count > 0 end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index baee00b8fcd..f0a3c30ea74 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -31,6 +31,10 @@ class MergeRequestDiff < ActiveRecord::Base # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? + def self.find_by_diff_refs(diff_refs) + find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha) + end + def self.select_without_diff select(column_names - ['st_diffs']) end @@ -130,6 +134,12 @@ class MergeRequestDiff < ActiveRecord::Base st_commits.map { |commit| commit[:id] } end + def diff_refs=(new_diff_refs) + self.base_commit_sha = new_diff_refs&.base_sha + self.start_commit_sha = new_diff_refs&.start_sha + self.head_commit_sha = new_diff_refs&.head_sha + end + def diff_refs return unless start_commit_sha || base_commit_sha @@ -177,6 +187,16 @@ class MergeRequestDiff < ActiveRecord::Base st_commits.count end + def utf8_st_diffs + return [] if st_diffs.blank? + + st_diffs.map do |diff| + diff.each do |k, v| + diff[k] = encode_utf8(v) if v.respond_to?(:encoding) + end + end + end + private # Old GitLab implementations may have generated diffs as ["--broken-diff"]. @@ -240,7 +260,7 @@ class MergeRequestDiff < ActiveRecord::Base new_attributes[:state] = :empty else diff_collection = compare.diffs(Commit.max_diff_options) - new_attributes[:real_size] = compare.diffs.real_size + new_attributes[:real_size] = diff_collection.real_size if diff_collection.any? new_diffs = dump_diffs(diff_collection) @@ -270,14 +290,6 @@ class MergeRequestDiff < ActiveRecord::Base project.merge_base_commit(head_commit_sha, start_commit_sha).try(:sha) end - def utf8_st_diffs - st_diffs.map do |diff| - diff.each do |k, v| - diff[k] = encode_utf8(v) if v.respond_to?(:encoding) - end - end - end - # # #save or #update_attributes providing changes on serialized attributes do a lot of # serialization and deserialization calls resulting in bad performance. diff --git a/app/models/milestone.rb b/app/models/milestone.rb index e85d5709624..c06bfe0ccdd 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -21,7 +21,6 @@ class Milestone < ActiveRecord::Base has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests - has_many :participants, -> { distinct.reorder('users.name') }, through: :issues, source: :assignee has_many :events, as: :target, dependent: :destroy scope :active, -> { with_state(:active) } @@ -30,7 +29,7 @@ class Milestone < ActiveRecord::Base validates :title, presence: true, uniqueness: { scope: :project_id } validates :project, presence: true - validate :start_date_should_be_less_than_due_date, if: Proc.new { |m| m.start_date.present? && m.due_date.present? } + validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } strip_attributes :title @@ -107,6 +106,10 @@ class Milestone < ActiveRecord::Base end end + def participants + User.joins(assigned_issues: :milestone).where("milestones.id = ?", id) + end + def self.sort(method) case method.to_s when 'due_date_asc' @@ -153,10 +156,6 @@ class Milestone < ActiveRecord::Base active? && issues.opened.count.zero? end - def is_empty?(user = nil) - total_items_count(user).zero? - end - def author_id nil end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 1d4b1f7d590..4d59267f71d 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -33,7 +33,7 @@ class Namespace < ActiveRecord::Base validates :path, presence: true, length: { maximum: 255 }, - namespace: true + dynamic_path: true validate :nesting_level_allowed @@ -46,7 +46,7 @@ class Namespace < ActiveRecord::Base before_destroy(prepend: true) { prepare_for_destroy } after_destroy :rm_dir - scope :root, -> { where('type IS NULL') } + scope :for_user, -> { where('type IS NULL') } scope :with_statistics, -> do joins('LEFT JOIN project_statistics ps ON ps.namespace_id = namespaces.id') @@ -56,7 +56,7 @@ class Namespace < ActiveRecord::Base 'COALESCE(SUM(ps.storage_size), 0) AS storage_size', 'COALESCE(SUM(ps.repository_size), 0) AS repository_size', 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size', - 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size', + 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size' ) end @@ -150,7 +150,7 @@ class Namespace < ActiveRecord::Base end def any_project_has_container_registry_tags? - projects.any?(&:has_container_registry_tags?) + all_projects.any?(&:has_container_registry_tags?) end def send_update_instructions @@ -214,6 +214,16 @@ class Namespace < ActiveRecord::Base @old_repository_storage_paths ||= repository_storage_paths end + # Includes projects from this namespace and projects from all subgroups + # that belongs to this namespace + def all_projects + Project.inside_path(full_path) + end + + def has_parent? + parent.present? + end + private def repository_storage_paths @@ -221,7 +231,7 @@ class Namespace < ActiveRecord::Base # pending delete. Unscoping also get rids of the default order, which causes # problems with SELECT DISTINCT. Project.unscoped do - projects.select('distinct(repository_storage)').to_a.map(&:repository_storage_path) + all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage_path) end end diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 0bbc9451ffd..59737bb6085 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -107,7 +107,8 @@ module Network def find_commits(skip = 0) opts = { max_count: self.class.max_count, - skip: skip + skip: skip, + order: :date } opts[:ref] = @commit.id if @filter_ref diff --git a/app/models/note.rb b/app/models/note.rb index 16d66cb1427..46d0a4f159f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -1,3 +1,6 @@ +# A note on the root of an issue, merge request, commit, or snippet. +# +# A note of this type is never resolvable. class Note < ActiveRecord::Base extend ActiveModel::Naming include Gitlab::CurrentSettings @@ -8,8 +11,17 @@ class Note < ActiveRecord::Base include FasterCacheKeys include CacheMarkdownField include AfterCommitQueue + include ResolvableNote + include IgnorableColumn - cache_markdown_field :note, pipeline: :note + ignore_column :original_discussion_id + + cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true + + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by # Attribute containing rendered and redacted Markdown as generated by # Banzai::ObjectRenderer. @@ -31,9 +43,7 @@ class Note < ActiveRecord::Base belongs_to :noteable, polymorphic: true, touch: true belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" - - # Only used by DiffNote, but defined here so that it can be used in `Note.includes` - belongs_to :resolved_by, class_name: "User" + belongs_to :last_edited_by, class_name: 'User' has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy @@ -54,10 +64,11 @@ class Note < ActiveRecord::Base validates :noteable_id, presence: true, unless: [:for_commit?, :importing?] validates :commit_id, presence: true, if: :for_commit? validates :author, presence: true + validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ } validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note| unless note.noteable.try(:project) == note.project - errors.add(:invalid_project, 'Note and noteable project mismatch') + errors.add(:project, 'does not match noteable project') end end @@ -69,6 +80,7 @@ class Note < ActiveRecord::Base scope :user, ->{ where(system: false) } scope :common, ->{ where(noteable_type: ["", nil]) } scope :fresh, ->{ order(created_at: :asc, id: :asc) } + scope :updated_after, ->(time){ where('updated_at > ?', time) } scope :inc_author_project, ->{ includes(:project, :author) } scope :inc_author, ->{ includes(:author) } scope :inc_relations_for_view, -> do @@ -76,7 +88,8 @@ class Note < ActiveRecord::Base end scope :diff_notes, ->{ where(type: %w(LegacyDiffNote DiffNote)) } - scope :non_diff_notes, ->{ where(type: ['Note', nil]) } + scope :new_diff_notes, ->{ where(type: 'DiffNote') } + scope :non_diff_notes, ->{ where(type: ['Note', 'DiscussionNote', nil]) } scope :with_associations, -> do # FYI noteable cannot be loaded for LegacyDiffNote for commits @@ -86,31 +99,41 @@ class Note < ActiveRecord::Base after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code - before_validation :set_discussion_id + before_validation :set_discussion_id, on: :create after_save :keep_around_commit, unless: :for_personal_snippet? after_save :expire_etag_cache + after_destroy :expire_etag_cache class << self def model_name ActiveModel::Name.new(self, nil, 'note') end - def build_discussion_id(noteable_type, noteable_id) - [:discussion, noteable_type.try(:underscore), noteable_id].join("-") + def discussions(context_noteable = nil) + Discussion.build_collection(fresh, context_noteable) end - def discussion_id(*args) - Digest::SHA1.hexdigest(build_discussion_id(*args)) - end + def find_discussion(discussion_id) + notes = where(discussion_id: discussion_id).fresh.to_a + return if notes.empty? - def discussions - Discussion.for_notes(fresh) + Discussion.build(notes) end - def grouped_diff_discussions - active_notes = diff_notes.fresh.select(&:active?) - Discussion.for_diff_notes(active_notes). - map { |d| [d.line_code, d] }.to_h + def grouped_diff_discussions(diff_refs = nil) + groups = {} + + diff_notes.fresh.discussions.each do |discussion| + if discussion.active?(diff_refs) + discussions = groups[discussion.line_code] ||= [] + elsif diff_refs && discussion.created_at_diff?(diff_refs) + discussions = groups[discussion.original_line_code] ||= [] + end + + discussions << discussion if discussions + end + + groups end def count_for_collection(ids, type) @@ -121,37 +144,17 @@ class Note < ActiveRecord::Base end def cross_reference? - system && SystemNoteService.cross_reference?(note) + system? && SystemNoteService.cross_reference?(note) end def diff_note? false end - def legacy_diff_note? - false - end - - def new_diff_note? - false - end - def active? true end - def resolvable? - false - end - - def resolved? - false - end - - def to_be_resolved? - resolvable? && !resolved? - end - def max_attachment_size current_application_settings.max_attachment_size.megabytes.to_i end @@ -228,7 +231,7 @@ class Note < ActiveRecord::Base end def can_be_award_emoji? - noteable.is_a?(Awardable) + noteable.is_a?(Awardable) && !part_of_discussion? end def contains_emoji_only? @@ -239,6 +242,63 @@ class Note < ActiveRecord::Base for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore end + def can_be_discussion_note? + self.noteable.supports_discussions? && !part_of_discussion? + end + + def discussion_class(noteable = nil) + # When commit notes are rendered on an MR's Discussion page, they are + # displayed in one discussion instead of individually. + # See also `#discussion_id` and `Discussion.override_discussion_id`. + if noteable && noteable != self.noteable + OutOfContextDiscussion + else + IndividualNoteDiscussion + end + end + + # See `Discussion.override_discussion_id` for details. + def discussion_id(noteable = nil) + discussion_class(noteable).override_discussion_id(self) || super() + end + + # Returns a discussion containing just this note. + # This method exists as an alternative to `#discussion` to use when the methods + # we intend to call on the Discussion object don't require it to have all of its notes, + # and just depend on the first note or the type of discussion. This saves us a DB query. + def to_discussion(noteable = nil) + Discussion.build([self], noteable) + end + + # Returns the entire discussion this note is part of. + # Consider using `#to_discussion` if we do not need to render the discussion + # and all its notes and if we don't care about the discussion's resolvability status. + def discussion + full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion? + full_discussion || to_discussion + end + + def part_of_discussion? + !to_discussion.individual_note? + end + + def in_reply_to?(other) + case other + when Note + if part_of_discussion? + in_reply_to?(other.noteable) && in_reply_to?(other.to_discussion) + else + in_reply_to?(other.noteable) + end + when Discussion + self.discussion_id == other.id + when Noteable + self.noteable == other + else + false + end + end + private def keep_around_commit @@ -264,17 +324,7 @@ class Note < ActiveRecord::Base end def set_discussion_id - self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id) - end - - def build_discussion_id - if for_merge_request? - # Notes on merge requests are always in a discussion of their own, - # so we generate a unique discussion ID. - [:discussion, :note, SecureRandom.hex].join("-") - else - self.class.build_discussion_id(noteable_type, noteable_id || commit_id) - end + self.discussion_id ||= discussion_class.discussion_id(self) end def expire_etag_cache diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 52577bd52ea..e4726e62e93 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -60,16 +60,25 @@ class NotificationSetting < ActiveRecord::Base def set_events return if custom? - EMAIL_EVENTS.each do |event| - events[event] = false - end + self.events = {} end # Validates store accessors values as boolean # It is a text field so it does not cast correct boolean values in JSON def events_to_boolean EMAIL_EVENTS.each do |event| - events[event] = ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(events[event]) + bool = ActiveRecord::ConnectionAdapters::Column::TRUE_VALUES.include?(public_send(event)) + + events[event] = bool end end + + # Allow people to receive failed pipeline notifications if they already have + # custom notifications enabled, as these are more like mentions than the other + # custom settings. + def failed_pipeline + bool = super + + bool.nil? || bool + end end diff --git a/app/models/out_of_context_discussion.rb b/app/models/out_of_context_discussion.rb new file mode 100644 index 00000000000..4227c40b69a --- /dev/null +++ b/app/models/out_of_context_discussion.rb @@ -0,0 +1,26 @@ +# When notes on a commit are displayed in the context of a merge request that +# contains that commit, they are displayed as if they were a discussion. +# +# This represents one of those discussions, consisting of `Note` notes. +# +# A discussion of this type is never resolvable. +class OutOfContextDiscussion < Discussion + # Returns an array of discussion ID components + def self.build_discussion_id(note) + base_discussion_id(note) + end + + # To make sure all out-of-context notes end up grouped as one discussion, + # we override the discussion ID to be a newly generated but consistent ID. + def self.override_discussion_id(note) + discussion_id(note) + end + + def self.note_class + Note + end + + def reply_attributes + super.tap { |attrs| attrs.delete(:discussion_id) } + end +end diff --git a/app/models/project.rb b/app/models/project.rb index f1bba56d32c..65745fd6d37 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -6,6 +6,7 @@ class Project < ActiveRecord::Base include Gitlab::VisibilityLevel include Gitlab::CurrentSettings include AccessRequestable + include Avatarable include CacheMarkdownField include Referable include Sortable @@ -53,6 +54,11 @@ class Project < ActiveRecord::Base update_column(:last_activity_at, self.created_at) end + after_create :set_last_repository_updated_at + def set_last_repository_updated_at + update_column(:last_repository_updated_at, self.created_at) + end + after_destroy :remove_pages # update visibility_level of forks @@ -74,6 +80,7 @@ class Project < ActiveRecord::Base attr_accessor :new_default_branch attr_accessor :old_path_with_namespace + attr_writer :pipeline_status alias_attribute :title, :name @@ -114,6 +121,9 @@ class Project < ActiveRecord::Base has_one :kubernetes_service, dependent: :destroy, inverse_of: :project has_one :prometheus_service, dependent: :destroy, inverse_of: :project has_one :mock_ci_service, dependent: :destroy + has_one :mock_deployment_service, dependent: :destroy + has_one :mock_monitoring_service, dependent: :destroy + has_one :microsoft_teams_service, dependent: :destroy has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id" has_one :forked_from_project, through: :forked_project_link @@ -132,6 +142,7 @@ class Project < ActiveRecord::Base has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet' has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy + has_many :protected_tags, dependent: :destroy has_many :project_authorizations has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User' @@ -157,16 +168,20 @@ class Project < ActiveRecord::Base has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" has_one :project_feature, dependent: :destroy has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete + has_many :container_repositories, dependent: :destroy has_many :commit_statuses, dependent: :destroy has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline' has_many :builds, class_name: 'Ci::Build' # the builds are created from the commit_statuses has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' - has_many :variables, dependent: :destroy, class_name: 'Ci::Variable' + has_many :variables, class_name: 'Ci::Variable' has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger' has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy + has_many :pipeline_schedules, dependent: :destroy, class_name: 'Ci::PipelineSchedule' + + has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature @@ -174,7 +189,7 @@ class Project < ActiveRecord::Base delegate :name, to: :owner, allow_nil: true, prefix: true delegate :count, to: :forks, prefix: true delegate :members, to: :team, prefix: true - delegate :add_user, to: :team + delegate :add_user, :add_users, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team delegate :empty_repo?, to: :repository @@ -188,13 +203,14 @@ class Project < ActiveRecord::Base message: Gitlab::Regex.project_name_regex_message } validates :path, presence: true, - project_path: true, + dynamic_path: true, length: { maximum: 255 }, format: { with: Gitlab::Regex.project_path_regex, - message: Gitlab::Regex.project_path_regex_message } + message: Gitlab::Regex.project_path_regex_message }, + uniqueness: { scope: :namespace_id } + validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } - validates :path, uniqueness: { scope: :namespace_id } validates :import_url, addressable_url: true, if: :external_import? validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } @@ -256,6 +272,8 @@ class Project < ActiveRecord::Base scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } + enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } + # project features may be "disabled", "internal" or "enabled". If "internal", # they are only available to team members. This scope returns projects where # the feature is either enabled, or internal with permission for the user. @@ -347,10 +365,15 @@ class Project < ActiveRecord::Base end def sort(method) - if method == 'storage_size_desc' + case method.to_s + when 'storage_size_desc' # storage_size is a joined column so we need to # pass a string to avoid AR adding the table name reorder('project_statistics.storage_size DESC, projects.id DESC') + when 'latest_activity_desc' + reorder(last_activity_at: :desc) + when 'latest_activity_asc' + reorder(last_activity_at: :asc) else order_by(method) end @@ -399,32 +422,15 @@ class Project < ActiveRecord::Base @repository ||= Repository.new(path_with_namespace, self) end - def container_registry_path_with_namespace - path_with_namespace.downcase - end - - def container_registry_repository - return unless Gitlab.config.registry.enabled - - @container_registry_repository ||= begin - token = Auth::ContainerRegistryAuthenticationService.full_access_token(container_registry_path_with_namespace) - url = Gitlab.config.registry.api_url - host_port = Gitlab.config.registry.host_port - registry = ContainerRegistry::Registry.new(url, token: token, path: host_port) - registry.repository(container_registry_path_with_namespace) - end - end - - def container_registry_repository_url + def container_registry_url if Gitlab.config.registry.enabled - "#{Gitlab.config.registry.host_port}/#{container_registry_path_with_namespace}" + "#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}" end end def has_container_registry_tags? - return unless container_registry_repository - - container_registry_repository.tags.any? + container_repositories.to_a.any?(&:has_tags?) || + has_root_container_repository_tags? end def commit(ref = 'HEAD') @@ -551,6 +557,10 @@ class Project < ActiveRecord::Base import_type == 'gitea' end + def github_import? + import_type == 'github' + end + def check_limit unless creator.can_create_project? || namespace.kind == 'group' projects_limit = creator.projects_limit @@ -789,12 +799,10 @@ class Project < ActiveRecord::Base repository.avatar end - def avatar_url - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - elsif avatar_in_git - Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) - end + def avatar_url(**args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) || (Gitlab::Routing.url_helpers.namespace_project_avatar_url(namespace, self) if avatar_in_git) end # For compatibility with old code @@ -859,14 +867,6 @@ class Project < ActiveRecord::Base @repo_exists = false end - # Branches that are not _exactly_ matched by a protected branch. - def open_branches - exact_protected_branch_names = protected_branches.reject(&:wildcard?).map(&:name) - branch_names = repository.branches.map(&:name) - non_open_branch_names = Set.new(exact_protected_branch_names).intersection(Set.new(branch_names)) - repository.branches.reject { |branch| non_open_branch_names.include? branch.name } - end - def root_ref?(branch) repository.root_ref == branch end @@ -881,16 +881,8 @@ class Project < ActiveRecord::Base Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url end - # Check if current branch name is marked as protected in the system - def protected_branch?(branch_name) - return true if empty_repo? && default_branch_protected? - - @protected_branches ||= self.protected_branches.to_a - ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present? - end - def user_can_push_to_empty_repo?(user) - !default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER + !ProtectedBranch.default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER end def forked? @@ -911,10 +903,10 @@ class Project < ActiveRecord::Base expire_caches_before_rename(old_path_with_namespace) if has_container_registry_tags? - Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present" + Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!" - # we currently doesn't support renaming repository if it contains tags in container registry - raise StandardError.new('Project cannot be renamed, because tags are present in its container registry') + # we currently doesn't support renaming repository if it contains images in container registry + raise StandardError.new('Project cannot be renamed, because images are present in its container registry') end if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace) @@ -975,7 +967,7 @@ class Project < ActiveRecord::Base namespace: namespace.name, visibility_level: visibility_level, path_with_namespace: path_with_namespace, - default_branch: default_branch, + default_branch: default_branch } # Backward compatibility @@ -1089,25 +1081,21 @@ class Project < ActiveRecord::Base end def shared_runners - shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none + @shared_runners ||= shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none end - def any_runners?(&block) - if runners.active.any?(&block) - return true - end + def active_shared_runners + @active_shared_runners ||= shared_runners.active + end - shared_runners.active.any?(&block) + def any_runners?(&block) + active_runners.any?(&block) || active_shared_runners.any?(&block) end def valid_runners_token?(token) self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token) end - def build_coverage_enabled? - build_coverage_regex.present? - end - def build_timeout_in_minutes build_timeout / 60 end @@ -1200,8 +1188,9 @@ class Project < ActiveRecord::Base end end + # Lazy loading of the `pipeline_status` attribute def pipeline_status - @pipeline_status ||= Ci::PipelineStatus.load_for_project(self) + @pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self) end def mark_import_as_failed(error_message) @@ -1261,7 +1250,7 @@ class Project < ActiveRecord::Base ] if container_registry_enabled? - variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_repository_url, public: true } + variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true } end variables @@ -1287,6 +1276,9 @@ class Project < ActiveRecord::Base else update_attribute(name, value) end + + rescue ActiveRecord::RecordNotSaved => e + handle_update_attribute_error(e, value) end def pushes_since_gc @@ -1331,6 +1323,14 @@ class Project < ActiveRecord::Base namespace_id_changed? end + def default_merge_request_target + if forked_from_project&.merge_requests_enabled? + forked_from_project + else + self + end + end + alias_method :name_with_namespace, :full_name alias_method :human_name, :full_name alias_method :path_with_namespace, :full_path @@ -1357,11 +1357,6 @@ class Project < ActiveRecord::Base "projects/#{id}/pushes_since_gc" end - def default_branch_protected? - current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || - current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE - end - # Similar to the normal callbacks that hook into the life cycle of an # Active Record object, you can also define callbacks that get triggered # when you add an object to an association collection. If any of these @@ -1394,4 +1389,27 @@ class Project < ActiveRecord::Base Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace) end + + ## + # This method is here because of support for legacy container repository + # which has exactly the same path like project does, but which might not be + # persisted in `container_repositories` table. + # + def has_root_container_repository_tags? + return false unless Gitlab.config.registry.enabled + + ContainerRepository.build_root_repository(self).has_tags? + end + + def handle_update_attribute_error(ex, value) + if ex.message.start_with?('Failed to replace') + if value.respond_to?(:each) + invalid = value.detect(&:invalid?) + + raise ex, ([ex.message] + invalid.errors.full_messages).join(' ') if invalid + end + end + + raise ex + end end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index 400020ee04a..3f5b3eb159b 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -52,7 +52,7 @@ class BambooService < CiService placeholder: 'Bamboo build plan key like KEY' }, { type: 'text', name: 'username', placeholder: 'A user with API access, if applicable' }, - { type: 'password', name: 'password' }, + { type: 'password', name: 'password' } ] end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 86d271a3f69..e2ad586aea7 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -2,11 +2,23 @@ require 'slack-notifier' module ChatMessage class BaseMessage + attr_reader :markdown + attr_reader :user_name + attr_reader :user_avatar + attr_reader :project_name + attr_reader :project_url + def initialize(params) - raise NotImplementedError + @markdown = params[:markdown] || false + @project_name = params.dig(:project, :path_with_namespace) || params[:project_name] + @project_url = params.dig(:project, :web_url) || params[:project_url] + @user_name = params.dig(:user, :username) || params[:user_name] + @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar] end def pretext + return message if markdown + format(message) end @@ -17,6 +29,10 @@ module ChatMessage raise NotImplementedError end + def activity + raise NotImplementedError + end + private def message @@ -34,5 +50,16 @@ module ChatMessage def link(text, url) "[#{text}](#{url})" end + + def pretty_duration(seconds) + parse_string = + if duration < 1.hour + '%M:%S' + else + '%H:%M:%S' + end + + Time.at(seconds).utc.strftime(parse_string) + end end end diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb index 791e5b0cec7..4b9a2b1e1f3 100644 --- a/app/models/project_services/chat_message/issue_message.rb +++ b/app/models/project_services/chat_message/issue_message.rb @@ -1,9 +1,6 @@ module ChatMessage class IssueMessage < BaseMessage - attr_reader :user_name attr_reader :title - attr_reader :project_name - attr_reader :project_url attr_reader :issue_iid attr_reader :issue_url attr_reader :action @@ -11,9 +8,7 @@ module ChatMessage attr_reader :description def initialize(params) - @user_name = params[:user][:username] - @project_name = params[:project_name] - @project_url = params[:project_url] + super obj_attr = params[:object_attributes] obj_attr = HashWithIndifferentAccess.new(obj_attr) @@ -27,15 +22,24 @@ module ChatMessage def attachments return [] unless opened_issue? + return description if markdown description_message end + def activity + { + title: "Issue #{state} by #{user_name}", + subtitle: "in #{project_link}", + text: issue_link, + image: user_avatar + } + end + private def message - case state - when "opened" + if state == 'opened' "[#{project_link}] Issue #{state} by #{user_name}" else "[#{project_link}] Issue #{issue_link} #{state} by #{user_name}" @@ -64,7 +68,7 @@ module ChatMessage end def issue_title - "##{issue_iid} #{title}" + "#{Issue.reference_prefix}#{issue_iid} #{title}" end end end diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb index 5e5efca7bec..7d0de81cdf0 100644 --- a/app/models/project_services/chat_message/merge_message.rb +++ b/app/models/project_services/chat_message/merge_message.rb @@ -1,36 +1,36 @@ module ChatMessage class MergeMessage < BaseMessage - attr_reader :user_name - attr_reader :project_name - attr_reader :project_url - attr_reader :merge_request_id + attr_reader :merge_request_iid attr_reader :source_branch attr_reader :target_branch attr_reader :state attr_reader :title def initialize(params) - @user_name = params[:user][:username] - @project_name = params[:project_name] - @project_url = params[:project_url] + super obj_attr = params[:object_attributes] obj_attr = HashWithIndifferentAccess.new(obj_attr) - @merge_request_id = obj_attr[:iid] + @merge_request_iid = obj_attr[:iid] @source_branch = obj_attr[:source_branch] @target_branch = obj_attr[:target_branch] @state = obj_attr[:state] @title = format_title(obj_attr[:title]) end - def pretext - format(message) - end - def attachments [] end + def activity + { + title: "Merge Request #{state} by #{user_name}", + subtitle: "in #{project_link}", + text: merge_request_link, + image: user_avatar + } + end + private def format_title(title) @@ -50,11 +50,15 @@ module ChatMessage end def merge_request_link - link("merge request !#{merge_request_id}", merge_request_url) + link(merge_request_title, merge_request_url) + end + + def merge_request_title + "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}" end def merge_request_url - "#{project_url}/merge_requests/#{merge_request_id}" + "#{project_url}/merge_requests/#{merge_request_iid}" end end end diff --git a/app/models/project_services/chat_message/note_message.rb b/app/models/project_services/chat_message/note_message.rb index 552113bac29..2da4c244229 100644 --- a/app/models/project_services/chat_message/note_message.rb +++ b/app/models/project_services/chat_message/note_message.rb @@ -1,70 +1,74 @@ module ChatMessage class NoteMessage < BaseMessage - attr_reader :message - attr_reader :user_name - attr_reader :project_name - attr_reader :project_url attr_reader :note attr_reader :note_url + attr_reader :title + attr_reader :target def initialize(params) - params = HashWithIndifferentAccess.new(params) - @user_name = params[:user][:username] - @project_name = params[:project_name] - @project_url = params[:project_url] + super + params = HashWithIndifferentAccess.new(params) obj_attr = params[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) @note = obj_attr[:note] @note_url = obj_attr[:url] - noteable_type = obj_attr[:noteable_type] - - case noteable_type - when "Commit" - create_commit_note(HashWithIndifferentAccess.new(params[:commit])) - when "Issue" - create_issue_note(HashWithIndifferentAccess.new(params[:issue])) - when "MergeRequest" - create_merge_note(HashWithIndifferentAccess.new(params[:merge_request])) - when "Snippet" - create_snippet_note(HashWithIndifferentAccess.new(params[:snippet])) - end + @target, @title = case obj_attr[:noteable_type] + when "Commit" + create_commit_note(params[:commit]) + when "Issue" + create_issue_note(params[:issue]) + when "MergeRequest" + create_merge_note(params[:merge_request]) + when "Snippet" + create_snippet_note(params[:snippet]) + end end def attachments + return note if markdown + description_message end + def activity + { + title: "#{user_name} #{link('commented on ' + target, note_url)}", + subtitle: "in #{project_link}", + text: formatted_title, + image: user_avatar + } + end + private + def message + "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*" + end + def format_title(title) title.lines.first.chomp end - def create_commit_note(commit) - commit_sha = commit[:id] - commit_sha = Commit.truncate_sha(commit_sha) - commented_on_message( - "commit #{commit_sha}", - format_title(commit[:message])) + def formatted_title + format_title(title) end def create_issue_note(issue) - commented_on_message( - "issue ##{issue[:iid]}", - format_title(issue[:title])) + ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]] + end + + def create_commit_note(commit) + commit_sha = Commit.truncate_sha(commit[:id]) + + ["commit #{commit_sha}", commit[:message]] end def create_merge_note(merge_request) - commented_on_message( - "merge request !#{merge_request[:iid]}", - format_title(merge_request[:title])) + ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]] end def create_snippet_note(snippet) - commented_on_message( - "snippet ##{snippet[:id]}", - format_title(snippet[:title])) + ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]] end def description_message @@ -74,9 +78,5 @@ module ChatMessage def project_link link(project_name, project_url) end - - def commented_on_message(target, title) - @message = "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{title}*" - end end end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 210027565a8..3edc395033c 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -1,19 +1,22 @@ module ChatMessage class PipelineMessage < BaseMessage - attr_reader :ref_type, :ref, :status, :project_name, :project_url, - :user_name, :duration, :pipeline_id + attr_reader :ref_type + attr_reader :ref + attr_reader :status + attr_reader :duration + attr_reader :pipeline_id def initialize(data) + super + + @user_name = data.dig(:user, :name) || 'API' + pipeline_attributes = data[:object_attributes] @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref = pipeline_attributes[:ref] @status = pipeline_attributes[:status] - @duration = pipeline_attributes[:duration] + @duration = pipeline_attributes[:duration].to_i @pipeline_id = pipeline_attributes[:id] - - @project_name = data[:project][:path_with_namespace] - @project_url = data[:project][:web_url] - @user_name = (data[:user] && data[:user][:name]) || 'API' end def pretext @@ -25,17 +28,24 @@ module ChatMessage end def attachments + return message if markdown + [{ text: format(message), color: attachment_color }] end + def activity + { + title: "Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status}", + subtitle: "in #{project_link}", + text: "in #{pretty_duration(duration)}", + image: user_avatar || '' + } + end + private def message - "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}" - end - - def format(string) - Slack::Notifier::LinkFormatter.format(string) + "#{project_link}: Pipeline #{pipeline_link} of #{ref_type} #{branch_link} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}" end def humanized_status @@ -60,7 +70,7 @@ module ChatMessage end def branch_link - "[#{ref}](#{branch_url})" + "`[#{ref}](#{branch_url})`" end def project_link diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb index 2d73b71ec37..04a59d559ca 100644 --- a/app/models/project_services/chat_message/push_message.rb +++ b/app/models/project_services/chat_message/push_message.rb @@ -3,33 +3,43 @@ module ChatMessage attr_reader :after attr_reader :before attr_reader :commits - attr_reader :project_name - attr_reader :project_url attr_reader :ref attr_reader :ref_type - attr_reader :user_name def initialize(params) + super + @after = params[:after] @before = params[:before] @commits = params.fetch(:commits, []) - @project_name = params[:project_name] - @project_url = params[:project_url] @ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch' @ref = Gitlab::Git.ref_name(params[:ref]) - @user_name = params[:user_name] - end - - def pretext - format(message) end def attachments return [] if new_branch? || removed_branch? + return commit_messages if markdown commit_message_attachments end + def activity + action = if new_branch? + "created" + elsif removed_branch? + "removed" + else + "pushed to" + end + + { + title: "#{user_name} #{action} #{ref_type}", + subtitle: "in #{project_link}", + text: compare_link, + image: user_avatar + } + end + private def message @@ -51,7 +61,7 @@ module ChatMessage end def removed_branch_message - "#{user_name} removed #{ref_type} #{ref} from #{project_link}" + "#{user_name} removed #{ref_type} `#{ref}` from #{project_link}" end def push_message @@ -59,7 +69,7 @@ module ChatMessage end def commit_messages - commits.map { |commit| compose_commit_message(commit) }.join("\n") + commits.map { |commit| compose_commit_message(commit) }.join("\n\n") end def commit_message_attachments @@ -92,7 +102,7 @@ module ChatMessage end def branch_link - "[#{ref}](#{branch_url})" + "`[#{ref}](#{branch_url})`" end def project_link diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb index 134083e4504..a139a8ee727 100644 --- a/app/models/project_services/chat_message/wiki_page_message.rb +++ b/app/models/project_services/chat_message/wiki_page_message.rb @@ -1,17 +1,12 @@ module ChatMessage class WikiPageMessage < BaseMessage - attr_reader :user_name attr_reader :title - attr_reader :project_name - attr_reader :project_url attr_reader :wiki_page_url attr_reader :action attr_reader :description def initialize(params) - @user_name = params[:user][:username] - @project_name = params[:project_name] - @project_url = params[:project_url] + super obj_attr = params[:object_attributes] obj_attr = HashWithIndifferentAccess.new(obj_attr) @@ -29,9 +24,20 @@ module ChatMessage end def attachments + return description if markdown + description_message end + def activity + { + title: "#{user_name} #{action} #{wiki_page_link}", + subtitle: "in #{project_link}", + text: title, + image: user_avatar + } + end + private def message diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 75834103db5..779ef54cfcb 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -39,7 +39,7 @@ class ChatNotificationService < Service { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'checkbox', name: 'notify_only_default_branch' }, + { type: 'checkbox', name: 'notify_only_default_branch' } ] end @@ -49,10 +49,7 @@ class ChatNotificationService < Service object_kind = data[:object_kind] - data = data.merge( - project_url: project_url, - project_name: project_name - ) + data = custom_data(data) # WebHook events often have an 'update' event that follows a 'open' or # 'close' action. Ignore update events for now to prevent duplicate @@ -68,8 +65,7 @@ class ChatNotificationService < Service opts[:channel] = channel_name if channel_name opts[:username] = username if username - notifier = Slack::Notifier.new(webhook, opts) - notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback) + return false unless notify(message, opts) true end @@ -92,6 +88,18 @@ class ChatNotificationService < Service private + def notify(message, opts) + Slack::Notifier.new(webhook, opts).ping( + message.pretext, + attachments: message.attachments, + fallback: message.fallback + ) + end + + def custom_data(data) + data.merge(project_url: project_url, project_name: project_name) + end + def get_message(object_kind, data) case object_kind when "push", "tag_push" @@ -142,7 +150,7 @@ class ChatNotificationService < Service def notify_for_ref?(data) return true if data[:object_attributes][:tag] - return true unless notify_only_default_branch + return true unless notify_only_default_branch? data[:object_attributes][:ref] == project.default_branch end diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index f4f913ee0b6..1a236e232f9 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -47,7 +47,7 @@ class EmailsOnPushService < Service help: "Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. #{domains})." }, { type: 'checkbox', name: 'disable_diffs', title: "Disable code diffs", help: "Don't include possibly sensitive code diffs in notification body." }, - { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' }, + { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' } ] end end diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index bdf6fa6a586..b4d7c977ce4 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -19,7 +19,7 @@ class ExternalWikiService < Service def fields [ - { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' }, + { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' } ] end diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index 10a13c3fbdc..2a05d757eb4 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -37,7 +37,7 @@ class FlowdockService < Service repo: project.repository.path_to_repo, repo_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}", commit_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/%s", - diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s", + diff_url: "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/compare/%s...%s" ) end end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index 8b181221bb0..c19fed339ba 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -41,7 +41,7 @@ class HipchatService < Service placeholder: 'Leave blank for default (v2)' }, { type: 'text', name: 'server', placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' } ] end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index c62bb4fa120..a51d43adcb9 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -58,7 +58,7 @@ class IrkerService < Service ' want to use a password, you have to omit the "#" on the channel). If you ' \ ' specify a default IRC URI to prepend before each recipient, you can just ' \ ' give a channel name.' }, - { type: 'checkbox', name: 'colorize_messages' }, + { type: 'checkbox', name: 'colorize_messages' } ] end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index eef403dba92..f388773efee 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -62,7 +62,7 @@ class JiraService < IssueTrackerService def help "You need to configure JIRA before enabling this service. For more details read the - [JIRA service documentation](#{help_page_url('project_services/jira')})." + [JIRA service documentation](#{help_page_url('user/project/integrations/jira')})." end def title @@ -91,7 +91,7 @@ class JiraService < IssueTrackerService { type: 'text', name: 'project_key', placeholder: 'Project Key' }, { type: 'text', name: 'username', placeholder: '' }, { type: 'password', name: 'password', placeholder: '' }, - { type: 'text', name: 'jira_issue_transition_id', placeholder: '2' } + { type: 'text', name: 'jira_issue_transition_id', placeholder: '' } ] end @@ -149,7 +149,7 @@ class JiraService < IssueTrackerService data = { user: { name: author.name, - url: resource_url(user_path(author)), + url: resource_url(user_path(author)) }, project: { name: self.project.path_with_namespace, diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 02fbd5497fa..b2494a0be6e 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -22,22 +22,21 @@ class KubernetesService < DeploymentService with_options presence: true, if: :activated? do validates :api_url, url: true validates :token - - validates :namespace, - format: { - with: Gitlab::Regex.kubernetes_namespace_regex, - message: Gitlab::Regex.kubernetes_namespace_regex_message, - }, - length: 1..63 end + validates :namespace, + allow_blank: true, + length: 1..63, + if: :activated?, + format: { + with: Gitlab::Regex.kubernetes_namespace_regex, + message: Gitlab::Regex.kubernetes_namespace_regex_message + } + after_save :clear_reactive_cache! def initialize_properties - if properties.nil? - self.properties = {} - self.namespace = "#{project.path}-#{project.id}" if project.present? - end + self.properties = {} if properties.nil? end def title @@ -62,7 +61,7 @@ class KubernetesService < DeploymentService { type: 'text', name: 'namespace', title: 'Kubernetes namespace', - placeholder: 'Kubernetes namespace' }, + placeholder: namespace_placeholder }, { type: 'text', name: 'api_url', title: 'API URL', @@ -74,7 +73,7 @@ class KubernetesService < DeploymentService { type: 'textarea', name: 'ca_pem', title: 'Custom CA bundle', - placeholder: 'Certificate Authority bundle (PEM format)' }, + placeholder: 'Certificate Authority bundle (PEM format)' } ] end @@ -92,7 +91,7 @@ class KubernetesService < DeploymentService variables = [ { key: 'KUBE_URL', value: api_url, public: true }, { key: 'KUBE_TOKEN', value: token, public: false }, - { key: 'KUBE_NAMESPACE', value: namespace, public: true } + { key: 'KUBE_NAMESPACE', value: namespace_variable, public: true } ] if ca_pem.present? @@ -135,8 +134,26 @@ class KubernetesService < DeploymentService { pods: pods } end + TEMPLATE_PLACEHOLDER = 'Kubernetes namespace'.freeze + private + def namespace_placeholder + default_namespace || TEMPLATE_PLACEHOLDER + end + + def namespace_variable + if namespace.present? + namespace + else + default_namespace + end + end + + def default_namespace + "#{project.path}-#{project.id}" if project.present? + end + def build_kubeclient!(api_path: 'api', api_version: 'v1') raise "Incomplete settings" unless api_url && namespace && token diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb new file mode 100644 index 00000000000..2facff53e26 --- /dev/null +++ b/app/models/project_services/microsoft_teams_service.rb @@ -0,0 +1,56 @@ +class MicrosoftTeamsService < ChatNotificationService + def title + 'Microsoft Teams Notification' + end + + def description + 'Receive event notifications in Microsoft Teams' + end + + def self.to_param + 'microsoft_teams' + end + + def help + 'This service sends notifications about projects events to Microsoft Teams channels.<br /> + To set up this service: + <ol> + <li><a href="https://msdn.microsoft.com/en-us/microsoft-teams/connectors">Getting started with 365 Office Connectors For Microsoft Teams</a>.</li> + <li>Paste the <strong>Webhook URL</strong> into the field below.</li> + <li>Select events below to enable notifications.</li> + </ol>' + end + + def webhook_placeholder + 'https://outlook.office.com/webhook/…' + end + + def event_field(event) + end + + def default_channel_placeholder + end + + def default_fields + [ + { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }, + { type: 'checkbox', name: 'notify_only_default_branch' } + ] + end + + private + + def notify(message, opts) + MicrosoftTeams::Notifier.new(webhook).ping( + title: message.project_name, + pretext: message.pretext, + activity: message.activity, + attachments: message.attachments + ) + end + + def custom_data(data) + super(data).merge(markdown: true) + end +end diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb index a8d581a1f67..546b6e0a498 100644 --- a/app/models/project_services/mock_ci_service.rb +++ b/app/models/project_services/mock_ci_service.rb @@ -21,7 +21,7 @@ class MockCiService < CiService [ { type: 'text', name: 'mock_service_url', - placeholder: 'http://localhost:4004' }, + placeholder: 'http://localhost:4004' } ] end diff --git a/app/models/project_services/mock_deployment_service.rb b/app/models/project_services/mock_deployment_service.rb new file mode 100644 index 00000000000..59a3811ce5d --- /dev/null +++ b/app/models/project_services/mock_deployment_service.rb @@ -0,0 +1,18 @@ +class MockDeploymentService < DeploymentService + def title + 'Mock deployment' + end + + def description + 'Mock deployment service' + end + + def self.to_param + 'mock_deployment' + end + + # No terminals support + def terminals(environment) + [] + end +end diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb new file mode 100644 index 00000000000..dd04e04e198 --- /dev/null +++ b/app/models/project_services/mock_monitoring_service.rb @@ -0,0 +1,17 @@ +class MockMonitoringService < MonitoringService + def title + 'Mock monitoring' + end + + def description + 'Mock monitoring service' + end + + def self.to_param + 'mock_monitoring' + end + + def metrics(environment) + JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json')) + end +end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb index ea585721e8f..ee9cd78327a 100644 --- a/app/models/project_services/monitoring_service.rb +++ b/app/models/project_services/monitoring_service.rb @@ -9,8 +9,11 @@ class MonitoringService < Service %w() end - # Environments have a number of metrics - def metrics(environment) + def environment_metrics(environment) + raise NotImplementedError + end + + def deployment_metrics(deployment) raise NotImplementedError end end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index ac617f409d9..f824171ad09 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -55,7 +55,7 @@ class PipelinesEmailService < Service name: 'recipients', placeholder: 'Emails separated by comma' }, { type: 'checkbox', - name: 'notify_only_broken_pipelines' }, + name: 'notify_only_broken_pipelines' } ] end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 6854d2243d7..ec72cb6856d 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -1,7 +1,6 @@ class PrometheusService < MonitoringService - include ReactiveCaching + include ReactiveService - self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } self.reactive_cache_lease_timeout = 30.seconds self.reactive_cache_refresh_interval = 30.seconds self.reactive_cache_lifetime = 1.minute @@ -64,37 +63,31 @@ class PrometheusService < MonitoringService { success: false, result: err } end - def metrics(environment) - with_reactive_cache(environment.slug) do |data| - data - end + def environment_metrics(environment) + with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &:itself) + end + + def deployment_metrics(deployment) + metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.id, &:itself) + metrics&.merge(deployment_time: created_at.to_i) || {} end # Cache metrics for specific environment - def calculate_reactive_cache(environment_slug) + def calculate_reactive_cache(query_class_name, *args) return unless active? && project && !project.pending_delete? - memory_query = %{(sum(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="#{environment_slug}"})) /1024/1024} - cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) / count(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}) * 100} + metrics = Kernel.const_get(query_class_name).new(client).query(*args) { success: true, - metrics: { - # Average Memory used in MB - memory_values: client.query_range(memory_query, start: 8.hours.ago), - memory_current: client.query(memory_query), - # Average CPU Utilization - cpu_values: client.query_range(cpu_query, start: 8.hours.ago), - cpu_current: client.query(cpu_query) - }, + metrics: metrics, last_update: Time.now.utc } - rescue Gitlab::PrometheusError => err { success: false, result: err.message } end def client - @prometheus ||= Gitlab::Prometheus.new(api_url: api_url) + @prometheus ||= Gitlab::PrometheusClient.new(api_url: api_url) end end diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb index 3e618a8dbf1..fc29a5277bb 100644 --- a/app/models/project_services/pushover_service.rb +++ b/app/models/project_services/pushover_service.rb @@ -55,7 +55,7 @@ class PushoverService < Service ['Pushover Echo (long)', 'echo'], ['Up Down (long)', 'updown'], ['None (silent)', 'none'] - ] }, + ] } ] end diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index cbaffb8ce48..b16beb406b9 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -55,7 +55,7 @@ class TeamcityService < CiService placeholder: 'Build configuration ID' }, { type: 'text', name: 'username', placeholder: 'A user with permissions to trigger a manual build' }, - { type: 'password', name: 'password' }, + { type: 'password', name: 'password' } ] end @@ -78,7 +78,7 @@ class TeamcityService < CiService auth = { username: username, - password: password, + password: password } branch = Gitlab::Git.ref_name(data[:ref]) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 6d6644053f8..543b9b293e0 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -50,8 +50,8 @@ class ProjectTeam end def add_users(users, access_level, current_user: nil, expires_at: nil) - ProjectMember.add_users_to_projects( - [project.id], + ProjectMember.add_users( + project, users, access_level, current_user: current_user, diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 70eef359cdd..189c106b70b 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -183,6 +183,6 @@ class ProjectWiki end def update_project_activity - @project.touch(:last_activity_at) + @project.touch(:last_activity_at, :last_repository_updated_at) end end diff --git a/app/models/protectable_dropdown.rb b/app/models/protectable_dropdown.rb new file mode 100644 index 00000000000..122fbce257d --- /dev/null +++ b/app/models/protectable_dropdown.rb @@ -0,0 +1,33 @@ +class ProtectableDropdown + def initialize(project, ref_type) + @project = project + @ref_type = ref_type + end + + # Tags/branches which are yet to be individually protected + def protectable_ref_names + @protectable_ref_names ||= ref_names - non_wildcard_protected_ref_names + end + + def hash + protectable_ref_names.map { |ref_name| { text: ref_name, id: ref_name, title: ref_name } } + end + + private + + def refs + @project.repository.public_send(@ref_type) + end + + def ref_names + refs.map(&:name) + end + + def protections + @project.public_send("protected_#{@ref_type}") + end + + def non_wildcard_protected_ref_names + protections.reject(&:wildcard?).map(&:name) + end +end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 39e979ef15b..28b7d5ad072 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -1,9 +1,6 @@ class ProtectedBranch < ActiveRecord::Base include Gitlab::ShellAdapter - - belongs_to :project - validates :name, presence: true - validates :project, presence: true + include ProtectedRef has_many :merge_access_levels, dependent: :destroy has_many :push_access_levels, dependent: :destroy @@ -14,54 +11,15 @@ class ProtectedBranch < ActiveRecord::Base accepts_nested_attributes_for :push_access_levels accepts_nested_attributes_for :merge_access_levels - def commit - project.commit(self.name) - end - - # Returns all protected branches that match the given branch name. - # This realizes all records from the scope built up so far, and does - # _not_ return a relation. - # - # This method optionally takes in a list of `protected_branches` to search - # through, to avoid calling out to the database. - def self.matching(branch_name, protected_branches: nil) - (protected_branches || all).select { |protected_branch| protected_branch.matches?(branch_name) } - end - - # Returns all branches (among the given list of branches [`Gitlab::Git::Branch`]) - # that match the current protected branch. - def matching(branches) - branches.select { |branch| self.matches?(branch.name) } - end - - # Checks if the protected branch matches the given branch name. - def matches?(branch_name) - return false if self.name.blank? - - exact_match?(branch_name) || wildcard_match?(branch_name) - end - - # Checks if this protected branch contains a wildcard - def wildcard? - self.name && self.name.include?('*') - end - - protected - - def exact_match?(branch_name) - self.name == branch_name - end + # Check if branch name is marked as protected in the system + def self.protected?(project, ref_name) + return true if project.empty_repo? && default_branch_protected? - def wildcard_match?(branch_name) - wildcard_regex === branch_name + self.matching(ref_name, protected_refs: project.protected_branches).present? end - def wildcard_regex - @wildcard_regex ||= begin - name = self.name.gsub('*', 'STAR_DONT_ESCAPE') - quoted_name = Regexp.quote(name) - regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?') - /\A#{regex_string}\z/ - end + def self.default_branch_protected? + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE end end diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb index 771e3376613..e8d35ac326f 100644 --- a/app/models/protected_branch/merge_access_level.rb +++ b/app/models/protected_branch/merge_access_level.rb @@ -1,13 +1,3 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base include ProtectedBranchAccess - - validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, - Gitlab::Access::DEVELOPER] } - - def self.human_access_levels - { - Gitlab::Access::MASTER => "Masters", - Gitlab::Access::DEVELOPER => "Developers + Masters" - }.with_indifferent_access - end end diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb index 14610cb42b7..7a2e9e5ec5d 100644 --- a/app/models/protected_branch/push_access_level.rb +++ b/app/models/protected_branch/push_access_level.rb @@ -1,21 +1,3 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base include ProtectedBranchAccess - - validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, - Gitlab::Access::DEVELOPER, - Gitlab::Access::NO_ACCESS] } - - def self.human_access_levels - { - Gitlab::Access::MASTER => "Masters", - Gitlab::Access::DEVELOPER => "Developers + Masters", - Gitlab::Access::NO_ACCESS => "No one" - }.with_indifferent_access - end - - def check_access(user) - return false if access_level == Gitlab::Access::NO_ACCESS - - super - end end diff --git a/app/models/protected_ref_matcher.rb b/app/models/protected_ref_matcher.rb new file mode 100644 index 00000000000..d970f2b01fc --- /dev/null +++ b/app/models/protected_ref_matcher.rb @@ -0,0 +1,54 @@ +class ProtectedRefMatcher + def initialize(protected_ref) + @protected_ref = protected_ref + end + + # Returns all protected refs that match the given ref name. + # This checks all records from the scope built up so far, and does + # _not_ return a relation. + # + # This method optionally takes in a list of `protected_refs` to search + # through, to avoid calling out to the database. + def self.matching(type, ref_name, protected_refs: nil) + (protected_refs || type.all).select { |protected_ref| protected_ref.matches?(ref_name) } + end + + # Returns all branches/tags (among the given list of refs [`Gitlab::Git::Branch`]) + # that match the current protected ref. + def matching(refs) + refs.select { |ref| @protected_ref.matches?(ref.name) } + end + + # Checks if the protected ref matches the given ref name. + def matches?(ref_name) + return false if @protected_ref.name.blank? + + exact_match?(ref_name) || wildcard_match?(ref_name) + end + + # Checks if this protected ref contains a wildcard + def wildcard? + @protected_ref.name && @protected_ref.name.include?('*') + end + + protected + + def exact_match?(ref_name) + @protected_ref.name == ref_name + end + + def wildcard_match?(ref_name) + return false unless wildcard? + + wildcard_regex === ref_name + end + + def wildcard_regex + @wildcard_regex ||= begin + name = @protected_ref.name.gsub('*', 'STAR_DONT_ESCAPE') + quoted_name = Regexp.quote(name) + regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?') + /\A#{regex_string}\z/ + end + end +end diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb new file mode 100644 index 00000000000..83964095516 --- /dev/null +++ b/app/models/protected_tag.rb @@ -0,0 +1,14 @@ +class ProtectedTag < ActiveRecord::Base + include Gitlab::ShellAdapter + include ProtectedRef + + has_many :create_access_levels, dependent: :destroy + + validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." } + + accepts_nested_attributes_for :create_access_levels + + def self.protected?(project, ref_name) + self.matching(ref_name, protected_refs: project.protected_tags).present? + end +end diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb new file mode 100644 index 00000000000..c7e1319719d --- /dev/null +++ b/app/models/protected_tag/create_access_level.rb @@ -0,0 +1,21 @@ +class ProtectedTag::CreateAccessLevel < ActiveRecord::Base + include ProtectedTagAccess + + validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::NO_ACCESS] } + + def self.human_access_levels + { + Gitlab::Access::MASTER => "Masters", + Gitlab::Access::DEVELOPER => "Developers + Masters", + Gitlab::Access::NO_ACCESS => "No one" + }.with_indifferent_access + end + + def check_access(user) + return false if access_level == Gitlab::Access::NO_ACCESS + + super + end +end diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb new file mode 100644 index 00000000000..1863a08f1de --- /dev/null +++ b/app/models/readme_blob.rb @@ -0,0 +1,13 @@ +class ReadmeBlob < SimpleDelegator + attr_reader :repository + + def initialize(blob, repository) + @repository = repository + + super(blob) + end + + def rendered_markup + repository.rendered_readme + end +end diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb new file mode 100644 index 00000000000..99812bcde53 --- /dev/null +++ b/app/models/redirect_route.rb @@ -0,0 +1,12 @@ +class RedirectRoute < ActiveRecord::Base + belongs_to :source, polymorphic: true + + validates :source, presence: true + + validates :path, + length: { within: 1..255 }, + presence: true, + uniqueness: { case_sensitive: false } + + scope :matching_path_and_descendants, -> (path) { where('redirect_routes.path = ? OR redirect_routes.path LIKE ?', path, "#{sanitize_sql_like(path)}/%") } +end diff --git a/app/models/repository.rb b/app/models/repository.rb index 6ab04440ca8..07e0b3bae4f 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -2,9 +2,12 @@ require 'securerandom' class Repository include Gitlab::ShellAdapter + include RepositoryMirroring attr_accessor :path_with_namespace, :project + delegate :ref_name_for_sha, to: :raw_repository + CommitError = Class.new(StandardError) CreateTreeError = Class.new(StandardError) @@ -14,9 +17,9 @@ class Repository # same name. The cache key used by those methods must also match method's # name. # - # For example, for entry `:readme` there's a method called `readme` which - # stores its data in the `readme` cache key. - CACHED_METHODS = %i(size commit_count readme version contribution_guide + # For example, for entry `:commit_count` there's a method called `commit_count` which + # stores its data in the `commit_count` cache key. + CACHED_METHODS = %i(size commit_count rendered_readme contribution_guide changelog license_blob license_key gitignore koding_yml gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? empty? root_ref).freeze @@ -25,11 +28,10 @@ class Repository # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to # the corresponding methods to call for refreshing caches. METHOD_CACHES_FOR_FILE_TYPES = { - readme: :readme, + readme: :rendered_readme, changelog: :changelog, - license: %i(license_blob license_key), + license: %i(license_blob license_key license), contributing: :contribution_guide, - version: :version, gitignore: :gitignore, koding: :koding_yml, gitlab_ci: :gitlab_ci_yml, @@ -40,13 +42,13 @@ class Repository # variable. # # This only works for methods that do not take any arguments. - def self.cache_method(name, fallback: nil) + def self.cache_method(name, fallback: nil, memoize_only: false) original = :"_uncached_#{name}" alias_method(original, name) define_method(name) do - cache_method_output(name, fallback: fallback) { __send__(original) } + cache_method_output(name, fallback: fallback, memoize_only: memoize_only) { __send__(original) } end end @@ -58,13 +60,13 @@ class Repository def raw_repository return nil unless path_with_namespace - @raw_repository ||= Gitlab::Git::Repository.new(path_to_repo) + @raw_repository ||= initialize_raw_repository end # Return absolute path to repository def path_to_repo @path_to_repo ||= File.expand_path( - File.join(@project.repository_storage_path, path_with_namespace + ".git") + File.join(repository_storage_path, path_with_namespace + ".git") ) end @@ -106,7 +108,7 @@ class Repository offset: offset, after: after, before: before, - follow: path.present?, + follow: Array(path).length == 1, skip_merges: skip_merges } @@ -145,12 +147,7 @@ class Repository # may cause the branch to "disappear" erroneously or have the wrong SHA. # # See: https://github.com/libgit2/libgit2/issues/1534 and https://gitlab.com/gitlab-org/gitlab-ce/issues/15392 - raw_repo = - if fresh_repo - Gitlab::Git::Repository.new(path_to_repo) - else - raw_repository - end + raw_repo = fresh_repo ? initialize_raw_repository : raw_repository raw_repo.find_branch(name) end @@ -401,10 +398,6 @@ class Repository expire_tags_cache end - def before_import - expire_content_cache - end - # Runs code after the HEAD of a repository is changed. def after_change_head expire_method_caches(METHOD_CACHES_FOR_FILE_TYPES.keys) @@ -413,8 +406,6 @@ class Repository # Runs code after a repository has been forked/imported. def after_import expire_content_cache - expire_tags_cache - expire_branches_cache end # Runs code after a new commit has been pushed. @@ -459,7 +450,7 @@ class Repository def blob_at(sha, path) unless Gitlab::Git.blank_ref?(sha) - Blob.decorate(Gitlab::Git::Blob.find(self, sha, path)) + Blob.decorate(Gitlab::Git::Blob.find(self, sha, path), project) end rescue Gitlab::Git::Repository::NoRepository nil @@ -508,22 +499,14 @@ class Repository end end - def branch_names - branches.map(&:name) - end + delegate :branch_names, to: :raw_repository cache_method :branch_names, fallback: [] delegate :tag_names, to: :raw_repository cache_method :tag_names, fallback: [] - def branch_count - branches.size - end + delegate :branch_count, :tag_count, to: :raw_repository cache_method :branch_count, fallback: 0 - - def tag_count - raw_repository.rugged.tags.count - end cache_method :tag_count, fallback: 0 def avatar @@ -534,16 +517,15 @@ class Repository cache_method :avatar def readme - if head = tree(:head) - head.readme + if readme = tree(:head)&.readme + ReadmeBlob.new(readme, self) end end - cache_method :readme - def version - file_on_head(:version) + def rendered_readme + MarkupHelper.markup_unsafe(readme.name, readme.data, project: project) if readme end - cache_method :version + cache_method :rendered_readme def contribution_guide file_on_head(:contributing) @@ -567,6 +549,13 @@ class Repository end cache_method :license_key + def license + return unless license_key + + Licensee::License.new(license_key) + end + cache_method :license, memoize_only: true + def gitignore file_on_head(:gitignore) end @@ -660,22 +649,8 @@ class Repository "#{name}-#{highest_branch_id + 1}" end - # Remove archives older than 2 hours def branches_sorted_by(value) - case value - when 'name' - branches.sort_by(&:name) - when 'updated_desc' - branches.sort do |a, b| - commit(b.dereferenced_target).committed_date <=> commit(a.dereferenced_target).committed_date - end - when 'updated_asc' - branches.sort do |a, b| - commit(a.dereferenced_target).committed_date <=> commit(b.dereferenced_target).committed_date - end - else - branches - end + raw_repository.local_branches(sort_by: value) end def tags_sorted_by(value) @@ -710,14 +685,6 @@ class Repository end end - def ref_name_for_sha(ref_path, sha) - args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha}) - - # Not found -> ["", 0] - # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0] - Gitlab::Popen.popen(args, path_to_repo).first.split.last - end - def refs_contains_sha(ref_type, sha) args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha}) names = Gitlab::Popen.popen(args, path_to_repo).first @@ -815,7 +782,7 @@ class Repository } options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) - Rugged::Commit.create(rugged, options) + create_commit(options) end end # rubocop:enable Metrics/ParameterLists @@ -859,10 +826,10 @@ class Repository actual_options = options.merge( parents: [our_commit, their_commit], - tree: merge_index.write_tree(rugged), + tree: merge_index.write_tree(rugged) ) - commit_id = Rugged::Commit.create(rugged, actual_options) + commit_id = create_commit(actual_options) merge_request.update(in_progress_merge_commit_sha: commit_id) commit_id end @@ -885,12 +852,11 @@ class Repository committer = user_to_committer(user) - Rugged::Commit.create(rugged, - message: commit.revert_message(user), - author: committer, - committer: committer, - tree: revert_tree_id, - parents: [start_commit.sha]) + create_commit(message: commit.revert_message(user), + author: committer, + committer: committer, + tree: revert_tree_id, + parents: [start_commit.sha]) end end @@ -909,16 +875,15 @@ class Repository committer = user_to_committer(user) - Rugged::Commit.create(rugged, - message: commit.message, - author: { - email: commit.author_email, - name: commit.author_name, - time: commit.authored_date - }, - committer: committer, - tree: cherry_pick_tree_id, - parents: [start_commit.sha]) + create_commit(message: commit.message, + author: { + email: commit.author_email, + name: commit.author_name, + time: commit.authored_date + }, + committer: committer, + tree: cherry_pick_tree_id, + parents: [start_commit.sha]) end end @@ -926,7 +891,7 @@ class Repository GitOperationService.new(user, self).with_branch(branch_name) do committer = user_to_committer(user) - Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer)) + create_commit(params.merge(author: committer, committer: committer)) end end @@ -981,7 +946,13 @@ class Repository end def is_ancestor?(ancestor_id, descendant_id) - merge_base(ancestor_id, descendant_id) == ancestor_id + Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled| + if is_enabled + raw_repository.is_ancestor?(ancestor_id, descendant_id) + else + merge_base_commit(ancestor_id, descendant_id) == ancestor_id + end + end end def empty_repo? @@ -1027,6 +998,23 @@ class Repository rugged.references.delete(tmp_ref) if tmp_ref end + def add_remote(name, url) + raw_repository.remote_add(name, url) + rescue Rugged::ConfigError + raw_repository.remote_update(name, url: url) + end + + def remove_remote(name) + raw_repository.remote_delete(name) + true + rescue Rugged::ConfigError + false + end + + def fetch_remote(remote, forced: false, no_tags: false) + gitlab_shell.fetch_remote(repository_storage_path, path_with_namespace, remote, forced: forced, no_tags: no_tags) + end + def fetch_ref(source_path, source_ref, target_ref) args = %W(#{Gitlab.config.git.bin_path} fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref}) Gitlab::Popen.popen(args, path_to_repo) @@ -1066,14 +1054,20 @@ class Repository # # key - The name of the key to cache the data in. # fallback - A value to fall back to in the event of a Git error. - def cache_method_output(key, fallback: nil, &block) + def cache_method_output(key, fallback: nil, memoize_only: false, &block) ivar = cache_instance_variable_name(key) if instance_variable_defined?(ivar) instance_variable_get(ivar) else begin - instance_variable_set(ivar, cache.fetch(key, &block)) + value = + if memoize_only + yield + else + cache.fetch(key, &block) + end + instance_variable_set(ivar, value) rescue Rugged::ReferenceError, Gitlab::Git::Repository::NoRepository # if e.g. HEAD or the entire repository doesn't exist we want to # gracefully handle this and not cache anything. @@ -1088,8 +1082,8 @@ class Repository def file_on_head(type) if head = tree(:head) - head.blobs.find do |file| - Gitlab::FileDetector.type_of(file.name) == type + head.blobs.find do |blob| + Gitlab::FileDetector.type_of(blob.path) == type end end end @@ -1144,4 +1138,18 @@ class Repository def repository_event(event, tags = {}) Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags)) end + + def create_commit(params = {}) + params[:message].delete!("\r") + + Rugged::Commit.create(rugged, params) + end + + def repository_storage_path + @project.repository_storage_path + end + + def initialize_raw_repository + Gitlab::Git::Repository.new(project.repository_storage, path_with_namespace + '.git') + end end diff --git a/app/models/route.rb b/app/models/route.rb index 4b3efab5c3c..be77b8b51a5 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -8,29 +8,58 @@ class Route < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } + after_create :delete_conflicting_redirects + after_update :delete_conflicting_redirects, if: :path_changed? + after_update :create_redirect_for_old_path after_update :rename_descendants scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") } def rename_descendants - if path_changed? || name_changed? - descendants = self.class.inside_path(path_was) + return unless path_changed? || name_changed? - descendants.each do |route| - attributes = {} + descendant_routes = self.class.inside_path(path_was) - if path_changed? && route.path.present? - attributes[:path] = route.path.sub(path_was, path) - end + descendant_routes.each do |route| + attributes = {} - if name_changed? && name_was.present? && route.name.present? - attributes[:name] = route.name.sub(name_was, name) - end + if path_changed? && route.path.present? + attributes[:path] = route.path.sub(path_was, path) + end - # Note that update_columns skips validation and callbacks. - # We need this to avoid recursive call of rename_descendants method - route.update_columns(attributes) unless attributes.empty? + if name_changed? && name_was.present? && route.name.present? + attributes[:name] = route.name.sub(name_was, name) + end + + if attributes.present? + old_path = route.path + + # Callbacks must be run manually + route.update_columns(attributes.merge(updated_at: Time.now)) + + # We are not calling route.delete_conflicting_redirects here, in hopes + # of avoiding deadlocks. The parent (self, in this method) already + # called it, which deletes conflicts for all descendants. + route.create_redirect(old_path) if attributes[:path] end end end + + def delete_conflicting_redirects + conflicting_redirects.delete_all + end + + def conflicting_redirects + RedirectRoute.matching_path_and_descendants(path) + end + + def create_redirect(path) + RedirectRoute.create(source: source, path: path) + end + + private + + def create_redirect_for_old_path + create_redirect(path_was) if path_changed? + end end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index f4bcb49b34d..0ae5864615a 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -5,10 +5,11 @@ class SentNotification < ActiveRecord::Base belongs_to :noteable, polymorphic: true belongs_to :recipient, class_name: "User" - validates :project, :recipient, :reply_key, presence: true - validates :reply_key, uniqueness: true + validates :project, :recipient, presence: true + validates :reply_key, presence: true, uniqueness: true validates :noteable_id, presence: true, unless: :for_commit? validates :commit_id, presence: true, if: :for_commit? + validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true } validate :note_valid after_save :keep_around_commit @@ -22,9 +23,7 @@ class SentNotification < ActiveRecord::Base find_by(reply_key: reply_key) end - def record(noteable, recipient_id, reply_key, attrs = {}) - return unless reply_key - + def record(noteable, recipient_id, reply_key = self.reply_key, attrs = {}) noteable_id = nil commit_id = nil if noteable.is_a?(Commit) @@ -34,23 +33,20 @@ class SentNotification < ActiveRecord::Base end attrs.reverse_merge!( - project: noteable.project, - noteable_type: noteable.class.name, - noteable_id: noteable_id, - commit_id: commit_id, - recipient_id: recipient_id, - reply_key: reply_key + project: noteable.project, + recipient_id: recipient_id, + reply_key: reply_key, + + noteable_type: noteable.class.name, + noteable_id: noteable_id, + commit_id: commit_id ) create(attrs) end - def record_note(note, recipient_id, reply_key, attrs = {}) - if note.diff_note? - attrs[:note_type] = note.type - - attrs.merge!(note.diff_attributes) - end + def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {}) + attrs[:in_reply_to_discussion_id] = note.discussion_id record(note.noteable, recipient_id, reply_key, attrs) end @@ -89,31 +85,45 @@ class SentNotification < ActiveRecord::Base self.reply_key end - def note_attributes - { - project: self.project, - author: self.recipient, - type: self.note_type, - noteable_type: self.noteable_type, - noteable_id: self.noteable_id, - commit_id: self.commit_id, - line_code: self.line_code, - position: self.position.to_json - } - end - - def create_note(note) - Notes::CreateService.new( - self.project, - self.recipient, - self.note_attributes.merge(note: note) - ).execute + def create_reply(message, dryrun: false) + klass = dryrun ? Notes::BuildService : Notes::CreateService + klass.new(self.project, self.recipient, reply_params.merge(note: message)).execute end private + def reply_params + attrs = { + noteable_type: self.noteable_type, + noteable_id: self.noteable_id, + commit_id: self.commit_id + } + + if self.in_reply_to_discussion_id.present? + attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id + else + # Remove in GitLab 10.0, when we will not support replying to SentNotifications + # that don't have `in_reply_to_discussion_id` anymore. + attrs.merge!( + type: self.note_type, + + # LegacyDiffNote + line_code: self.line_code, + + # DiffNote + position: self.position.to_json + ) + end + + attrs + end + def note_valid - Note.new(note_attributes.merge(note: "Test")).valid? + note = create_reply('Test', dryrun: true) + + unless note.valid? + self.errors.add(:base, "Note parameters are invalid: #{note.errors.full_messages.to_sentence}") + end end def keep_around_commit diff --git a/app/models/service.rb b/app/models/service.rb index e73f7e5d1a3..8916f88076e 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -12,7 +12,7 @@ class Service < ActiveRecord::Base default_value_for :merge_requests_events, true default_value_for :tag_push_events, true default_value_for :note_events, true - default_value_for :build_events, true + default_value_for :job_events, true default_value_for :pipeline_events, true default_value_for :wiki_page_events, true @@ -25,7 +25,8 @@ class Service < ActiveRecord::Base belongs_to :project, inverse_of: :services has_one :service_hook - validates :project_id, presence: true, unless: Proc.new { |service| service.template? } + validates :project_id, presence: true, unless: proc { |service| service.template? } + validates :type, presence: true scope :visible, -> { where.not(type: 'GitlabIssueTrackerService') } scope :issue_trackers, -> { where(category: 'issue_tracker') } @@ -39,7 +40,7 @@ class Service < ActiveRecord::Base scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) } scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) } scope :note_hooks, -> { where(note_events: true, active: true) } - scope :build_hooks, -> { where(build_events: true, active: true) } + scope :job_hooks, -> { where(job_events: true, active: true) } scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } @@ -131,7 +132,7 @@ class Service < ActiveRecord::Base end def can_test? - !project.empty_repo? + true end # reason why service cannot be tested @@ -237,8 +238,11 @@ class Service < ActiveRecord::Base slack_slash_commands slack teamcity + microsoft_teams ] - service_names << 'mock_ci' if Rails.env.development? + if Rails.env.development? + service_names += %w[mock_ci mock_deployment mock_monitoring] + end service_names.sort_by(&:downcase) end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 30aca62499c..882e2fa0594 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -1,7 +1,7 @@ class Snippet < ActiveRecord::Base include Gitlab::VisibilityLevel - include Linguist::BlobHelper include CacheMarkdownField + include Noteable include Participable include Referable include Sortable @@ -12,6 +12,11 @@ class Snippet < ActiveRecord::Base cache_markdown_field :title, pipeline: :single_line cache_markdown_field :content + # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102 + alias_attribute :last_edited_at, :updated_at + alias_attribute :last_edited_by, :updated_by + # If file_name changes, it invalidates content alias_method :default_content_html_invalidator, :content_html_invalidated? def content_html_invalidated? @@ -86,47 +91,26 @@ class Snippet < ActiveRecord::Base ] end - def data - content + def blob + @blob ||= Blob.decorate(SnippetBlob.new(self), nil) end def hook_attrs attributes end - def size - 0 - end - def file_name super.to_s end - # alias for compatibility with blobs and highlighting - def path - file_name - end - - def name - file_name - end - def sanitized_file_name file_name.gsub(/[^a-zA-Z0-9_\-\.]+/, '') end - def mode - nil - end - def visibility_level_field :visibility_level end - def no_highlighting? - content.lines.count > 1000 - end - def notes_with_associations notes.includes(:author) end @@ -168,18 +152,5 @@ class Snippet < ActiveRecord::Base where(table[:content].matches(pattern)) end - - def accessible_to(user) - return are_public unless user.present? - return all if user.admin? - - where( - 'visibility_level IN (:visibility_levels) - OR author_id = :author_id - OR project_id IN (:project_ids)', - visibility_levels: [Snippet::PUBLIC, Snippet::INTERNAL], - author_id: user.id, - project_ids: user.authorized_projects.select(:id)) - end end end diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb new file mode 100644 index 00000000000..fa5fa151607 --- /dev/null +++ b/app/models/snippet_blob.rb @@ -0,0 +1,31 @@ +class SnippetBlob + include BlobLike + + attr_reader :snippet + + def initialize(snippet) + @snippet = snippet + end + + delegate :id, to: :snippet + + def name + snippet.file_name + end + + alias_method :path, :name + + def size + data.bytesize + end + + def data + snippet.content + end + + def rendered_markup + return unless Gitlab::MarkupHelper.gitlab_markdown?(name) + + Banzai.render_field(snippet, :content) + end +end diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb index 3b8b9833565..dd21ee15c6c 100644 --- a/app/models/spam_log.rb +++ b/app/models/spam_log.rb @@ -3,9 +3,9 @@ class SpamLog < ActiveRecord::Base validates :user, presence: true - def remove_user + def remove_user(deleted_by:) user.block - user.destroy + DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true, hard_delete: true) end def text diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 5cc66574941..b44f4fe000c 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -1,7 +1,7 @@ class SystemNoteMetadata < ActiveRecord::Base ICON_TYPES = %w[ - commit merge confidentiality status label assignee cross_reference - title time_tracking branch milestone discussion task moved + commit description merge confidential visible label assignee cross_reference + title time_tracking branch milestone discussion task moved opened closed merged ].freeze validates :note, presence: true diff --git a/app/models/todo.rb b/app/models/todo.rb index da3fa7277c2..b011001b235 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -84,6 +84,10 @@ class Todo < ActiveRecord::Base action == BUILD_FAILED end + def assigned? + action == ASSIGNED + end + def action_name ACTION_NAMES[action] end @@ -117,6 +121,14 @@ class Todo < ActiveRecord::Base end end + def self_added? + author == user + end + + def self_assigned? + assigned? && self_added? + end + private def keep_around_commit diff --git a/app/models/tree.rb b/app/models/tree.rb index fe148b0ec65..c89b8eca9be 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -40,10 +40,7 @@ class Tree readme_path = path == '/' ? readme_tree.name : File.join(path, readme_tree.name) - git_repo = repository.raw_repository - @readme = Gitlab::Git::Blob.find(git_repo, sha, readme_path) - @readme.load_all_data!(git_repo) - @readme + @readme = repository.blob_at(sha, readme_path) end def trees diff --git a/app/models/user.rb b/app/models/user.rb index cbd741f96ed..837ab78228b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,6 +5,7 @@ class User < ActiveRecord::Base include Gitlab::ConfigHelper include Gitlab::CurrentSettings + include Avatarable include Referable include Sortable include CaseSensitivity @@ -23,6 +24,7 @@ class User < ActiveRecord::Base default_value_for :hide_no_password, false default_value_for :project_view, :files default_value_for :notified_of_own_activity, false + default_value_for :preferred_language, I18n.default_locale attr_encrypted :otp_secret, key: Gitlab::Application.secrets.otp_key_base, @@ -39,6 +41,17 @@ class User < ActiveRecord::Base devise :lockable, :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :confirmable, :registerable + # Override Devise::Models::Trackable#update_tracked_fields! + # to limit database writes to at most once every hour + def update_tracked_fields!(request) + update_tracked_fields(request) + + lease = Gitlab::ExclusiveLease.new("user_update_tracked_fields:#{id}", timeout: 1.hour.to_i) + return unless lease.try_obtain + + save(validate: false) + end + attr_accessor :force_random_password # Virtual attribute for authenticating by either username or email @@ -89,7 +102,8 @@ class User < ActiveRecord::Base has_many :subscriptions, dependent: :destroy has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy - has_one :abuse_report, dependent: :destroy + has_one :abuse_report, dependent: :destroy, foreign_key: :user_id + has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" has_many :spam_logs, dependent: :destroy has_many :builds, dependent: :nullify, class_name: 'Ci::Build' has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' @@ -98,7 +112,8 @@ class User < ActiveRecord::Base has_many :award_emoji, dependent: :destroy has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id - has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue" + has_many :issue_assignees + has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # Issues that a user owns are expected to be moved to the "ghost" user before @@ -120,7 +135,7 @@ class User < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } validates :username, - namespace: true, + dynamic_path: true, presence: true, uniqueness: { case_sensitive: false } @@ -151,8 +166,13 @@ class User < ActiveRecord::Base enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos] # User's Project preference - # Note: When adding an option, it MUST go on the end of the array. - enum project_view: [:readme, :activity, :files] + # + # Note: When adding an option, it MUST go on the end of the hash with a + # number higher than the current max. We cannot move options and/or change + # their numbers. + # + # We skip 0 because this was used by an option that has since been removed. + enum project_view: { activity: 1, files: 2 } alias_attribute :private_token, :authentication_token @@ -196,7 +216,7 @@ class User < ActiveRecord::Base scope :admins, -> { where(admin: true) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } - scope :active, -> { with_state(:active) } + scope :active, -> { with_state(:active).non_internal } scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all } scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') } scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) } @@ -334,6 +354,11 @@ class User < ActiveRecord::Base find_by(id: Key.unscoped.select(:user_id).where(id: key_id)) end + def find_by_full_path(path, follow_redirects: false) + namespace = Namespace.for_user.find_by_full_path(path, follow_redirects: follow_redirects) + namespace&.owner + end + def reference_prefix '@' end @@ -356,6 +381,10 @@ class User < ActiveRecord::Base end end + def full_path + username + end + def self.internal_attributes [:ghost] end @@ -484,6 +513,14 @@ class User < ActiveRecord::Base Group.member_descendants(id) end + def all_expanded_groups + Group.member_hierarchy(id) + end + + def expanded_groups_requiring_two_factor_authentication + all_expanded_groups.where(require_two_factor_authentication: true) + end + def nested_groups_projects Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL'). member_descendants(id) @@ -546,10 +583,6 @@ class User < ActiveRecord::Base authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled end - def is_admin? - admin - end - def require_ssh_key? keys.count == 0 && Gitlab::ProtocolAccess.allowed?('ssh') end @@ -582,10 +615,6 @@ class User < ActiveRecord::Base name.split.first unless name.blank? end - def cared_merge_requests - MergeRequest.cared(self) - end - def projects_limit_left projects_limit - personal_projects.count end @@ -635,8 +664,10 @@ class User < ActiveRecord::Base end def fork_of(project) - links = ForkedProjectLink.where(forked_from_project_id: project, forked_to_project_id: personal_projects) - + links = ForkedProjectLink.where( + forked_from_project_id: project, + forked_to_project_id: personal_projects.unscope(:order) + ) if links.any? links.first.forked_to_project else @@ -759,12 +790,10 @@ class User < ActiveRecord::Base email.start_with?('temp-email-for-oauth') end - def avatar_url(size = nil, scale = 2) - if self[:avatar].present? - [gitlab_config.url, avatar.url].join - else - GravatarService.new.execute(email, size, scale) - end + def avatar_url(size: nil, scale: 2, **args) + # We use avatar_path instead of overriding avatar_url because of carrierwave. + # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 + avatar_path(args) || GravatarService.new.execute(email, size, scale) end def all_emails @@ -888,23 +917,36 @@ class User < ActiveRecord::Base @global_notification_setting end - def assigned_open_merge_request_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_merge_request_count'], force: force) do - assigned_merge_requests.opened.count + def assigned_open_merge_requests_count(force: false) + Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force) do + MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened').execute.count end end def assigned_open_issues_count(force: false) Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do - assigned_issues.opened.count + IssuesFinder.new(self, assignee_id: self.id, state: 'opened').execute.count end end def update_cache_counts - assigned_open_merge_request_count(force: true) + assigned_open_merge_requests_count(force: true) assigned_open_issues_count(force: true) end + def invalidate_cache_counts + invalidate_issue_cache_counts + invalidate_merge_request_cache_counts + end + + def invalidate_issue_cache_counts + Rails.cache.delete(['users', id, 'assigned_open_issues_count']) + end + + def invalidate_merge_request_cache_counts + Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count']) + end + def todos_done_count(force: false) Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do TodosFinder.new(self, state: :done).execute.count @@ -953,6 +995,15 @@ class User < ActiveRecord::Base self.admin = (new_level == 'admin') end + def update_two_factor_requirement + periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period) + + self.require_two_factor_authentication_from_group = periods.any? + self.two_factor_grace_period = periods.min || User.column_defaults['two_factor_grace_period'] + + save + end + protected # override, from Devise::Validatable @@ -977,6 +1028,15 @@ class User < ActiveRecord::Base devise_mailer.send(notification, self, *args).deliver_later end + # This works around a bug in Devise 4.2.0 that erroneously causes a user to + # be considered active in MySQL specs due to a sub-second comparison + # issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709 + def confirmation_period_valid? + return false if self.class.allow_unconfirmed_access_for == 0.days + + super + end + def ensure_external_user_rights return unless external? @@ -1059,11 +1119,13 @@ class User < ActiveRecord::Base User.find_by_email(s) end - scope.create( + user = scope.build( username: username, email: email, &creation_block ) + user.save(validate: false) + user ensure Gitlab::ExclusiveLease.cancel(lease_key, uuid) end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 8890409d056..623424c63e0 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -97,6 +97,10 @@ class BasePolicy rules end + def rules + raise NotImplementedError + end + def delegate!(new_subject) @rule_set.merge(Ability.allowed(@user, new_subject)) end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 8b25332b73c..d4af4490608 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -1,5 +1,7 @@ module Ci class BuildPolicy < CommitStatusPolicy + alias_method :build, :subject + def rules super @@ -8,6 +10,20 @@ module Ci %w[read create update admin].each do |rule| cannot! :"#{rule}_commit_status" unless can? :"#{rule}_build" end + + if can?(:update_build) && protected_action? + cannot! :update_build + end + end + + private + + def protected_action? + return false unless build.action? + + !::Gitlab::UserAccess + .new(user, project: build.project) + .can_push_to_branch?(build.ref) end end end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 3d2eef1c50c..10aa2d3e72a 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -1,4 +1,7 @@ module Ci - class PipelinePolicy < BuildPolicy + class PipelinePolicy < BasePolicy + def rules + delegate! @subject.project + end end end diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb new file mode 100644 index 00000000000..1877e89bb23 --- /dev/null +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -0,0 +1,4 @@ +module Ci + class PipelineSchedulePolicy < PipelinePolicy + end +end diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index 7edd383530d..416d93ffe63 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -3,7 +3,7 @@ module Ci def rules return unless @user - can! :assign_runner if @user.is_admin? + can! :assign_runner if @user.admin? return if @subject.is_shared? || @subject.locked? diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb index f4219569161..2fa15e64562 100644 --- a/app/policies/environment_policy.rb +++ b/app/policies/environment_policy.rb @@ -1,5 +1,17 @@ class EnvironmentPolicy < BasePolicy + alias_method :environment, :subject + def rules - delegate! @subject.project + delegate! environment.project + + if can?(:create_deployment) && environment.stop_action? + can! :stop_environment if can_play_stop_action? + end + end + + private + + def can_play_stop_action? + Ability.allowed?(user, :update_build, environment.stop_action) end end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index cb72c2b4590..4757ba71680 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -10,6 +10,7 @@ class GlobalPolicy < BasePolicy can! :access_api can! :access_git can! :receive_notifications + can! :use_slash_commands end end end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 4cc21696eb6..87398303c68 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -12,7 +12,7 @@ class GroupPolicy < BasePolicy can_read ||= globally_viewable can_read ||= member can_read ||= @user.admin? - can_read ||= GroupProjectsFinder.new(@subject).execute(@user).any? + can_read ||= GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any? can! :read_group if can_read # Only group masters and group owners can create new projects @@ -28,6 +28,7 @@ class GroupPolicy < BasePolicy can! :admin_namespace can! :admin_group_member can! :change_visibility_level + can! :create_subgroup if @user.can_create_group end if globally_viewable && @subject.request_access_enabled && !member @@ -41,6 +42,6 @@ class GroupPolicy < BasePolicy return true if @subject.internal? && !@user.external? return true if @subject.users.include?(@user) - GroupProjectsFinder.new(@subject).execute(@user).any? + GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any? end end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index d3913986cd8..e1e5336da8c 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -3,11 +3,16 @@ class PersonalSnippetPolicy < BasePolicy can! :read_personal_snippet if @subject.public? return unless @user + if @subject.public? + can! :comment_personal_snippet + end + if @subject.author == @user can! :read_personal_snippet can! :update_personal_snippet can! :destroy_personal_snippet can! :admin_personal_snippet + can! :comment_personal_snippet end unless @user.external? @@ -16,6 +21,7 @@ class PersonalSnippetPolicy < BasePolicy if @subject.internal? && !@user.external? can! :read_personal_snippet + can! :comment_personal_snippet end end end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index f8594e29547..3959b895f44 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -2,20 +2,13 @@ class ProjectPolicy < BasePolicy def rules team_access!(user) - owner = project.owner == user || - (project.group && project.group.has_owner?(user)) - - owner_access! if user.admin? || owner - team_member_owner_access! if owner + owner_access! if user.admin? || owner? + team_member_owner_access! if owner? if project.public? || (project.internal? && !user.external?) guest_access! public_access! - - if project.request_access_enabled && - !(owner || user.admin? || project.team.member?(user) || project_group_member?(user)) - can! :request_access - end + can! :request_access if access_requestable? end archived_access! if project.archived? @@ -27,6 +20,13 @@ class ProjectPolicy < BasePolicy @subject end + def owner? + return @owner if defined?(@owner) + + @owner = project.owner == user || + (project.group && project.group.has_owner?(user)) + end + def guest_access! can! :read_project can! :read_board @@ -46,6 +46,7 @@ class ProjectPolicy < BasePolicy if project.public_builds? can! :read_pipeline + can! :read_pipeline_schedule can! :read_build end end @@ -63,6 +64,7 @@ class ProjectPolicy < BasePolicy can! :read_build can! :read_container_image can! :read_pipeline + can! :read_pipeline_schedule can! :read_environment can! :read_deployment can! :read_merge_request @@ -83,6 +85,8 @@ class ProjectPolicy < BasePolicy can! :update_build can! :create_pipeline can! :update_pipeline + can! :create_pipeline_schedule + can! :update_pipeline_schedule can! :create_merge_request can! :create_wiki can! :push_code @@ -94,7 +98,7 @@ class ProjectPolicy < BasePolicy end def master_access! - can! :push_code_to_protected_branches + can! :delete_protected_branch can! :update_project_snippet can! :update_environment can! :update_deployment @@ -108,6 +112,7 @@ class ProjectPolicy < BasePolicy can! :admin_build can! :admin_container_image can! :admin_pipeline + can! :admin_pipeline_schedule can! :admin_environment can! :admin_deployment can! :admin_pages @@ -120,6 +125,7 @@ class ProjectPolicy < BasePolicy can! :fork_project can! :read_commit_status can! :read_pipeline + can! :read_pipeline_schedule can! :read_container_image can! :build_download_code can! :build_read_container_image @@ -167,7 +173,7 @@ class ProjectPolicy < BasePolicy def archived_access! cannot! :create_merge_request cannot! :push_code - cannot! :push_code_to_protected_branches + cannot! :delete_protected_branch cannot! :update_merge_request cannot! :admin_merge_request end @@ -198,13 +204,14 @@ class ProjectPolicy < BasePolicy unless project.feature_available?(:builds, user) && repository_enabled cannot!(*named_abilities(:build)) cannot!(*named_abilities(:pipeline)) + cannot!(*named_abilities(:pipeline_schedule)) cannot!(*named_abilities(:environment)) cannot!(*named_abilities(:deployment)) end unless repository_enabled cannot! :push_code - cannot! :push_code_to_protected_branches + cannot! :delete_protected_branch cannot! :download_code cannot! :fork_project cannot! :read_commit_status @@ -226,14 +233,6 @@ class ProjectPolicy < BasePolicy disabled_features! end - def project_group_member?(user) - project.group && - ( - project.group.members_with_parents.exists?(user_id: user.id) || - project.group.requesters.exists?(user_id: user.id) - ) - end - def block_issues_abilities unless project.feature_available?(:issues, user) cannot! :read_issue if project.default_issues_tracker? @@ -254,6 +253,22 @@ class ProjectPolicy < BasePolicy private + def project_group_member?(user) + project.group && + ( + project.group.members_with_parents.exists?(user_id: user.id) || + project.group.requesters.exists?(user_id: user.id) + ) + end + + def access_requestable? + project.request_access_enabled && + !owner? && + !user.admin? && + !project.team.member?(user) && + !project_group_member?(user) + end + # A base set of abilities for read-only users, which # is then augmented as necessary for anonymous and other # read-only users. @@ -269,6 +284,7 @@ class ProjectPolicy < BasePolicy can! :read_merge_request can! :read_note can! :read_pipeline + can! :read_pipeline_schedule can! :read_commit_status can! :read_container_image can! :download_code diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index 3a96836917e..cf8ff92617f 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -13,7 +13,7 @@ class ProjectSnippetPolicy < BasePolicy can! :read_project_snippet end - if @subject.private? && @subject.project.team.member?(@user) + if @subject.project.team.member?(@user) can! :read_project_snippet end end diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index ed72ed14d72..c495c3f39bb 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -11,5 +11,11 @@ module Ci def erased_by_name erased_by.name if erased_by_user? end + + def status_title + if auto_canceled? + "Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" + end + end end end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb new file mode 100644 index 00000000000..a542bdd8295 --- /dev/null +++ b/app/presenters/ci/pipeline_presenter.rb @@ -0,0 +1,11 @@ +module Ci + class PipelinePresenter < Gitlab::View::Presenter::Delegated + presents :pipeline + + def status_title + if auto_canceled? + "Pipeline is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" + end + end + end +end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb new file mode 100644 index 00000000000..0db9e31031c --- /dev/null +++ b/app/presenters/merge_request_presenter.rb @@ -0,0 +1,172 @@ +class MergeRequestPresenter < Gitlab::View::Presenter::Delegated + include ActionView::Helpers::UrlHelper + include GitlabRoutingHelper + include MarkupHelper + include TreeHelper + + presents :merge_request + + def ci_status + if pipeline + status = pipeline.status + status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? + + status || "preparing" + else + ci_service = source_project.try(:ci_service) + ci_service&.commit_status(diff_head_sha, source_branch) + end + end + + def cancel_merge_when_pipeline_succeeds_path + if can_cancel_merge_when_pipeline_succeeds?(current_user) + cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path( + project.namespace, + project, + merge_request) + end + end + + def create_issue_to_resolve_discussions_path + if can?(current_user, :create_issue, project) && project.issues_enabled? + new_namespace_project_issue_path(project.namespace, + project, + merge_request_to_resolve_discussions_of: iid) + end + end + + def remove_wip_path + if can?(current_user, :update_merge_request, merge_request.project) + remove_wip_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def merge_path + if can_be_merged_by?(current_user) + merge_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def revert_in_fork_path + if user_can_fork_project? && can_be_reverted?(current_user) + continue_params = { + to: merge_request_path(merge_request), + notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.", + notice_now: edit_in_new_fork_notice_now + } + + namespace_project_forks_path(merge_request.project.namespace, merge_request.project, + namespace_key: current_user.namespace.id, + continue: continue_params) + end + end + + def cherry_pick_in_fork_path + if user_can_fork_project? && can_be_cherry_picked? + continue_params = { + to: merge_request_path(merge_request), + notice: "#{edit_in_new_fork_notice} Try to revert this commit again.", + notice_now: edit_in_new_fork_notice_now + } + + namespace_project_forks_path(project.namespace, project, + namespace_key: current_user.namespace.id, + continue: continue_params) + end + end + + def conflict_resolution_path + if conflicts.can_be_resolved_in_ui? && conflicts.can_be_resolved_by?(current_user) + conflicts_namespace_project_merge_request_path(project.namespace, project, merge_request) + end + end + + def target_branch_commits_path + if target_branch_exists? + namespace_project_commits_path(project.namespace, project, target_branch) + end + end + + def source_branch_path + if source_branch_exists? + namespace_project_branch_path(source_project.namespace, source_project, source_branch) + end + end + + def source_branch_with_namespace_link + namespace = source_project_namespace + branch = source_branch + + if source_branch_exists? + namespace = link_to(namespace, project_path(source_project)) + branch = link_to(branch, namespace_project_commits_path(source_project.namespace, source_project, source_branch)) + end + + if for_fork? + namespace + ":" + branch + else + branch + end + end + + def closing_issues_links + markdown issues_sentence(project, closing_issues), pipeline: :gfm, author: author, project: project + end + + def mentioned_issues_links + mentioned_issues = issues_mentioned_but_not_closing(current_user) + markdown issues_sentence(project, mentioned_issues), pipeline: :gfm, author: author, project: project + end + + def assign_to_closing_issues_link + issues = MergeRequests::AssignIssuesService.new(project, + current_user, + merge_request: merge_request, + closes_issues: closing_issues + ).assignable_issues + path = assign_related_issues_namespace_project_merge_request_path(project.namespace, project, merge_request) + if issues.present? + pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue" + link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post + end + end + + def can_revert_on_current_merge_request? + user_can_collaborate_with_project? && can_be_reverted?(current_user) + end + + def can_cherry_pick_on_current_merge_request? + user_can_collaborate_with_project? && can_be_cherry_picked? + end + + private + + def conflicts + @conflicts ||= MergeRequests::Conflicts::ListService.new(merge_request) + end + + def closing_issues + @closing_issues ||= closes_issues(current_user) + end + + def pipeline + @pipeline ||= head_pipeline + end + + def issues_sentence(project, issues) + # Sorting based on the `#123` or `group/project#123` reference will sort + # local issues first. + issues.map do |issue| + issue.to_reference(project) + end.sort.to_sentence + end + + def user_can_collaborate_with_project? + can?(current_user, :push_code, project) || + (current_user && current_user.already_forked?(project)) + end + + def user_can_fork_project? + can?(current_user, :fork_project, project) + end +end diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 86ac513b3c0..070b0c35e36 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -48,6 +48,17 @@ module Projects available_public_keys.any? end + def as_json + serializer = DeployKeySerializer.new + opts = { user: current_user } + + { + enabled_keys: serializer.represent(enabled_keys, opts), + available_project_keys: serializer.represent(available_project_keys, opts), + public_keys: serializer.represent(available_public_keys, opts) + } + end + def to_partial_path 'projects/deploy_keys/index' end diff --git a/app/serializers/README.md b/app/serializers/README.md new file mode 100644 index 00000000000..0337f88db5f --- /dev/null +++ b/app/serializers/README.md @@ -0,0 +1,325 @@ +# Serializers + +This is a documentation for classes located in `app/serializers` directory. + +In GitLab, we use [grape-entities][grape-entity-project], accompanied by a +serializer, to convert a Ruby object to its JSON representation. + +Serializers are typically used in controllers to build a JSON response +that is usually consumed by a frontend code. + +## Why using a serializer is important? + +Using serializers, instead of `to_json` method, has several benefits: + +* it helps to prevent exposure of a sensitive data stored in the database +* it makes it easier to test what should and should not be exposed +* it makes it easier to reuse serialization entities that are building blocks +* it makes it easier to move complexity from controllers to easily testable + classes +* it encourages hiding complexity behind intentions-revealing interfaces +* it makes it easier to take care about serialization performance concerns +* it makes it easier to reduce merge conflicts between CE -> EE +* it makes it easier to benefit from domain driven development techniques + +## What is a serializer? + +A serializer is a class that encapsulates all business rules for building a +JSON response using serialization entities. + +It is designed to be testable and to support passing additional context from +the controller. + +## What is a serialization entity? + +Entities are lightweight structures that allow to represent domain models +in a consistent and abstracted way, and reuse them as building blocks to +create a payload. + +Entities located in `app/serializers` are usually derived from a +[`Grape::Entity`][grape-entity-class] class. + +Serialization entities that do require to have a knowledge about specific +elements of the request, need to mix `RequestAwareEntity` in. + +A serialization entity usually maps a domain model class into its JSON +representation. It rarely happens that a serialization entity exists without +a corresponding domain model class. As an example, we have an `Issue` class and +a corresponding `IssueSerializer`. + +Serialization entites are designed to reuse other serialization entities, which +is a convenient way to create a multi-level JSON representation of a piece of +a domain model you want to serialize. + +See [documentation for Grape Entites][grape-entity-readme] for more details. + +## How to implement a serializer? + +### Base implementation + +In order to effectively implement a serializer it is necessary to create a new +class in `app/serializers`. See existing serializers as an example. + +A new serializer should inherit from a `BaseSerializer` class. It is necessary +to specify which serialization entity will be used to serialize a resource. + +```ruby +class MyResourceSerializer < BaseSerialize + entity MyResourceEntity +end +``` + +The example above shows how a most simple serializer can look like. + +Given that the entity `MyResourceEntity` exists, you can now use +`MyResourceSerializer` in the controller by creating an instance of it, and +calling `MyResourceSerializer#represent(resource)` method. + +Note that a `resource` can be either a single object, an array of objects or an +`ActiveRecord::Relation` object. A serialization entity should be smart enough +to accurately represent each of these. + +It should not be necessary to use `Enumerable#map`, and it should be avoided +from the performance reasons. + +### Choosing what gets serialized + +It often happens that you might want to use the same serializer in many places, +but sometimes the intention is to only expose a small subset of object's +attributes in one place, and a different subset in another. + +`BaseSerializer#represent(resource, opts = {})` method can take an additional +hash argument, `opts`, that defines what is going to be serialized. + +`BaseSerializer` will pass these options to a serialization entity. See +how it is [documented in the upstream project][grape-entity-only]. + +With this approach you can extend the serializer to respond to methods that will +create a JSON response according to your needs. + +```ruby +class PipelineSerializer < BaseSerializer + entity PipelineEntity + + def represent_details(resource) + represent(resource, only: [:details]) + end + + def represent_status(resource) + represent(resource, only: [:status]) + end +end +``` + +It is possible to use `only` and `except` keywords. Both keywords do support +nested attributes, like `except: [:id, { user: [:id] }]`. + +Passing `only` and `except` to the `represent` method from a controller is +possible, but it defies principles of encapsulation and testability, and it is +better to avoid it, and to add a specific method to the serializer instead. + +### Reusing serialization entities from the API + +Public API in GitLab is implemented using [Grape][grape-project]. + +Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes. +This means that it is possible to reuse these classes to implement internal +serializers. + +You can either use such entity directly: + +```ruby +class MyResourceSerializer < BaseSerializer + entity API::Entities::SomeEntity +end +``` + +Or derive a new serialization entity class from it: + +```ruby +class MyEntity < API::Entities::SomeEntity + include RequestAwareEntity + + unexpose :something +end +``` + +It might be a good idea to write specs for entities that do inherit from +the API, because when API payloads are changed / extended, it is easy to forget +about the impact on the internal API through a serializer that reuses API +entities. + +It is usually safe to do that, because API entities rarely break backward +compatibility, but additional exposure may have a performance impact when API +gets extended significantly. Write tests that check if only necessary data is +exposed. + +## How to write tests for a serializer? + +Like every other class in the project, creating a serializer warrants writing +tests for it. + +It is usually a good idea to test each public method in the serializer against +a valid payload. `BaseSerializer#represent` returns a hash, so it is possible +to use usual RSpec matchers like `include`. + +Sometimes, when the payload is large, it makes sense to validate it entirely +using `match_response_schema` matcher along with a new fixture that can be +stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema` +gem, which is quite flexible, see a [documentation][json-schema-gem] for it. + +## How to use a serializer in a controller? + +Once a new serializer is implemented, it is possible to use it in a controller. + +Create an instance of the serializer and render the response. + +```ruby +def index + format.json do + render json: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources) + nd +end +``` + +If it is necessary to include additional information in the payload, it is +possible to extend what is going to be rendered, the usual way: + +```ruby +def index + format.json do + render json: { + resources: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources), + count: @project.resources.count + } + nd +end +``` + +Note that in these examples an additional context is being passed to the +serializer (`current_user: @current_user`). + +## How to pass an additional context from the controller? + +It is possible to pass an additional context from a controller to a +serializer and each serialization entity that is used in the process. + +Serialization entities that do require an additional context have +`RequestAwareEntity` concern mixed in. This piece of the code exposes a method +called `request` in every serialization entity that is instantiated during +serialization. + +An object returned by this method is an instance of `EntityRequest`, which +behaves like an `OpenStruct` object, with the difference that it will raise +an error if an unknown method is called. + +In other words, in the previous example, `request` method will return an +instance of `EntityRequest` that responds to `current_user` method. It will be +available in every serialization entity instantiated by `MyResourceSerializer`. + +`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be +refactored soon. Please avoid passing an additional context that is not +required by a serialization entity. + +At the moment, the context that is passed to entities most often is +`current_user` and `project`. + +## How is this related to using presenters? + +Payload created by a serializer is usually a representation of the backed code, +combined with the current request data. Therefore, technically, serializers +are presenters that create payload consumed by a frontend code, usually Vue +components. + +In GitLab, it is possible to use [presenters][presenters-readme], but +`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898]. + +It is possible to use presenters when serializer is used to represent only +a single object. It is not supported when `ActiveRecord::Relation` is being +serialized. + +```ruby +MyObjectSerializer.new.represent(object.present) +``` + +## Best practices + +1. Do not invoke a serializer from within a serialization entity. + + If you need to use a serializer from within a serialization entity, it is + possible that you are missing a class for an important domain concept. + + Consider creating a new domain class and a corresponding serialization + entity for it. + +1. Use only one approach to switch behavior of the serializer. + + It is possible to use a few approaches to switch a behavior of the + serializer. Most common are using a [Fluent Interface][fluent-interface] + and creating a separate `represent_something` methods. + + Whatever you choose, it might be better to use only one approach at a time. + +1. Do not forget about creating specs for serialization entities. + + Writing tests for the serializer indeed does cover testing a behavior of + serialization entities that the serializer instantiates. However it might + be a good idea to write separate tests for entities as well, because these + are meant to be reused in different serializers, and a serializer can + change a behavior of a serialization entity. + +1. Use `ActiveRecord::Relation` where possible + + Using an `ActiveRecord::Relation` might help from the performance perspective. + +1. Be diligent about passing an additional context from the controller. + + Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack + of high-level mechanism. It is meant to be refactored, and current + implementation is error prone. Imagine the situation that one serialization + entity requires `request.user` attribute, but the second one wants + `request.current_user`. When it happens that these two entities are used in + the same serialization request, you might need to pass both parameters to + the serializer, which is obviously not a perfect situation. + + When in doubt, pass only `current_user` and `project` if these are required. + +1. Keep performance concerns in mind + + Using a serializer incorrectly can have significant impact on the + performance. + + Because serializers are technically presenters, it is often necessary + to calculate, for example, paths to various controller-actions. + Since using URL helpers usually involve passing `project` and `namespace` + adding `includes(project: :namespace)` in the serializer, can help to avoid + N+1 queries. + + Also, try to avoid using `Enumerable#map` or other methods that will + execute a database query eagerly. + +1. Avoid passing `only` and `except` from the controller. +1. Write tests checking for N+1 queries. +1. Write controller tests for actions / formats using serializers. +1. Write tests that check if only necessary data is exposed. +1. Write tests that check if no sensitive data is exposed. + +## Future + +* [Next iteration of serializers][issue-27569] + +[grape-project]: http://www.ruby-grape.org +[grape-entity-project]: https://github.com/ruby-grape/grape-entity +[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md +[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb +[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want +[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md +[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface +[json-schema-gem]: https://github.com/ruby-json-schema/json-schema +[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045 +[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898 +[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569 diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb index 69bf693de8d..564612202b5 100644 --- a/app/serializers/analytics_stage_entity.rb +++ b/app/serializers/analytics_stage_entity.rb @@ -2,6 +2,7 @@ class AnalyticsStageEntity < Grape::Entity include EntityDateHelper expose :title + expose :name expose :legend expose :description diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index 91803ec07f5..9c37afd53e1 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -1,7 +1,4 @@ class AnalyticsSummaryEntity < Grape::Entity expose :value, safe: true - - expose :title do |object| - object.title.pluralize(object.value) - end + expose :title end diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb index 311ee9c96be..4e6c15f673b 100644 --- a/app/serializers/base_serializer.rb +++ b/app/serializers/base_serializer.rb @@ -3,8 +3,10 @@ class BaseSerializer @request = EntityRequest.new(parameters) end - def represent(resource, opts = {}) - self.class.entity_class + def represent(resource, opts = {}, entity_class = nil) + entity_class = entity_class || self.class.entity_class + + entity_class .represent(resource, opts.merge(request: @request)) .as_json end diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 184f5fd4b52..5e99204c658 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -11,4 +11,14 @@ class BuildActionEntity < Grape::Entity build.project, build) end + + expose :playable?, as: :playable + + private + + alias_method :build, :object + + def playable? + build.playable? && can?(request.current_user, :update_build, build) + end end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index fadd6c5c597..e2276808b90 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -12,10 +12,11 @@ class BuildEntity < Grape::Entity path_to(:retry_namespace_project_build, build) end - expose :play_path, if: ->(build, _) { build.playable? } do |build| + expose :play_path, if: -> (*) { playable? } do |build| path_to(:play_namespace_project_build, build) end + expose :playable?, as: :playable expose :created_at expose :updated_at expose :detailed_status, as: :status, with: StatusEntity @@ -24,11 +25,15 @@ class BuildEntity < Grape::Entity alias_method :build, :object - def path_to(route, build) - send("#{route}_path", build.project.namespace, build.project, build) + def playable? + build.playable? && can?(request.current_user, :update_build, build) end def detailed_status - build.detailed_status(request.user) + build.detailed_status(request.current_user) + end + + def path_to(route, build) + send("#{route}_path", build.project.namespace, build.project, build) end end diff --git a/app/serializers/cohort_activity_month_entity.rb b/app/serializers/cohort_activity_month_entity.rb new file mode 100644 index 00000000000..e6788a8b596 --- /dev/null +++ b/app/serializers/cohort_activity_month_entity.rb @@ -0,0 +1,11 @@ +class CohortActivityMonthEntity < Grape::Entity + include ActionView::Helpers::NumberHelper + + expose :total do |cohort_activity_month| + number_with_delimiter(cohort_activity_month[:total]) + end + + expose :percentage do |cohort_activity_month| + number_to_percentage(cohort_activity_month[:percentage], precision: 0) + end +end diff --git a/app/serializers/cohort_entity.rb b/app/serializers/cohort_entity.rb new file mode 100644 index 00000000000..7cdba5b0484 --- /dev/null +++ b/app/serializers/cohort_entity.rb @@ -0,0 +1,17 @@ +class CohortEntity < Grape::Entity + include ActionView::Helpers::NumberHelper + + expose :registration_month do |cohort| + cohort[:registration_month].strftime('%b %Y') + end + + expose :total do |cohort| + number_with_delimiter(cohort[:total]) + end + + expose :inactive do |cohort| + number_with_delimiter(cohort[:inactive]) + end + + expose :activity_months, using: CohortActivityMonthEntity +end diff --git a/app/serializers/cohorts_entity.rb b/app/serializers/cohorts_entity.rb new file mode 100644 index 00000000000..98f5995ba6f --- /dev/null +++ b/app/serializers/cohorts_entity.rb @@ -0,0 +1,4 @@ +class CohortsEntity < Grape::Entity + expose :months_included + expose :cohorts, using: CohortEntity +end diff --git a/app/serializers/cohorts_serializer.rb b/app/serializers/cohorts_serializer.rb new file mode 100644 index 00000000000..fe9367b13d8 --- /dev/null +++ b/app/serializers/cohorts_serializer.rb @@ -0,0 +1,3 @@ +class CohortsSerializer < AnalyticsGenericSerializer + entity CohortsEntity +end diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb new file mode 100644 index 00000000000..d75a83d0fa5 --- /dev/null +++ b/app/serializers/deploy_key_entity.rb @@ -0,0 +1,14 @@ +class DeployKeyEntity < Grape::Entity + expose :id + expose :user_id + expose :title + expose :fingerprint + expose :can_push + expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned + expose :almost_orphaned?, as: :almost_orphaned + expose :created_at + expose :updated_at + expose :projects, using: ProjectEntity do |deploy_key| + deploy_key.projects.select { |project| options[:user].can?(:read_project, project) } + end +end diff --git a/app/serializers/deploy_key_serializer.rb b/app/serializers/deploy_key_serializer.rb new file mode 100644 index 00000000000..8f849eb88b7 --- /dev/null +++ b/app/serializers/deploy_key_serializer.rb @@ -0,0 +1,3 @@ +class DeployKeySerializer < BaseSerializer + entity DeployKeyEntity +end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index d610fbe0c8a..8b3de1bed0f 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -18,8 +18,10 @@ class DeploymentEntity < Grape::Entity end end + expose :created_at expose :tag expose :last? + expose :user, using: UserEntity expose :commit, using: CommitEntity expose :deployable, using: BuildEntity diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb new file mode 100644 index 00000000000..cba5c3f311f --- /dev/null +++ b/app/serializers/deployment_serializer.rb @@ -0,0 +1,8 @@ +class DeploymentSerializer < BaseSerializer + entity DeploymentEntity + + def represent_concise(resource, opts = {}) + opts[:only] = [:iid, :id, :sha, :created_at, :tag, :last?, :id, ref: [:name]] + represent(resource, opts) + end +end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 4ff15a78115..4e8a3c67b21 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -31,7 +31,7 @@ class EnvironmentEntity < Grape::Entity end expose :terminal_path, if: ->(environment, _) { environment.has_terminals? } do |environment| - can?(request.user, :admin_environment, environment.project) && + can?(request.current_user, :admin_environment, environment.project) && terminal_namespace_project_environment_path( environment.project.namespace, environment.project, diff --git a/app/serializers/event_entity.rb b/app/serializers/event_entity.rb new file mode 100644 index 00000000000..935d67a4f37 --- /dev/null +++ b/app/serializers/event_entity.rb @@ -0,0 +1,4 @@ +class EventEntity < Grape::Entity + expose :author, using: UserEntity + expose :updated_at +end diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index 29aecb50849..65b204d4dd2 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -1,7 +1,6 @@ class IssuableEntity < Grape::Entity expose :id expose :iid - expose :assignee_id expose :author_id expose :description expose :lock_version diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 6429159ebe1..bc4f68710b2 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,6 +1,7 @@ class IssueEntity < IssuableEntity expose :branch_name expose :confidential + expose :assignees, using: API::Entities::UserBasic expose :due_date expose :moved_to_id expose :project_id diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb new file mode 100644 index 00000000000..04487e59009 --- /dev/null +++ b/app/serializers/job_group_entity.rb @@ -0,0 +1,16 @@ +class JobGroupEntity < Grape::Entity + include RequestAwareEntity + + expose :name + expose :size + expose :detailed_status, as: :status, with: StatusEntity + expose :jobs, with: BuildEntity + + private + + alias_method :group, :object + + def detailed_status + group.detailed_status(request.current_user) + end +end diff --git a/app/serializers/label_entity.rb b/app/serializers/label_entity.rb index 304fd9de08f..ad565654342 100644 --- a/app/serializers/label_entity.rb +++ b/app/serializers/label_entity.rb @@ -6,6 +6,7 @@ class LabelEntity < Grape::Entity expose :group_id expose :project_id expose :template + expose :text_color expose :created_at expose :updated_at end diff --git a/app/serializers/label_serializer.rb b/app/serializers/label_serializer.rb new file mode 100644 index 00000000000..ad6ba8c46c9 --- /dev/null +++ b/app/serializers/label_serializer.rb @@ -0,0 +1,7 @@ +class LabelSerializer < BaseSerializer + entity LabelEntity + + def represent_appearance(resource) + represent(resource, { only: [:id, :title, :color, :text_color] }) + end +end diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb new file mode 100644 index 00000000000..8461f158bb5 --- /dev/null +++ b/app/serializers/merge_request_basic_entity.rb @@ -0,0 +1,11 @@ +class MergeRequestBasicEntity < Grape::Entity + expose :assignee_id + expose :merge_status + expose :merge_error + expose :state + expose :source_branch_exists?, as: :source_branch_exists + expose :time_estimate + expose :total_time_spent + expose :human_time_estimate + expose :human_total_time_spent +end diff --git a/app/serializers/merge_request_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb new file mode 100644 index 00000000000..cc5c664c8fa --- /dev/null +++ b/app/serializers/merge_request_basic_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestBasicSerializer < BaseSerializer + entity MergeRequestBasicEntity +end diff --git a/app/serializers/merge_request_create_entity.rb b/app/serializers/merge_request_create_entity.rb new file mode 100644 index 00000000000..11234313293 --- /dev/null +++ b/app/serializers/merge_request_create_entity.rb @@ -0,0 +1,7 @@ +class MergeRequestCreateEntity < Grape::Entity + expose :iid + + expose :url do |merge_request| + Gitlab::UrlBuilder.build(merge_request) + end +end diff --git a/app/serializers/merge_request_create_serializer.rb b/app/serializers/merge_request_create_serializer.rb new file mode 100644 index 00000000000..08daf473319 --- /dev/null +++ b/app/serializers/merge_request_create_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestCreateSerializer < BaseSerializer + entity MergeRequestCreateEntity +end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 5f80ab397a9..b3247ae36dd 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -1,4 +1,6 @@ class MergeRequestEntity < IssuableEntity + include RequestAwareEntity + expose :in_progress_merge_commit_sha expose :locked_at expose :merge_commit_sha @@ -11,4 +13,176 @@ class MergeRequestEntity < IssuableEntity expose :source_project_id expose :target_branch expose :target_project_id + + # Events + expose :merge_event, using: EventEntity + expose :closed_event, using: EventEntity + + # User entities + expose :author, using: UserEntity + expose :merge_user, using: UserEntity + + # Diff sha's + expose :diff_head_sha do |merge_request| + merge_request.diff_head_sha if merge_request.diff_head_commit + end + + expose :merge_commit_sha + expose :merge_commit_message + expose :head_pipeline, with: PipelineEntity, as: :pipeline + + # Booleans + expose :work_in_progress?, as: :work_in_progress + expose :source_branch_exists?, as: :source_branch_exists + expose :mergeable_discussions_state?, as: :mergeable_discussions_state + expose :branch_missing?, as: :branch_missing + expose :commits_count + expose :cannot_be_merged?, as: :has_conflicts + expose :can_be_merged?, as: :can_be_merged + + expose :project_archived do |merge_request| + merge_request.project.archived? + end + + expose :only_allow_merge_if_pipeline_succeeds do |merge_request| + merge_request.project.only_allow_merge_if_pipeline_succeeds? + end + + # CI related + expose :has_ci?, as: :has_ci + expose :ci_status do |merge_request| + presenter(merge_request).ci_status + end + + expose :issues_links do + expose :assign_to_closing do |merge_request| + presenter(merge_request).assign_to_closing_issues_link + end + + expose :closing do |merge_request| + presenter(merge_request).closing_issues_links + end + + expose :mentioned_but_not_closing do |merge_request| + presenter(merge_request).mentioned_issues_links + end + end + + expose :source_branch_with_namespace_link do |merge_request| + presenter(merge_request).source_branch_with_namespace_link + end + + expose :source_branch_path do |merge_request| + presenter(merge_request).source_branch_path + end + + expose :current_user do + expose :can_remove_source_branch do |merge_request| + merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user) + end + + expose :can_revert_on_current_merge_request do |merge_request| + presenter(merge_request).can_revert_on_current_merge_request? + end + + expose :can_cherry_pick_on_current_merge_request do |merge_request| + presenter(merge_request).can_cherry_pick_on_current_merge_request? + end + end + + # Paths + # + expose :target_branch_commits_path do |merge_request| + presenter(merge_request).target_branch_commits_path + end + + expose :new_blob_path do |merge_request| + if can?(current_user, :push_code, merge_request.project) + namespace_project_new_blob_path(merge_request.project.namespace, + merge_request.project, + merge_request.source_branch) + end + end + + expose :conflict_resolution_path do |merge_request| + presenter(merge_request).conflict_resolution_path + end + + expose :remove_wip_path do |merge_request| + presenter(merge_request).remove_wip_path + end + + expose :cancel_merge_when_pipeline_succeeds_path do |merge_request| + presenter(merge_request).cancel_merge_when_pipeline_succeeds_path + end + + expose :create_issue_to_resolve_discussions_path do |merge_request| + presenter(merge_request).create_issue_to_resolve_discussions_path + end + + expose :merge_path do |merge_request| + presenter(merge_request).merge_path + end + + expose :cherry_pick_in_fork_path do |merge_request| + presenter(merge_request).cherry_pick_in_fork_path + end + + expose :revert_in_fork_path do |merge_request| + presenter(merge_request).revert_in_fork_path + end + + expose :email_patches_path do |merge_request| + namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request, + format: :patch) + end + + expose :plain_diff_path do |merge_request| + namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request, + format: :diff) + end + + expose :status_path do |merge_request| + namespace_project_merge_request_path(merge_request.target_project.namespace, + merge_request.target_project, + merge_request, + format: :json) + end + + expose :ci_environments_status_path do |merge_request| + ci_environments_status_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + expose :merge_commit_message_with_description do |merge_request| + merge_request.merge_commit_message(include_description: true) + end + + expose :diverged_commits_count do |merge_request| + if merge_request.open? && merge_request.diverged_from_target_branch? + merge_request.diverged_commits_count + else + 0 + end + end + + expose :commit_change_content_path do |merge_request| + commit_change_content_namespace_project_merge_request_path(merge_request.project.namespace, + merge_request.project, + merge_request) + end + + private + + delegate :current_user, to: :request + + def presenter(merge_request) + @presenters ||= {} + @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) + end end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index aa6e00dfcb4..f67034ce47a 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -1,3 +1,9 @@ class MergeRequestSerializer < BaseSerializer - entity MergeRequestEntity + # This overrided method takes care of which entity should be used + # to serialize the `merge_request` based on `basic` key in `opts` param. + # Hence, `entity` doesn't need to be declared on the class scope. + def represent(merge_request, opts = {}) + entity = opts[:basic] ? MergeRequestBasicEntity : MergeRequestEntity + super(merge_request, opts, entity) + end end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 3f16dd66d54..ea57cc97a7e 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -3,6 +3,8 @@ class PipelineEntity < Grape::Entity expose :id expose :user, using: UserEntity + expose :active?, as: :active + expose :coverage expose :path do |pipeline| namespace_project_pipeline_path( @@ -36,10 +38,7 @@ class PipelineEntity < Grape::Entity expose :path do |pipeline| if pipeline.ref - namespace_project_tree_path( - pipeline.project.namespace, - pipeline.project, - id: pipeline.ref) + project_ref_path(pipeline.project, pipeline.ref) end end @@ -48,15 +47,15 @@ class PipelineEntity < Grape::Entity end expose :commit, using: CommitEntity - expose :yaml_errors, if: ->(pipeline, _) { pipeline.has_yaml_errors? } + expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? } - expose :retry_path, if: proc { can_retry? } do |pipeline| + expose :retry_path, if: -> (*) { can_retry? } do |pipeline| retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) end - expose :cancel_path, if: proc { can_cancel? } do |pipeline| + expose :cancel_path, if: -> (*) { can_cancel? } do |pipeline| cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) @@ -69,16 +68,16 @@ class PipelineEntity < Grape::Entity alias_method :pipeline, :object def can_retry? - pipeline.retryable? && - can?(request.user, :update_pipeline, pipeline) + can?(request.current_user, :update_pipeline, pipeline) && + pipeline.retryable? end def can_cancel? - pipeline.cancelable? && - can?(request.user, :update_pipeline, pipeline) + can?(request.current_user, :update_pipeline, pipeline) && + pipeline.cancelable? end def detailed_status - pipeline.detailed_status(request.user) + pipeline.detailed_status(request.current_user) end end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 4cbb58fb4f0..0e79d269ae7 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -15,6 +15,7 @@ class PipelineSerializer < BaseSerializer if resource.is_a?(ActiveRecord::Relation) resource = resource.preload( :user, + :trigger_requests, statuses: { project: [:project_feature, :namespace] }, project: :namespace) @@ -22,6 +23,17 @@ class PipelineSerializer < BaseSerializer resource = @paginator.paginate(resource) end + resource = resource.preload([ + :retryable_builds, + :cancelable_statuses, + :trigger_requests, + :project, + { pending_builds: :project }, + { manual_actions: :project }, + { artifacts: :project } + ]) + end + preload_commit_authors(resource) elsif paginated? raise Gitlab::Serializer::Pagination::InvalidResourceError @@ -79,4 +91,11 @@ class PipelineSerializer < BaseSerializer a.alternative_email || a.email end end + + def represent_stages(resource) + return {} unless resource.present? + + data = represent(resource, { only: [{ details: [:stages] }] }) + data.dig(:details, :stages) || [] + end end diff --git a/app/serializers/project_entity.rb b/app/serializers/project_entity.rb new file mode 100644 index 00000000000..a471a7e6a88 --- /dev/null +++ b/app/serializers/project_entity.rb @@ -0,0 +1,14 @@ +class ProjectEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :name + + expose :full_path do |project| + namespace_project_path(project.namespace, project) + end + + expose :full_name do |project| + project.full_name + end +end diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb index 3039014aaaa..d53fcfb8c1b 100644 --- a/app/serializers/request_aware_entity.rb +++ b/app/serializers/request_aware_entity.rb @@ -3,6 +3,7 @@ module RequestAwareEntity included do include Gitlab::Routing + include GitlabRoutingHelper include Gitlab::Allowable end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 7a047bdc712..cee0089056f 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -7,9 +7,11 @@ class StageEntity < Grape::Entity "#{stage.name}: #{detailed_status.label}" end - expose :detailed_status, - as: :status, - with: StatusEntity + expose :groups, + if: -> (_, opts) { opts[:grouped] }, + with: JobGroupEntity + + expose :detailed_status, as: :status, with: StatusEntity expose :path do |stage| namespace_project_pipeline_path( @@ -33,6 +35,6 @@ class StageEntity < Grape::Entity alias_method :stage, :object def detailed_status - stage.detailed_status(request.user) + stage.detailed_status(request.current_user) end end diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index dfd9d1584a1..3e40ecf1c1c 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -1,8 +1,22 @@ class StatusEntity < Grape::Entity include RequestAwareEntity - expose :icon, :favicon, :text, :label, :group + expose :icon, :text, :label, :group expose :has_details?, as: :has_details expose :details_path + + expose :favicon do |status| + dir = 'ci_favicons' + dir = File.join(dir, 'dev') if Rails.env.development? + + ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) + end + + expose :action, if: -> (status, _) { status.has_action? } do + expose :action_icon, as: :icon + expose :action_title, as: :title + expose :action_path, as: :path + expose :action_method, as: :method + end end diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb index 76b9f1feda7..8e11a2a36a7 100644 --- a/app/services/akismet_service.rb +++ b/app/services/akismet_service.rb @@ -16,7 +16,7 @@ class AkismetService created_at: DateTime.now, author: owner.name, author_email: owner.email, - referrer: options[:referrer], + referrer: options[:referrer] } begin diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index 8a000585e89..5ad9a50687c 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -8,7 +8,7 @@ class AuditEventService with: @details[:with], target_id: @author.id, target_type: 'User', - target_details: @author.name, + target_details: @author.name } self diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index db82b8f6c30..5e151b0f044 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -17,6 +17,7 @@ module Auth end def self.full_access_token(*names) + names = names.flatten registry = Gitlab.config.registry token = JSONWebToken::RSAToken.new(registry.key) token.issuer = registry.issuer @@ -37,13 +38,13 @@ module Auth private def authorized_token(*accesses) - token = JSONWebToken::RSAToken.new(registry.key) - token.issuer = registry.issuer - token.audience = params[:service] - token.subject = current_user.try(:username) - token.expire_time = self.class.token_expire_at - token[:access] = accesses.compact - token + JSONWebToken::RSAToken.new(registry.key).tap do |token| + token.issuer = registry.issuer + token.audience = params[:service] + token.subject = current_user.try(:username) + token.expire_time = self.class.token_expire_at + token[:access] = accesses.compact + end end def scope @@ -55,20 +56,43 @@ module Auth def process_scope(scope) type, name, actions = scope.split(':', 3) actions = actions.split(',') + path = ContainerRegistry::Path.new(name) + return unless type == 'repository' - process_repository_access(type, name, actions) + process_repository_access(type, path, actions) end - def process_repository_access(type, name, actions) - requested_project = Project.find_by_full_path(name) + def process_repository_access(type, path, actions) + return unless path.valid? + + requested_project = path.repository_project + return unless requested_project actions = actions.select do |action| can_access?(requested_project, action) end - { type: type, name: name, actions: actions } if actions.present? + return unless actions.present? + + # At this point user/build is already authenticated. + # + ensure_container_repository!(path, actions) + + { type: type, name: path.to_s, actions: actions } + end + + ## + # Because we do not have two way communication with registry yet, + # we create a container repository image resource when push to the + # registry is successfuly authorized. + # + def ensure_container_repository!(path, actions) + return if path.has_repository? + return unless actions.include?('push') + + ContainerRepository.create_from_path!(path) end def can_access?(requested_project, requested_action) @@ -101,6 +125,11 @@ module Auth can?(current_user, :read_container_image, requested_project) end + ## + # We still support legacy pipeline triggers which do not have associated + # actor. New permissions model and new triggers are always associated with + # an actor, so this should be improved in 10.0 version of GitLab. + # def build_can_push?(requested_project) # Build can push only to the project from which it originates has_authentication_ability?(:build_create_container_image) && @@ -113,14 +142,11 @@ module Auth end def error(code, status:, message: '') - { - errors: [{ code: code, message: message }], - http_status: status - } + { errors: [{ code: code, message: message }], http_status: status } end def has_authentication_ability?(capability) - (@authentication_abilities || []).include?(capability) + @authentication_abilities.to_a.include?(capability) end end end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index 745c2c4b681..a0cb00dba58 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -24,6 +24,10 @@ class BaseService Gitlab::AppLogger.info message end + def log_error(message) + Gitlab::AppLogger.error message + end + def system_hook_service SystemHooksService.new end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index d5735f13c1e..ecabb2a48e4 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -38,7 +38,7 @@ module Boards attrs.merge!( add_label_ids: add_label_ids, remove_label_ids: remove_label_ids, - state_event: issue_state, + state_event: issue_state ) end @@ -61,7 +61,7 @@ module Boards if moving_to_list.movable? moving_from_list.label_id else - project.boards.joins(:lists).merge(List.movable).pluck(:label_id) + Label.on_project_boards(project.id).pluck(:label_id) end Array(label_ids).compact diff --git a/app/services/ci/create_pipeline_schedule_service.rb b/app/services/ci/create_pipeline_schedule_service.rb new file mode 100644 index 00000000000..cd40deb6187 --- /dev/null +++ b/app/services/ci/create_pipeline_schedule_service.rb @@ -0,0 +1,13 @@ +module Ci + class CreatePipelineScheduleService < BaseService + def execute + project.pipeline_schedules.create(pipeline_schedule_params) + end + + private + + def pipeline_schedule_params + params.merge(owner: current_user) + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 38a85e9fc42..1f6c1f4a7f6 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -2,7 +2,7 @@ module Ci class CreatePipelineService < BaseService attr_reader :pipeline - def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil) + def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil) @pipeline = Ci::Pipeline.new( project: project, ref: ref, @@ -10,7 +10,8 @@ module Ci before_sha: before_sha, tag: tag?, trigger_requests: Array(trigger_request), - user: current_user + user: current_user, + pipeline_schedule: schedule ) unless project.builds_enabled? @@ -46,13 +47,15 @@ module Ci end Ci::Pipeline.transaction do - pipeline.save + update_merge_requests_head_pipeline if pipeline.save Ci::CreatePipelineBuildsService .new(project, current_user) .execute(pipeline) end + cancel_pending_pipelines if project.auto_cancel_pending_pipelines? + pipeline.tap(&:process!) end @@ -63,6 +66,22 @@ module Ci pipeline.git_commit_message =~ /\[(ci[ _-]skip|skip[ _-]ci)\]/i end + def cancel_pending_pipelines + Gitlab::OptimisticLocking.retry_lock(auto_cancelable_pipelines) do |cancelables| + cancelables.find_each do |cancelable| + cancelable.auto_cancel_running(pipeline) + end + end + end + + def auto_cancelable_pipelines + project.pipelines + .where(ref: pipeline.ref) + .where.not(id: pipeline.id) + .where.not(sha: project.repository.sha_from_ref(pipeline.ref)) + .created_or_pending + end + def commit @commit ||= project.commit(origin_sha || origin_ref) end @@ -99,6 +118,11 @@ module Ci origin_sha && origin_sha != Gitlab::Git::BLANK_SHA end + def update_merge_requests_head_pipeline + MergeRequest.where(source_branch: @pipeline.ref, source_project: @pipeline.project). + update_all(head_pipeline_id: @pipeline.id) + end + def error(message, save: false) pipeline.errors.add(:base, message) pipeline.drop if save diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index dca5aa9f5d7..8362f01ddb8 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -5,9 +5,8 @@ module Ci pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref). execute(ignore_skip_ci: true, trigger_request: trigger_request) - if pipeline.persisted? - trigger_request - end + + trigger_request if pipeline.persisted? end end end diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb new file mode 100644 index 00000000000..e24f48c2d16 --- /dev/null +++ b/app/services/ci/play_build_service.rb @@ -0,0 +1,17 @@ +module Ci + class PlayBuildService < ::BaseService + def execute(build) + unless can?(current_user, :update_build, build) + raise Gitlab::Access::AccessDeniedError + end + + # Try to enqueue the build, otherwise create a duplicate. + # + if build.enqueue + build.tap { |action| action.update(user: current_user) } + else + Ci::Build.retry(build, current_user) + end + end + end +end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 2935d00c075..55af193d717 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -5,7 +5,7 @@ module Ci def execute(pipeline) @pipeline = pipeline - ensure_created_builds! # TODO, remove me in 9.0 + update_retried new_builds = stage_indexes_of_created_builds.map do |index| @@ -52,7 +52,7 @@ module Ci when 'always' %w[success failed skipped] when 'manual' - %w[success] + %w[success skipped] else [] end @@ -74,17 +74,22 @@ module Ci pipeline.builds.created end - # This method is DEPRECATED and should be removed in 9.0. - # - # We need it to maintain backwards compatibility with previous versions - # when builds were not created within one transaction with the pipeline. - # - def ensure_created_builds! - return if created_builds.any? - - Ci::CreatePipelineBuildsService - .new(project, current_user) - .execute(pipeline) + # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab + # This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb + # and ensures that functionality will not be broken before migration is run + # this updates only when there are data that needs to be updated, there are two groups with no retried flag + def update_retried + # find the latest builds for each name + latest_statuses = pipeline.statuses.latest + .group(:name) + .having('count(*) > 1') + .pluck('max(id)', 'name') + + # mark builds that are retried + pipeline.statuses.latest + .where(name: latest_statuses.map(&:second)) + .where.not(id: latest_statuses.map(&:first)) + .update_all(retried: true) if latest_statuses.any? end end end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 89da05b72bb..f51e9fd1d54 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -6,7 +6,7 @@ module Ci description tag_list].freeze def execute(build) - reprocess(build).tap do |new_build| + reprocess!(build).tap do |new_build| build.pipeline.mark_as_processable_after_stage(build.stage_idx) new_build.enqueue! @@ -17,7 +17,7 @@ module Ci end end - def reprocess(build) + def reprocess!(build) unless can?(current_user, :update_build, build) raise Gitlab::Access::AccessDeniedError end @@ -28,7 +28,14 @@ module Ci attributes.push([:user, current_user]) - project.builds.create(Hash[attributes]) + Ci::Build.transaction do + # mark all other builds of that name as retried + build.pipeline.builds.latest + .where(name: build.name) + .update_all(retried: true) + + project.builds.create!(Hash[attributes]) + end end end end diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index f72ddbf690c..c5a43869990 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -7,11 +7,11 @@ module Ci raise Gitlab::Access::AccessDeniedError end - pipeline.builds.latest.failed_or_canceled.find_each do |build| - next unless build.retryable? + pipeline.retryable_builds.find_each do |build| + next unless can?(current_user, :update_build, build) Ci::RetryBuildService.new(project, current_user) - .reprocess(build) + .reprocess!(build) end pipeline.builds.latest.skipped.find_each do |skipped| diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index 42c72aba7dd..43c9a065fcf 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -5,10 +5,11 @@ module Ci def execute(branch_name) @ref = branch_name - return unless has_ref? + return unless @ref.present? environments.each do |environment| - next unless can?(current_user, :create_deployment, project) + next unless environment.stop_action? + next unless can?(current_user, :stop_environment, environment) environment.stop_with_action!(current_user) end @@ -16,13 +17,10 @@ module Ci private - def has_ref? - @ref.present? - end - def environments - @environments ||= - EnvironmentsFinder.new(project, current_user, ref: @ref, recently_updated: true).execute + @environments ||= EnvironmentsFinder + .new(project, current_user, ref: @ref, recently_updated: true) + .execute end end end diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb new file mode 100644 index 00000000000..6781533af28 --- /dev/null +++ b/app/services/cohorts_service.rb @@ -0,0 +1,100 @@ +class CohortsService + MONTHS_INCLUDED = 12 + + def execute + { + months_included: MONTHS_INCLUDED, + cohorts: cohorts + } + end + + # Get an array of hashes that looks like: + # + # [ + # { + # registration_month: Date.new(2017, 3), + # activity_months: [3, 2, 1], + # total: 3 + # inactive: 0 + # }, + # etc. + # + # The `months` array is always from oldest to newest, so it's always + # non-strictly decreasing from left to right. + def cohorts + months = Array.new(MONTHS_INCLUDED) { |i| i.months.ago.beginning_of_month.to_date } + + Array.new(MONTHS_INCLUDED) do + registration_month = months.last + activity_months = running_totals(months, registration_month) + + # Even if no users registered in this month, we always want to have a + # value to fill in the table. + inactive = counts_by_month[[registration_month, nil]].to_i + + months.pop + + { + registration_month: registration_month, + activity_months: activity_months, + total: activity_months.first[:total], + inactive: inactive + } + end + end + + private + + # Calculate a running sum of active users, so users active in later months + # count as active in this month, too. Start with the most recent month first, + # for calculating the running totals, and then reverse for displaying in the + # table. + # + # Each month has a total, and a percentage of the overall total, as keys. + def running_totals(all_months, registration_month) + month_totals = + all_months + .map { |activity_month| counts_by_month[[registration_month, activity_month]] } + .reduce([]) { |result, total| result << result.last.to_i + total.to_i } + .reverse + + overall_total = month_totals.first + + month_totals.map do |total| + { total: total, percentage: total.zero? ? 0 : 100 * total / overall_total } + end + end + + # Get a hash that looks like: + # + # { + # [created_at_month, last_activity_on_month] => count, + # [created_at_month, last_activity_on_month_2] => count_2, + # # etc. + # } + # + # created_at_month can never be nil, but last_activity_on_month can (when a + # user has never logged in, just been created). This covers the last + # MONTHS_INCLUDED months. + def counts_by_month + @counts_by_month ||= + begin + created_at_month = column_to_date('created_at') + last_activity_on_month = column_to_date('last_activity_on') + + User + .where('created_at > ?', MONTHS_INCLUDED.months.ago.end_of_month) + .group(created_at_month, last_activity_on_month) + .reorder("#{created_at_month} ASC", "#{last_activity_on_month} ASC") + .count + end + end + + def column_to_date(column) + if Gitlab::Database.postgresql? + "CAST(DATE_TRUNC('month', #{column}) AS date)" + else + "STR_TO_DATE(DATE_FORMAT(#{column}, '%Y-%m-01'), '%Y-%m-%d')" + end + end +end diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index 1297a792259..a48d6a976f0 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -1,69 +1,27 @@ module Commits - class ChangeService < ::BaseService - ValidationError = Class.new(StandardError) - ChangeError = Class.new(StandardError) + class ChangeService < Commits::CreateService + def initialize(*args) + super - def execute - @start_project = params[:start_project] || @project - @start_branch = params[:start_branch] - @target_branch = params[:target_branch] @commit = params[:commit] - - check_push_permissions - - commit - rescue Repository::CommitError, Gitlab::Git::Repository::InvalidBlobName, GitHooksService::PreReceiveError, - ValidationError, ChangeError => ex - error(ex.message) end private - def commit - raise NotImplementedError - end - def commit_change(action) raise NotImplementedError unless repository.respond_to?(action) - validate_target_branch if different_branch? - repository.public_send( action, current_user, @commit, - @target_branch, + @branch_name, start_project: @start_project, start_branch_name: @start_branch) - - success rescue Repository::CreateTreeError error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically. - 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." + This #{@commit.change_type_title(current_user)} may already have been #{action.to_s.dasherize}ed, or a more recent commit may have updated some of its content." raise ChangeError, error_msg end - - def check_push_permissions - allowed = ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(@target_branch) - - unless allowed - raise ValidationError.new('You are not allowed to push into this branch') - end - - true - end - - def validate_target_branch - result = ValidateNewBranchService.new(@project, current_user) - .execute(@target_branch) - - if result[:status] == :error - raise ChangeError, "There was an error creating the source branch: #{result[:message]}" - end - end - - def different_branch? - @start_branch != @target_branch || @start_project != @project - end end end diff --git a/app/services/commits/cherry_pick_service.rb b/app/services/commits/cherry_pick_service.rb index 605cca36f9c..320e229560d 100644 --- a/app/services/commits/cherry_pick_service.rb +++ b/app/services/commits/cherry_pick_service.rb @@ -1,6 +1,6 @@ module Commits class CherryPickService < ChangeService - def commit + def create_commit! commit_change(:cherry_pick) end end diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb new file mode 100644 index 00000000000..c58f04a252b --- /dev/null +++ b/app/services/commits/create_service.rb @@ -0,0 +1,74 @@ +module Commits + class CreateService < ::BaseService + ValidationError = Class.new(StandardError) + ChangeError = Class.new(StandardError) + + def initialize(*args) + super + + @start_project = params[:start_project] || @project + @start_branch = params[:start_branch] + @branch_name = params[:branch_name] + end + + def execute + validate! + + new_commit = create_commit! + + success(result: new_commit) + rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Repository::CommitError, GitHooksService::PreReceiveError => ex + error(ex.message) + end + + private + + def create_commit! + raise NotImplementedError + end + + def raise_error(message) + raise ValidationError, message + end + + def different_branch? + @start_branch != @branch_name || @start_project != @project + end + + def validate! + validate_permissions! + validate_on_branch! + validate_branch_existance! + + validate_new_branch_name! if different_branch? + end + + def validate_permissions! + allowed = ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(@branch_name) + + unless allowed + raise_error("You are not allowed to push into this branch") + end + end + + def validate_on_branch! + if !@start_project.empty_repo? && !@start_project.repository.branch_exists?(@start_branch) + raise_error('You can only create or edit files when you are on a branch') + end + end + + def validate_branch_existance! + if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) + raise_error("A branch called '#{@branch_name}' already exists. Switch to that branch in order to make changes") + end + end + + def validate_new_branch_name! + result = ValidateNewBranchService.new(project, current_user).execute(@branch_name) + + if result[:status] == :error + raise_error("Something went wrong when we tried to create '#{@branch_name}' for you: #{result[:message]}") + end + end + end +end diff --git a/app/services/commits/revert_service.rb b/app/services/commits/revert_service.rb index addd55cb32f..dc27399e047 100644 --- a/app/services/commits/revert_service.rb +++ b/app/services/commits/revert_service.rb @@ -1,6 +1,6 @@ module Commits class RevertService < ChangeService - def commit + def create_commit! commit_change(:revert) end end diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb index 297c7d696c3..910a2a15e5d 100644 --- a/app/services/concerns/issues/resolve_discussions.rb +++ b/app/services/concerns/issues/resolve_discussions.rb @@ -21,11 +21,11 @@ module Issues @discussions_to_resolve ||= if discussion_to_resolve_id discussion_or_nil = merge_request_to_resolve_discussions_of - .find_diff_discussion(discussion_to_resolve_id) + .find_discussion(discussion_to_resolve_id) Array(discussion_or_nil) else merge_request_to_resolve_discussions_of - .resolvable_discussions + .discussions_to_be_resolved end end end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 11a045f4c31..64b3c0118fb 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -3,22 +3,14 @@ class DeleteBranchService < BaseService repository = project.repository branch = repository.find_branch(branch_name) - unless branch - return error('No such branch', 404) - end - - if branch_name == repository.root_ref - return error('Cannot remove HEAD branch', 405) - end - - if project.protected_branch?(branch_name) - return error('Protected branch cant be removed', 405) - end - unless current_user.can?(:push_code, project) return error('You dont have push access to repo', 405) end + unless branch + return error('No such branch', 404) + end + if repository.rm_branch(current_user, branch_name) success('Branch was removed') else diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb index 1b5623baebe..3b611588466 100644 --- a/app/services/delete_merged_branches_service.rb +++ b/app/services/delete_merged_branches_service.rb @@ -8,9 +8,20 @@ class DeleteMergedBranchesService < BaseService branches = project.repository.branch_names branches = branches.select { |branch| project.repository.merged_to_root_ref?(branch) } + # Prevent deletion of branches relevant to open merge requests + branches -= merge_request_branch_names branches.each do |branch| DeleteBranchService.new(project, current_user).execute(branch) end end + + private + + def merge_request_branch_names + # reorder(nil) is necessary for SELECT DISTINCT because default scope adds an ORDER BY + source_names = project.origin_merge_requests.opened.reorder(nil).uniq.pluck(:source_branch) + target_names = project.merge_requests.opened.reorder(nil).uniq.pluck(:target_branch) + (source_names + target_names).uniq + end end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index e24cc66e0fe..0f3a485a3fd 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -72,6 +72,8 @@ class EventCreateService def push(project, current_user, push_data) create_event(project, current_user, Event::PUSHED, data: push_data) + + Users::ActivityService.new(current_user, 'push').execute end private diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index c8a60422bf4..38231f66009 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -1,79 +1,17 @@ module Files - class BaseService < ::BaseService - ValidationError = Class.new(StandardError) - - def execute - @start_project = params[:start_project] || @project - @start_branch = params[:start_branch] - @target_branch = params[:target_branch] + class BaseService < Commits::CreateService + def initialize(*args) + super + @author_email = params[:author_email] + @author_name = params[:author_name] @commit_message = params[:commit_message] - @file_path = params[:file_path] - @previous_path = params[:previous_path] - @file_content = if params[:file_content_encoding] == 'base64' - Base64.decode64(params[:file_content]) - else - params[:file_content] - end - @last_commit_sha = params[:last_commit_sha] - @author_email = params[:author_email] - @author_name = params[:author_name] - - # Validate parameters - validate - - # Create new branch if it different from start_branch - validate_target_branch if different_branch? - - result = commit - if result - success(result: result) - else - error('Something went wrong. Your changes were not committed') - end - rescue Repository::CommitError, Gitlab::Git::Repository::InvalidBlobName, GitHooksService::PreReceiveError, ValidationError => ex - error(ex.message) - end - - private - - def different_branch? - @start_branch != @target_branch || @start_project != @project - end - - def file_has_changed? - return false unless @last_commit_sha && last_commit - - @last_commit_sha != last_commit.sha - end - - def raise_error(message) - raise ValidationError.new(message) - end - - def validate - allowed = ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(@target_branch) - - unless allowed - raise_error("You are not allowed to push into this branch") - end - - if !@start_project.empty_repo? && !@start_project.repository.branch_exists?(@start_branch) - raise ValidationError, 'You can only create or edit files when you are on a branch' - end - - if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) - raise ValidationError, "A branch called #{@branch_name} already exists. Switch to that branch in order to make changes" - end - end - def validate_target_branch - result = ValidateNewBranchService.new(project, current_user). - execute(@target_branch) + @file_path = params[:file_path] + @previous_path = params[:previous_path] - if result[:status] == :error - raise_error("Something went wrong when we tried to create #{@target_branch} for you: #{result[:message]}") - end + @file_content = params[:file_content] + @file_content = Base64.decode64(@file_content) if params[:file_content_encoding] == 'base64' end end end diff --git a/app/services/files/create_dir_service.rb b/app/services/files/create_dir_service.rb index 083ffdc634c..8ecac6115bd 100644 --- a/app/services/files/create_dir_service.rb +++ b/app/services/files/create_dir_service.rb @@ -1,26 +1,15 @@ module Files class CreateDirService < Files::BaseService - def commit + def create_commit! repository.create_dir( current_user, @file_path, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, author_email: @author_email, author_name: @author_name, start_project: @start_project, start_branch_name: @start_branch) end - - def validate - super - - unless @file_path =~ Gitlab::Regex.file_path_regex - raise_error( - 'Your changes could not be committed, because the file path ' + - Gitlab::Regex.file_path_regex_message - ) - end - end end end diff --git a/app/services/files/create_service.rb b/app/services/files/create_service.rb index 65b5537fb68..00a8dcf0934 100644 --- a/app/services/files/create_service.rb +++ b/app/services/files/create_service.rb @@ -1,48 +1,16 @@ module Files class CreateService < Files::BaseService - def commit + def create_commit! repository.create_file( current_user, @file_path, @file_content, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, author_email: @author_email, author_name: @author_name, start_project: @start_project, start_branch_name: @start_branch) end - - def validate - super - - if @file_content.nil? - raise_error("You must provide content.") - end - - if @file_path =~ Gitlab::Regex.directory_traversal_regex - raise_error( - 'Your changes could not be committed, because the file name ' + - Gitlab::Regex.directory_traversal_regex_message - ) - end - - unless @file_path =~ Gitlab::Regex.file_path_regex - raise_error( - 'Your changes could not be committed, because the file name ' + - Gitlab::Regex.file_path_regex_message - ) - end - - unless project.empty_repo? - @file_path.slice!(0) if @file_path.start_with?('/') - - blob = repository.blob_at_branch(@start_branch, @file_path) - - if blob - raise_error('Your changes could not be committed because a file with the same name already exists') - end - end - end end end diff --git a/app/services/files/destroy_service.rb b/app/services/files/delete_service.rb index e294659bc98..7952e5c95d4 100644 --- a/app/services/files/destroy_service.rb +++ b/app/services/files/delete_service.rb @@ -1,11 +1,11 @@ module Files - class DestroyService < Files::BaseService - def commit + class DeleteService < Files::BaseService + def create_commit! repository.delete_file( current_user, @file_path, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, author_email: @author_email, author_name: @author_name, start_project: @start_project, diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index 700f9f4f6f0..bfacc462847 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -1,14 +1,10 @@ module Files class MultiService < Files::BaseService - FileChangedError = Class.new(StandardError) - - ACTIONS = %w[create update delete move].freeze - - def commit + def create_commit! repository.multi_action( user: current_user, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, actions: params[:actions], author_email: @author_email, author_name: @author_name, @@ -19,122 +15,17 @@ module Files private - def validate + def validate! super - params[:actions].each_with_index do |action, index| - if ACTIONS.include?(action[:action].to_s) - action[:action] = action[:action].to_sym - else - raise_error("Unknown action type `#{action[:action]}`.") - end - - unless action[:file_path].present? - raise_error("You must specify a file_path.") - end - - action[:file_path].slice!(0) if action[:file_path] && action[:file_path].start_with?('/') - action[:previous_path].slice!(0) if action[:previous_path] && action[:previous_path].start_with?('/') - - regex_check(action[:file_path]) - regex_check(action[:previous_path]) if action[:previous_path] - - if project.empty_repo? && action[:action] != :create - raise_error("No files to #{action[:action]}.") - end - - validate_file_exists(action) - - case action[:action] - when :create - validate_create(action) - when :update - validate_update(action) - when :delete - validate_delete(action) - when :move - validate_move(action, index) - end - end - end - - def validate_file_exists(action) - return if action[:action] == :create - - file_path = action[:file_path] - file_path = action[:previous_path] if action[:action] == :move - - blob = repository.blob_at_branch(params[:branch], file_path) - - unless blob - raise_error("File to be #{action[:action]}d `#{file_path}` does not exist.") + params[:actions].each do |action| + validate_action!(action) end end - def last_commit - Gitlab::Git::Commit.last_for_path(repository, @start_branch, @file_path) - end - - def regex_check(file) - if file =~ Gitlab::Regex.directory_traversal_regex - raise_error( - 'Your changes could not be committed, because the file name, `' + - file + - '` ' + - Gitlab::Regex.directory_traversal_regex_message - ) - end - - unless file =~ Gitlab::Regex.file_path_regex - raise_error( - 'Your changes could not be committed, because the file name, `' + - file + - '` ' + - Gitlab::Regex.file_path_regex_message - ) - end - end - - def validate_create(action) - return if project.empty_repo? - - if repository.blob_at_branch(params[:branch], action[:file_path]) - raise_error("Your changes could not be committed because a file with the name `#{action[:file_path]}` already exists.") - end - - if action[:content].nil? - raise_error("You must provide content.") - end - end - - def validate_update(action) - if action[:content].nil? - raise_error("You must provide content.") - end - - if file_has_changed? - raise FileChangedError.new("You are attempting to update a file `#{action[:file_path]}` that has changed since you started editing it.") - end - end - - def validate_delete(action) - end - - def validate_move(action, index) - if action[:previous_path].nil? - raise_error("You must supply the original file path when moving file `#{action[:file_path]}`.") - end - - blob = repository.blob_at_branch(params[:branch], action[:file_path]) - - if blob - raise_error("Move destination `#{action[:file_path]}` already exists.") - end - - if action[:content].nil? - blob = repository.blob_at_branch(params[:branch], action[:previous_path]) - blob.load_all_data!(repository) if blob.truncated? - params[:actions][index][:content] = blob.data + def validate_action!(action) + unless Gitlab::Git::Index::ACTIONS.include?(action[:action].to_s) + raise_error("Unknown action '#{action[:action]}'") end end end diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index fbbab97632e..f23a9f6d57c 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -2,10 +2,16 @@ module Files class UpdateService < Files::BaseService FileChangedError = Class.new(StandardError) - def commit + def initialize(*args) + super + + @last_commit_sha = params[:last_commit_sha] + end + + def create_commit! repository.update_file(current_user, @file_path, @file_content, message: @commit_message, - branch_name: @target_branch, + branch_name: @branch_name, previous_path: @previous_path, author_email: @author_email, author_name: @author_name, @@ -15,21 +21,23 @@ module Files private - def validate - super - - if @file_content.nil? - raise_error("You must provide content.") - end + def file_has_changed? + return false unless @last_commit_sha && last_commit - if file_has_changed? - raise FileChangedError.new("You are attempting to update a file that has changed since you started editing it.") - end + @last_commit_sha != last_commit.sha end def last_commit @last_commit ||= Gitlab::Git::Commit. last_for_path(@start_project.repository, @start_branch, @file_path) end + + def validate! + super + + if file_has_changed? + raise FileChangedError, "You are attempting to update a file that has changed since you started editing it." + end + end end end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index bc7431c89a8..d22236b961b 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -67,7 +67,7 @@ class GitPushService < BaseService paths = Set.new @push_commits.each do |commit| - commit.raw_diffs(deltas_only: true).each do |diff| + commit.raw_deltas.each do |diff| paths << diff.new_path end end @@ -85,8 +85,10 @@ class GitPushService < BaseService default = is_default_branch? push_commits.last(PROCESS_COMMIT_LIMIT).each do |commit| - ProcessCommitWorker. - perform_async(project.id, current_user.id, commit.to_hash, default) + if commit.matches_cross_reference_regex? + ProcessCommitWorker. + perform_async(project.id, current_user.id, commit.to_hash, default) + end end end @@ -127,7 +129,7 @@ class GitPushService < BaseService project.change_head(branch_name) # Set protection on the default branch if configured - if current_application_settings.default_branch_protection != PROTECTION_NONE && !@project.protected_branch?(@project.default_branch) + if current_application_settings.default_branch_protection != PROTECTION_NONE && !ProtectedBranch.protected?(@project, @project.default_branch) params = { name: @project.default_branch, diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 60891cbb255..5d42a89fced 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,10 +7,14 @@ module Issuable ids = params.delete(:issuable_ids).split(",") items = model_class.where(id: ids) - %i(state_event milestone_id assignee_id add_label_ids remove_label_ids subscription_event).each do |key| + permitted_attrs(type).each do |key| params.delete(key) unless params[key].present? end + if params[:assignee_ids] == [IssuableFinder::NONE.to_s] + params[:assignee_ids] = [] + end + items.each do |issuable| next unless can?(current_user, :"update_#{type}", issuable) @@ -22,5 +26,17 @@ module Issuable success: !items.count.zero? } end + + private + + def permitted_attrs(type) + attrs = %i(state_event milestone_id assignee_id assignee_ids add_label_ids remove_label_ids subscription_event) + + if type == 'issue' + attrs.push(:assignee_ids) + else + attrs.push(:assignee_id) + end + end end end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index b071a398481..e94ab3e64db 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -1,11 +1,6 @@ class IssuableBaseService < BaseService private - def create_assignee_note(issuable) - SystemNoteService.change_assignee( - issuable, issuable.project, current_user, issuable.assignee) - end - def create_milestone_note(issuable) SystemNoteService.change_milestone( issuable, issuable.project, current_user, issuable.milestone) @@ -24,6 +19,10 @@ class IssuableBaseService < BaseService issuable, issuable.project, current_user, old_title) end + def create_description_change_note(issuable) + SystemNoteService.change_description(issuable, issuable.project, current_user) + end + def create_branch_change_note(issuable, branch_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, @@ -53,6 +52,7 @@ class IssuableBaseService < BaseService params.delete(:add_label_ids) params.delete(:remove_label_ids) params.delete(:label_ids) + params.delete(:assignee_ids) params.delete(:assignee_id) params.delete(:due_date) end @@ -77,7 +77,7 @@ class IssuableBaseService < BaseService def assignee_can_read?(issuable, assignee_id) new_assignee = User.find_by_id(assignee_id) - return false unless new_assignee.present? + return false unless new_assignee ability_name = :"read_#{issuable.to_ability_name}" resource = issuable.persisted? ? issuable : project @@ -178,6 +178,7 @@ class IssuableBaseService < BaseService after_create(issuable) issuable.create_cross_references!(current_user) execute_hooks(issuable) + invalidate_cache_counts(issuable.assignees, issuable) end issuable @@ -207,6 +208,7 @@ class IssuableBaseService < BaseService filter_params(issuable) old_labels = issuable.labels.to_a old_mentioned_users = issuable.mentioned_users.to_a + old_assignees = issuable.assignees.to_a label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids) params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids) @@ -214,6 +216,10 @@ class IssuableBaseService < BaseService if issuable.changed? || params.present? issuable.assign_attributes(params.merge(updated_by: current_user)) + if has_title_or_description_changed?(issuable) + issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user) + end + before_update(issuable) if issuable.with_transaction_returning_status { issuable.save } @@ -222,7 +228,18 @@ class IssuableBaseService < BaseService handle_common_system_notes(issuable, old_labels: old_labels) end - handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users) + handle_changes( + issuable, + old_labels: old_labels, + old_mentioned_users: old_mentioned_users, + old_assignees: old_assignees + ) + + if old_assignees != issuable.assignees + assignees = old_assignees + issuable.assignees.to_a + invalidate_cache_counts(assignees.compact, issuable) + end + after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') @@ -236,6 +253,10 @@ class IssuableBaseService < BaseService old_label_ids.sort != new_label_ids.sort end + def has_title_or_description_changed?(issuable) + issuable.title_changed? || issuable.description_changed? + end + def change_state(issuable) case params.delete(:state_event) when 'reopen' @@ -272,7 +293,7 @@ class IssuableBaseService < BaseService end end - def has_changes?(issuable, old_labels: []) + def has_changes?(issuable, old_labels: [], old_assignees: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| @@ -281,7 +302,9 @@ class IssuableBaseService < BaseService labels_changed = issuable.labels != old_labels - attrs_changed || labels_changed + assignees_changed = issuable.assignees != old_assignees + + attrs_changed || labels_changed || assignees_changed end def handle_common_system_notes(issuable, old_labels: []) @@ -289,6 +312,10 @@ class IssuableBaseService < BaseService create_title_change_note(issuable, issuable.previous_changes['title'].first) end + if issuable.previous_changes.include?('description') + create_description_change_note(issuable) + end + if issuable.previous_changes.include?('description') && issuable.tasks? create_task_status_note(issuable) end @@ -303,4 +330,10 @@ class IssuableBaseService < BaseService create_labels_note(issuable, old_labels) if issuable.labels != old_labels end + + def invalidate_cache_counts(users, issuable) + users.each do |user| + user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") + end + end end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index ee1b40db718..34199eb5d13 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -9,11 +9,33 @@ module Issues private + def create_assignee_note(issue, old_assignees) + SystemNoteService.change_issue_assignees( + issue, issue.project, current_user, old_assignees) + end + def execute_hooks(issue, action = 'open') issue_data = hook_data(issue, action) hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks issue.project.execute_hooks(issue_data, hooks_scope) issue.project.execute_services(issue_data, hooks_scope) end + + def filter_assignee(issuable) + return if params[:assignee_ids].blank? + + # The number of assignees is limited by one for GitLab CE + params[:assignee_ids] = params[:assignee_ids][0, 1] + + assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) } + + if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE] + params[:assignee_ids] = [] + elsif assignee_ids.any? + params[:assignee_ids] = assignee_ids + else + params.delete(:assignee_ids) + end + end end end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 77bced4bd5c..3a4f7b159f1 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -35,14 +35,19 @@ module Issues end def item_for_discussion(discussion) - first_note = discussion.first_note_to_resolve || discussion.first_note + first_note_to_resolve = discussion.first_note_to_resolve || discussion.first_note + + is_very_first_note = first_note_to_resolve == discussion.first_note + action = is_very_first_note ? "started" : "commented on" + + note_url = Gitlab::UrlBuilder.build(first_note_to_resolve) + other_note_count = discussion.notes.size - 1 - note_url = Gitlab::UrlBuilder.build(first_note) - discussion_info = "- [ ] #{first_note.author.to_reference} commented on a [discussion](#{note_url}): " + discussion_info = "- [ ] #{first_note_to_resolve.author.to_reference} #{action} a [discussion](#{note_url}): " discussion_info << " (+#{other_note_count} #{'comment'.pluralize(other_note_count)})" if other_note_count > 0 - note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note.note).call + note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note_to_resolve.note).call spaces = ' ' * 4 quote = note_without_block_quotes.lines.map { |line| "#{spaces}> #{line}" }.join diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b7fe5cb168b..cd9f9a4a16e 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -12,8 +12,12 @@ module Issues spam_check(issue, current_user) end - def handle_changes(issue, old_labels: [], old_mentioned_users: []) - if has_changes?(issue, old_labels: old_labels) + def handle_changes(issue, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + old_assignees = options[:old_assignees] || [] + + if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -26,9 +30,9 @@ module Issues create_milestone_note(issue) end - if issue.previous_changes.include?('assignee_id') - create_assignee_note(issue) - notification_service.reassigned_issue(issue, current_user) + if issue.assignees != old_assignees + create_assignee_note(issue, old_assignees) + notification_service.reassigned_issue(issue, current_user, old_assignees) todo_service.reassigned_issue(issue, current_user) end diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb index b7a244c2029..f846d72498f 100644 --- a/app/services/members/authorized_destroy_service.rb +++ b/app/services/members/authorized_destroy_service.rb @@ -9,7 +9,11 @@ module Members def execute return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user) - member.destroy + Member.transaction do + unassign_issues_and_merge_requests(member) unless member.invite? + + member.destroy + end if member.request? && member.user != user notification_service.decline_access_request(member) @@ -17,5 +21,40 @@ module Members member end + + private + + def unassign_issues_and_merge_requests(member) + if member.is_a?(GroupMember) + issues = Issue.unscoped.select(1). + joins(:project). + where('issues.id = issue_assignees.issue_id AND projects.namespace_id = ?', member.source_id) + + # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) + IssueAssignee.unscoped. + where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues). + delete_all + + MergeRequestsFinder.new(user, group_id: member.source_id, assignee_id: member.user_id). + execute. + update_all(assignee_id: nil) + else + project = member.source + + # SELECT 1 FROM issues WHERE issues.id = issue_assignees.issue_id AND issues.project_id = X + issues = Issue.unscoped.select(1). + where('issues.id = issue_assignees.issue_id'). + where(project_id: project.id) + + # DELETE FROM issue_assignees WHERE user_id = X AND EXISTS (...) + IssueAssignee.unscoped. + where('user_id = :user_id AND EXISTS (:sub)', user_id: member.user_id, sub: issues). + delete_all + + project.merge_requests.opened.assigned_to(member.user).update_all(assignee_id: nil) + end + + member.user.invalidate_cache_counts + end end end diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index e4b24ccef92..3a58f6c065d 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -1,9 +1,15 @@ module Members class CreateService < BaseService + def initialize(source, current_user, params = {}) + @source = source + @current_user = current_user + @params = params + end + def execute return false if params[:user_ids].blank? - project.team.add_users( + @source.add_users( params[:user_ids].split(','), params[:access_level], expires_at: params[:expires_at], diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb index 066efa1acc3..8c6c4841020 100644 --- a/app/services/merge_requests/assign_issues_service.rb +++ b/app/services/merge_requests/assign_issues_service.rb @@ -4,7 +4,7 @@ module MergeRequests @assignable_issues ||= begin if current_user == merge_request.author closes_issues.select do |issue| - !issue.is_a?(ExternalIssue) && !issue.assignee_id? && can?(current_user, :admin_issue, issue) + !issue.is_a?(ExternalIssue) && !issue.assignees.present? && can?(current_user, :admin_issue, issue) end else [] @@ -14,7 +14,7 @@ module MergeRequests def execute assignable_issues.each do |issue| - Issues::UpdateService.new(issue.project, current_user, assignee_id: current_user.id).execute(issue) + Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue) end { diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 5a53b973059..3542a41ac83 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -38,8 +38,13 @@ module MergeRequests private + def create_assignee_note(merge_request) + SystemNoteService.change_assignee( + merge_request, merge_request.project, current_user, merge_request.assignee) + end + # Returns all origin and fork merge requests from `@project` satisfying passed arguments. - def merge_requests_for(source_branch, mr_states: [:opened]) + def merge_requests_for(source_branch, mr_states: [:opened, :reopened]) MergeRequest .with_state(mr_states) .where(source_branch: source_branch, source_project_id: @project.id) diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index fdce542bd9e..bc0e7ad4e39 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -21,12 +21,14 @@ module MergeRequests delegate :target_branch, :source_branch, :source_project, :target_project, :compare_commits, :wip_title, :description, :errors, to: :merge_request def find_source_project - source_project || project + return source_project if source_project.present? && can?(current_user, :read_project, source_project) + + project end def find_target_project return target_project if target_project.present? && can?(current_user, :read_project, target_project) - project.forked_from_project || project + project.default_merge_request_target end def find_target_branch diff --git a/app/services/merge_requests/conflicts/base_service.rb b/app/services/merge_requests/conflicts/base_service.rb new file mode 100644 index 00000000000..b50875347d9 --- /dev/null +++ b/app/services/merge_requests/conflicts/base_service.rb @@ -0,0 +1,11 @@ +module MergeRequests + module Conflicts + class BaseService + attr_reader :merge_request + + def initialize(merge_request) + @merge_request = merge_request + end + end + end +end diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb new file mode 100644 index 00000000000..9835606812c --- /dev/null +++ b/app/services/merge_requests/conflicts/list_service.rb @@ -0,0 +1,36 @@ +module MergeRequests + module Conflicts + class ListService < MergeRequests::Conflicts::BaseService + delegate :file_for_path, :to_json, to: :conflicts + + def can_be_resolved_by?(user) + return false unless merge_request.source_project + + access = ::Gitlab::UserAccess.new(user, project: merge_request.source_project) + access.can_push_to_branch?(merge_request.source_branch) + end + + def can_be_resolved_in_ui? + return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui) + + return @conflicts_can_be_resolved_in_ui = false unless merge_request.cannot_be_merged? + return @conflicts_can_be_resolved_in_ui = false unless merge_request.has_complete_diff_refs? + return @conflicts_can_be_resolved_in_ui = false if merge_request.branch_missing? + + begin + # Try to parse each conflict. If the MR's mergeable status hasn't been + # updated, ensure that we don't say there are conflicts to resolve + # when there are no conflict files. + conflicts.files.each(&:lines) + @conflicts_can_be_resolved_in_ui = conflicts.files.length > 0 + rescue Rugged::OdbError, Gitlab::Conflict::Parser::UnresolvableError, Gitlab::Conflict::FileCollection::ConflictSideMissing + @conflicts_can_be_resolved_in_ui = false + end + end + + def conflicts + @conflicts ||= Gitlab::Conflict::FileCollection.read_only(merge_request) + end + end + end +end diff --git a/app/services/merge_requests/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb new file mode 100644 index 00000000000..d74a82effd6 --- /dev/null +++ b/app/services/merge_requests/conflicts/resolve_service.rb @@ -0,0 +1,53 @@ +module MergeRequests + module Conflicts + class ResolveService < MergeRequests::Conflicts::BaseService + MissingFiles = Class.new(Gitlab::Conflict::ResolutionError) + + def execute(current_user, params) + rugged = merge_request.source_project.repository.rugged + + Gitlab::Conflict::FileCollection.for_resolution(merge_request) do |conflicts_for_resolution| + merge_index = conflicts_for_resolution.merge_index + + params[:files].each do |file_params| + conflict_file = conflicts_for_resolution.file_for_path(file_params[:old_path], file_params[:new_path]) + + write_resolved_file_to_index(merge_index, rugged, conflict_file, file_params) + end + + unless merge_index.conflicts.empty? + missing_files = merge_index.conflicts.map { |file| file[:ours][:path] } + + raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}" + end + + commit_params = { + message: params[:commit_message] || conflicts_for_resolution.default_commit_message, + parents: [conflicts_for_resolution.our_commit, conflicts_for_resolution.their_commit].map(&:oid), + tree: merge_index.write_tree(rugged) + } + + conflicts_for_resolution. + project. + repository. + resolve_conflicts(current_user, merge_request.source_branch, commit_params) + end + end + + private + + def write_resolved_file_to_index(merge_index, rugged, file, params) + new_file = if params[:sections] + file.resolve_lines(params[:sections]).map(&:text).join("\n") + elsif params[:content] + file.resolve_content(params[:content]) + end + + our_path = file.our_path + + merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode) + merge_index.conflict_remove(our_path) + end + end + end +end diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb new file mode 100644 index 00000000000..738cedbaed7 --- /dev/null +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -0,0 +1,54 @@ +module MergeRequests + class CreateFromIssueService < MergeRequests::CreateService + def execute + return error('Invalid issue iid') unless issue_iid.present? && issue.present? + + result = CreateBranchService.new(project, current_user).execute(branch_name, ref) + return result if result[:status] == :error + + SystemNoteService.new_issue_branch(issue, project, current_user, branch_name) + + new_merge_request = create(merge_request) + + if new_merge_request.valid? + success(new_merge_request) + else + error(new_merge_request.errors) + end + end + + private + + def issue_iid + @isssue_iid ||= params.delete(:issue_iid) + end + + def issue + @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid) + end + + def branch_name + @branch_name ||= issue.to_branch_name + end + + def ref + project.default_branch || 'master' + end + + def merge_request + MergeRequests::BuildService.new(project, current_user, merge_request_params).execute + end + + def merge_request_params + { + source_project_id: project.id, + source_branch: branch_name, + target_project_id: project.id + } + end + + def success(merge_request) + super().merge(merge_request: merge_request) + end + end +end diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb deleted file mode 100644 index 82cd89d9a0b..00000000000 --- a/app/services/merge_requests/resolve_service.rb +++ /dev/null @@ -1,65 +0,0 @@ -module MergeRequests - class ResolveService < MergeRequests::BaseService - MissingFiles = Class.new(Gitlab::Conflict::ResolutionError) - - attr_accessor :conflicts, :rugged, :merge_index, :merge_request - - def execute(merge_request) - @conflicts = merge_request.conflicts - @rugged = project.repository.rugged - @merge_index = conflicts.merge_index - @merge_request = merge_request - - fetch_their_commit! - - params[:files].each do |file_params| - conflict_file = merge_request.conflicts.file_for_path(file_params[:old_path], file_params[:new_path]) - - write_resolved_file_to_index(conflict_file, file_params) - end - - unless merge_index.conflicts.empty? - missing_files = merge_index.conflicts.map { |file| file[:ours][:path] } - - raise MissingFiles, "Missing resolutions for the following files: #{missing_files.join(', ')}" - end - - commit_params = { - message: params[:commit_message] || conflicts.default_commit_message, - parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid), - tree: merge_index.write_tree(rugged) - } - - project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params) - end - - def write_resolved_file_to_index(file, params) - new_file = if params[:sections] - file.resolve_lines(params[:sections]).map(&:text).join("\n") - elsif params[:content] - file.resolve_content(params[:content]) - end - - our_path = file.our_path - - merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode) - merge_index.conflict_remove(our_path) - end - - # If their commit (in the target project) doesn't exist in the source project, it - # can't be a parent for the merge commit we're about to create. If that's the case, - # fetch the target branch ref into the source project so the commit exists in both. - # - def fetch_their_commit! - return if rugged.include?(conflicts.their_commit.oid) - - random_string = SecureRandom.hex - - project.repository.fetch_ref( - merge_request.target_project.repository.path_to_repo, - "refs/heads/#{merge_request.target_branch}", - "refs/tmp/#{random_string}/head" - ) - end - end -end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index ab7fcf3b6e2..5c843a258fb 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -21,7 +21,10 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, old_labels: [], old_mentioned_users: []) + def handle_changes(merge_request, options) + old_labels = options[:old_labels] || [] + old_mentioned_users = options[:old_mentioned_users] || [] + if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb new file mode 100644 index 00000000000..abf25bb778b --- /dev/null +++ b/app/services/notes/build_service.rb @@ -0,0 +1,39 @@ +module Notes + class BuildService < ::BaseService + def execute + in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id) + + if in_reply_to_discussion_id.present? + discussion = find_discussion(in_reply_to_discussion_id) + + unless discussion + note = Note.new + note.errors.add(:base, 'Discussion to reply to cannot be found') + return note + end + + params.merge!(discussion.reply_attributes) + end + + note = Note.new(params) + note.project = project + note.author = current_user + + note + end + + def find_discussion(discussion_id) + if project + project.notes.find_discussion(discussion_id) + else + # only PersonalSnippets can have discussions without project association + discussion = Note.find_discussion(discussion_id) + noteable = discussion.noteable + + return nil unless noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) + + discussion + end + end + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 61d66a26932..f3954f6f8c4 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -1,12 +1,10 @@ module Notes - class CreateService < BaseService + class CreateService < ::BaseService def execute merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha) - note = Note.new(params) - note.project = project - note.author = current_user - note.system = false + note = Notes::BuildService.new(project, current_user, params).execute + return note unless note.valid? # We execute commands (extracted from `params[:note]`) on the noteable # **before** we save the note because if the note consists of commands diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 940e850600f..988bd0a7cdb 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -3,7 +3,7 @@ # class NotificationRecipientService attr_reader :project - + def initialize(project) @project = project end @@ -12,20 +12,21 @@ class NotificationRecipientService custom_action = build_custom_key(action, target) recipients = target.participants(current_user) - - unless NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action) - recipients = add_project_watchers(recipients) - end - + recipients = add_project_watchers(recipients) recipients = add_custom_notifications(recipients, custom_action) recipients = reject_mention_users(recipients) # Re-assign is considered as a mention of the new assignee so we add the # new assignee to the list of recipients after we rejected users with # the "on mention" notification level - if [:reassign_merge_request, :reassign_issue].include?(custom_action) + case custom_action + when :reassign_merge_request recipients << previous_assignee if previous_assignee recipients << target.assignee + when :reassign_issue + previous_assignees = Array(previous_assignee) + recipients.concat(previous_assignees) + recipients.concat(target.assignees) end recipients = reject_muted_users(recipients) @@ -43,6 +44,28 @@ class NotificationRecipientService recipients.uniq end + def build_pipeline_recipients(target, current_user, action:) + return [] unless current_user + + custom_action = + case action.to_s + when 'failed' + :failed_pipeline + when 'success' + :success_pipeline + end + + notification_setting = notification_setting_for_user_project(current_user, target.project) + + return [] if notification_setting.mention? || notification_setting.disabled? + + return [] if notification_setting.custom? && !notification_setting.public_send(custom_action) + + return [] if (notification_setting.watch? || notification_setting.participating?) && NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(custom_action) + + reject_users_without_access([current_user], target) + end + def build_relabeled_recipients(target, current_user, labels:) recipients = add_labels_subscribers([], target, labels: labels) recipients = reject_unsubscribed_users(recipients, target) @@ -290,4 +313,16 @@ class NotificationRecipientService def build_custom_key(action, object) "#{action}_#{object.class.model_name.name.underscore}".to_sym end + + def notification_setting_for_user_project(user, project) + project_setting = user.notification_settings_for(project) + + return project_setting unless project_setting.global? + + group_setting = user.notification_settings_for(project.group) + + return group_setting unless group_setting.global? + + user.global_notification_setting + end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 2c6f849259e..646ccbdb2bf 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -66,8 +66,25 @@ class NotificationService # * issue new assignee if their notification level is not Disabled # * users with custom level checked with "reassign issue" # - def reassigned_issue(issue, current_user) - reassign_resource_email(issue, issue.project, current_user, :reassigned_issue_email) + def reassigned_issue(issue, current_user, previous_assignees = []) + recipients = NotificationRecipientService.new(issue.project).build_recipients( + issue, + current_user, + action: "reassign", + previous_assignee: previous_assignees + ) + + previous_assignee_ids = previous_assignees.map(&:id) + + recipients.each do |recipient| + mailer.send( + :reassigned_issue_email, + recipient.id, + issue.id, + previous_assignee_ids, + current_user.id + ).deliver_later + end end # When we add labels to an issue we should send an email to: @@ -278,11 +295,11 @@ class NotificationService return unless mailer.respond_to?(email_template) - recipients ||= NotificationRecipientService.new(pipeline.project).build_recipients( + recipients ||= NotificationRecipientService.new(pipeline.project).build_pipeline_recipients( pipeline, pipeline.user, - action: pipeline.status, - skip_current_user: false).map(&:notification_email) + action: pipeline.status + ).map(&:notification_email) if recipients.any? mailer.public_send(email_template, pipeline, recipients).deliver_later @@ -367,10 +384,10 @@ class NotificationService end def previous_record(object, attribute) - if object && attribute - if object.previous_changes.include?(attribute) - object.previous_changes[attribute].first - end + return unless object && attribute + + if object.previous_changes.include?(attribute) + object.previous_changes[attribute].first end end end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb new file mode 100644 index 00000000000..10d45bbf73c --- /dev/null +++ b/app/services/preview_markdown_service.rb @@ -0,0 +1,45 @@ +class PreviewMarkdownService < BaseService + def execute + text, commands = explain_slash_commands(params[:text]) + users = find_user_references(text) + + success( + text: text, + users: users, + commands: commands.join(' ') + ) + end + + private + + def explain_slash_commands(text) + return text, [] unless %w(Issue MergeRequest).include?(commands_target_type) + + slash_commands_service = SlashCommands::InterpretService.new(project, current_user) + slash_commands_service.explain(text, find_commands_target) + end + + def find_user_references(text) + extractor = Gitlab::ReferenceExtractor.new(project, current_user) + extractor.analyze(text, author: current_user) + extractor.users.map(&:username) + end + + def find_commands_target + if commands_target_id.present? + finder = commands_target_type == 'Issue' ? IssuesFinder : MergeRequestsFinder + finder.new(current_user, project_id: project.id).find(commands_target_id) + else + collection = commands_target_type == 'Issue' ? project.issues : project.merge_requests + collection.build + end + end + + def commands_target_type + params[:slash_commands_target_type] + end + + def commands_target_id + params[:slash_commands_target_id] + end +end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index fbdaa455651..535d93385e6 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -58,6 +58,9 @@ module Projects fail(error: @project.errors.full_messages.join(', ')) end @project + rescue ActiveRecord::RecordInvalid => e + message = "Unable to save #{e.record.type}: #{e.record.errors.full_messages.join(", ")} " + fail(error: message) rescue => e fail(error: e.message) end @@ -94,7 +97,8 @@ module Projects system_hook_service.execute_hooks_for(@project, :create) unless @project.group || @project.gitlab_project_import? - @project.team << [current_user, :master, current_user] + owners = [current_user, @project.namespace.owner].compact.uniq + @project.add_master(owners, current_user: current_user) end @project.group&.refresh_members_authorized_projects diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index a7142d5950e..06d8d143231 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -31,16 +31,16 @@ module Projects project.team.truncate project.destroy! - unless remove_registry_tags - raise_error('Failed to remove project container registry. Please try again or contact administrator') + unless remove_legacy_registry_tags + raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.') end unless remove_repository(repo_path) - raise_error('Failed to remove project repository. Please try again or contact administrator') + raise_error('Failed to remove project repository. Please try again or contact administrator.') end unless remove_repository(wiki_path) - raise_error('Failed to remove wiki repository. Please try again or contact administrator') + raise_error('Failed to remove wiki repository. Please try again or contact administrator.') end end @@ -68,10 +68,16 @@ module Projects end end - def remove_registry_tags + ## + # This method makes sure that we correctly remove registry tags + # for legacy image repository (when repository path equals project path). + # + def remove_legacy_registry_tags return true unless Gitlab.config.registry.enabled - project.container_registry_repository.delete_tags + ContainerRepository.build_root_repository(project).tap do |repository| + return repository.has_tags? ? repository.delete_tags! : true + end end def raise_error(message) diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb index 3cf4264ce9b..121385afca3 100644 --- a/app/services/projects/enable_deploy_key_service.rb +++ b/app/services/projects/enable_deploy_key_service.rb @@ -4,7 +4,10 @@ module Projects key = accessible_keys.find_by(id: params[:key_id] || params[:id]) return unless key - project.deploy_keys << key + unless project.deploy_keys.include?(key) + project.deploy_keys << key + end + key end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index d484a96f785..eea17e24903 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -11,7 +11,7 @@ module Projects success rescue => e - error(e.message) + error("Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}") end private @@ -32,23 +32,39 @@ module Projects end def import_repository + raise Error, 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url) + begin - raise Error, "Blocked import URL." if Gitlab::UrlBlocker.blocked_url?(project.import_url) - gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url) - rescue => e + if project.github_import? || project.gitea_import? + fetch_repository + else + clone_repository + end + rescue Gitlab::Shell::Error => e # Expire cache to prevent scenarios such as: # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true # 2. Retried import, repo is broken or not imported but +exists?+ still returns true - project.repository.before_import if project.repository_exists? + project.repository.expire_content_cache if project.repository_exists? - raise Error, "Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}" + raise Error, e.message end end + def clone_repository + gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url) + end + + def fetch_repository + project.create_repository + project.repository.add_remote(project.import_type, project.import_url) + project.repository.set_remote_as_mirror(project.import_type) + project.repository.fetch_remote(project.import_type, forced: true) + end + def import_data return unless has_importer? - project.repository.before_import unless project.gitlab_project_import? + project.repository.expire_content_cache unless project.gitlab_project_import? unless importer.execute raise Error, 'The remote data could not be imported.' diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb new file mode 100644 index 00000000000..a8ef2108492 --- /dev/null +++ b/app/services/projects/propagate_service_template.rb @@ -0,0 +1,103 @@ +module Projects + class PropagateServiceTemplate + BATCH_SIZE = 100 + + def self.propagate(*args) + new(*args).propagate + end + + def initialize(template) + @template = template + end + + def propagate + return unless @template.active? + + Rails.logger.info("Propagating services for template #{@template.id}") + + propagate_projects_with_template + end + + private + + def propagate_projects_with_template + loop do + batch = project_ids_batch + + bulk_create_from_template(batch) unless batch.empty? + + break if batch.size < BATCH_SIZE + end + end + + def bulk_create_from_template(batch) + service_list = batch.map do |project_id| + service_hash.values << project_id + end + + Project.transaction do + bulk_insert_services(service_hash.keys << 'project_id', service_list) + run_callbacks(batch) + end + end + + def project_ids_batch + Project.connection.select_values( + <<-SQL + SELECT id + FROM projects + WHERE NOT EXISTS ( + SELECT true + FROM services + WHERE services.project_id = projects.id + AND services.type = '#{@template.type}' + ) + AND projects.pending_delete = false + AND projects.archived = false + LIMIT #{BATCH_SIZE} + SQL + ) + end + + def bulk_insert_services(columns, values_array) + ActiveRecord::Base.connection.execute( + <<-SQL.strip_heredoc + INSERT INTO services (#{columns.join(', ')}) + VALUES #{values_array.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + SQL + ) + end + + def service_hash + @service_hash ||= + begin + template_hash = @template.as_json(methods: :type).except('id', 'template', 'project_id') + + template_hash.each_with_object({}) do |(key, value), service_hash| + value = value.is_a?(Hash) ? value.to_json : value + + service_hash[ActiveRecord::Base.connection.quote_column_name(key)] = + ActiveRecord::Base.sanitize(value) + end + end + end + + def run_callbacks(batch) + if active_external_issue_tracker? + Project.where(id: batch).update_all(has_external_issue_tracker: true) + end + + if active_external_wiki? + Project.where(id: batch).update_all(has_external_wiki: true) + end + end + + def active_external_issue_tracker? + @template.issue_tracker? && !@template.default + end + + def active_external_wiki? + @template.type == 'ExternalWikiService' + end + end +end diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index eb4809afa85..cacb74b1205 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -27,7 +27,7 @@ module Projects { domain: domain.domain, certificate: domain.certificate, - key: domain.key, + key: domain.key } end end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 523b9f41916..17cf71cf098 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -46,6 +46,7 @@ module Projects end def error(message, http_status = nil) + log_error("Projects::UpdatePagesService: #{message}") @status.allow_failure = !latest? @status.description = message @status.drop diff --git a/app/services/projects/upload_service.rb b/app/services/projects/upload_service.rb deleted file mode 100644 index be34d4fa9b8..00000000000 --- a/app/services/projects/upload_service.rb +++ /dev/null @@ -1,22 +0,0 @@ -module Projects - class UploadService < BaseService - def initialize(project, file) - @project, @file = project, file - end - - def execute - return nil unless @file && @file.size <= max_attachment_size - - uploader = FileUploader.new(@project) - uploader.store!(@file) - - uploader.to_h - end - - private - - def max_attachment_size - current_application_settings.max_attachment_size.megabytes.to_i - end - end -end diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb index 89d8ba60134..4b3337a5c9d 100644 --- a/app/services/protected_branches/update_service.rb +++ b/app/services/protected_branches/update_service.rb @@ -1,13 +1,10 @@ module ProtectedBranches class UpdateService < BaseService - attr_reader :protected_branch - def execute(protected_branch) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) - @protected_branch = protected_branch - @protected_branch.update(params) - @protected_branch + protected_branch.update(params) + protected_branch end end end diff --git a/app/services/protected_tags/create_service.rb b/app/services/protected_tags/create_service.rb new file mode 100644 index 00000000000..faba7865a17 --- /dev/null +++ b/app/services/protected_tags/create_service.rb @@ -0,0 +1,11 @@ +module ProtectedTags + class CreateService < BaseService + attr_reader :protected_tag + + def execute + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) + + project.protected_tags.create(params) + end + end +end diff --git a/app/services/protected_tags/update_service.rb b/app/services/protected_tags/update_service.rb new file mode 100644 index 00000000000..aea6a48968d --- /dev/null +++ b/app/services/protected_tags/update_service.rb @@ -0,0 +1,10 @@ +module ProtectedTags + class UpdateService < BaseService + def execute(protected_tag) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) + + protected_tag.update(params) + protected_tag + end + end +end diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index 781cd13b44b..ff188102b62 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -7,14 +7,19 @@ module Search end def execute - group = Group.find_by(id: params[:group_id]) if params[:group_id].present? - projects = ProjectsFinder.new.execute(current_user) + Gitlab::SearchResults.new(current_user, projects, params[:search]) + end - if group - projects = projects.inside_path(group.full_path) - end + def projects + @projects ||= ProjectsFinder.new(current_user: current_user).execute + end - Gitlab::SearchResults.new(current_user, projects, params[:search]) + def scope + @scope ||= begin + allowed_scopes = %w[issues merge_requests milestones] + + allowed_scopes.delete(params[:scope]) { 'projects' } + end end end end diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb new file mode 100644 index 00000000000..29478e3251f --- /dev/null +++ b/app/services/search/group_service.rb @@ -0,0 +1,18 @@ +module Search + class GroupService < Search::GlobalService + attr_accessor :group + + def initialize(user, group, params) + super(user, params) + + @group = group + end + + def projects + return Project.none unless group + return @projects if defined? @projects + + @projects = super.inside_path(group.full_path) + end + end +end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 4b500914cfb..9a22abae635 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -12,5 +12,9 @@ module Search params[:search], params[:repository_ref]) end + + def scope + @scope ||= %w[notes issues merge_requests milestones wiki_blobs commits].delete(params[:scope]) { 'blobs' } + end end end diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb index 0b3e713e220..85da0be6fff 100644 --- a/app/services/search/snippet_service.rb +++ b/app/services/search/snippet_service.rb @@ -7,9 +7,13 @@ module Search end def execute - snippets = Snippet.accessible_to(current_user) + snippets = SnippetsFinder.new(current_user).execute Gitlab::SnippetSearchResults.new(snippets, params[:search]) end + + def scope + @scope ||= %w[snippet_titles].delete(params[:scope]) { 'snippet_blobs' } + end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb new file mode 100644 index 00000000000..22736c71725 --- /dev/null +++ b/app/services/search_service.rb @@ -0,0 +1,65 @@ +class SearchService + include Gitlab::Allowable + + def initialize(current_user, params = {}) + @current_user = current_user + @params = params.dup + end + + def project + return @project if defined?(@project) + + @project = + if params[:project_id].present? + the_project = Project.find_by(id: params[:project_id]) + can?(current_user, :download_code, the_project) ? the_project : nil + else + nil + end + end + + def group + return @group if defined?(@group) + + @group = + if params[:group_id].present? + the_group = Group.find_by(id: params[:group_id]) + can?(current_user, :read_group, the_group) ? the_group : nil + else + nil + end + end + + def show_snippets? + return @show_snippets if defined?(@show_snippets) + + @show_snippets = params[:snippets] == 'true' + end + + delegate :scope, to: :search_service + + def search_results + @search_results ||= search_service.execute + end + + def search_objects + @search_objects ||= search_results.objects(scope, params[:page]) + end + + private + + def search_service + @search_service ||= + if project + Search::ProjectService.new(project, current_user, params) + elsif show_snippets? + Search::SnippetService.new(current_user, params) + elsif group + Search::GroupService.new(current_user, group, params) + else + Search::GlobalService.new(current_user, params) + end + end + + attr_reader :current_user, :params +end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 595653ea58a..a7e13648b54 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -2,31 +2,31 @@ module SlashCommands class InterpretService < BaseService include Gitlab::SlashCommands::Dsl - attr_reader :issuable, :options + attr_reader :issuable # Takes a text and interprets the commands that are extracted from it. # Returns the content without commands, and hash of changes to be applied to a record. def execute(content, issuable) + return [content, {}] unless current_user.can?(:use_slash_commands) + @issuable = issuable @updates = {} - opts = { - issuable: issuable, - current_user: current_user, - project: project, - params: params - } - - content, commands = extractor.extract_commands(content, opts) + content, commands = extractor.extract_commands(content, context) + extract_updates(commands, context) + [content, @updates] + end - commands.each do |name, arg| - definition = self.class.command_definitions_by_name[name.to_sym] - next unless definition + # Takes a text and interprets the commands that are extracted from it. + # Returns the content without commands, and array of changes explained. + def explain(content, issuable) + return [content, []] unless current_user.can?(:use_slash_commands) - definition.execute(self, opts, arg) - end + @issuable = issuable - [content, @updates] + content, commands = extractor.extract_commands(content, context) + commands = explain_commands(commands, context) + [content, commands] end private @@ -38,6 +38,9 @@ module SlashCommands desc do "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" end + explanation do + "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.open? && @@ -50,6 +53,9 @@ module SlashCommands desc do "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" end + explanation do + "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.closed? && @@ -60,6 +66,7 @@ module SlashCommands end desc 'Merge (when the pipeline succeeds)' + explanation 'Merges this merge request when the pipeline succeeds.' condition do last_diff_sha = params && params[:merge_request_diff_head_sha] issuable.is_a?(MergeRequest) && @@ -71,6 +78,9 @@ module SlashCommands end desc 'Change title' + explanation do |title_param| + "Changes the title to \"#{title_param}\"." + end params '<New title>' condition do issuable.persisted? && @@ -81,41 +91,70 @@ module SlashCommands end desc 'Assign' + explanation do |users| + "Assigns #{users.map(&:to_reference).to_sentence}." if users.any? + end params '@user' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :assign do |assignee_param| - user = extract_references(assignee_param, :user).first - user ||= User.find_by(username: assignee_param) + parse_params do |assignee_param| + users = extract_references(assignee_param, :user) - @updates[:assignee_id] = user.id if user + if users.empty? + users = User.where(username: assignee_param.split(' ').map(&:strip)) + end + + users + end + command :assign do |users| + next if users.empty? + + if issuable.is_a?(Issue) + @updates[:assignee_ids] = users.map(&:id) + else + @updates[:assignee_id] = users.last.id + end end desc 'Remove assignee' + explanation do + "Removes assignee #{issuable.assignees.first.to_reference}." + end condition do issuable.persisted? && - issuable.assignee_id? && + issuable.assignees.any? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :unassign do - @updates[:assignee_id] = nil + if issuable.is_a?(Issue) + @updates[:assignee_ids] = [] + else + @updates[:assignee_id] = nil + end end desc 'Set milestone' + explanation do |milestone| + "Sets the milestone to #{milestone.to_reference}." if milestone + end params '%"milestone"' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) && project.milestones.active.any? end - command :milestone do |milestone_param| - milestone = extract_references(milestone_param, :milestone).first - milestone ||= project.milestones.find_by(title: milestone_param.strip) - + parse_params do |milestone_param| + extract_references(milestone_param, :milestone).first || + project.milestones.find_by(title: milestone_param.strip) + end + command :milestone do |milestone| @updates[:milestone_id] = milestone.id if milestone end desc 'Remove milestone' + explanation do + "Removes #{issuable.milestone.to_reference(format: :name)} milestone." + end condition do issuable.persisted? && issuable.milestone_id? && @@ -126,6 +165,11 @@ module SlashCommands end desc 'Add label(s)' + explanation do |labels_param| + labels = find_label_references(labels_param) + + "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + end params '~label1 ~"label 2"' condition do available_labels = LabelsFinder.new(current_user, project_id: project.id).execute @@ -145,6 +189,14 @@ module SlashCommands end desc 'Remove all or specific label(s)' + explanation do |labels_param = nil| + if labels_param.present? + labels = find_label_references(labels_param) + "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + else + 'Removes all labels.' + end + end params '~label1 ~"label 2"' condition do issuable.persisted? && @@ -167,6 +219,10 @@ module SlashCommands end desc 'Replace all label(s)' + explanation do |labels_param| + labels = find_label_references(labels_param) + "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? + end params '~label1 ~"label 2"' condition do issuable.persisted? && @@ -185,6 +241,7 @@ module SlashCommands end desc 'Add a todo' + explanation 'Adds a todo.' condition do issuable.persisted? && !TodoService.new.todo_exist?(issuable, current_user) @@ -194,6 +251,7 @@ module SlashCommands end desc 'Mark todo as done' + explanation 'Marks todo as done.' condition do issuable.persisted? && TodoService.new.todo_exist?(issuable, current_user) @@ -203,6 +261,9 @@ module SlashCommands end desc 'Subscribe' + explanation do + "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && !issuable.subscribed?(current_user, project) @@ -212,6 +273,9 @@ module SlashCommands end desc 'Unsubscribe' + explanation do + "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}." + end condition do issuable.persisted? && issuable.subscribed?(current_user, project) @@ -221,18 +285,23 @@ module SlashCommands end desc 'Set due date' + explanation do |due_date| + "Sets the due date to #{due_date.to_s(:medium)}." if due_date + end params '<in 2 days | this Friday | December 31st>' condition do issuable.respond_to?(:due_date) && current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :due do |due_date_param| - due_date = Chronic.parse(due_date_param).try(:to_date) - + parse_params do |due_date_param| + Chronic.parse(due_date_param).try(:to_date) + end + command :due do |due_date| @updates[:due_date] = due_date if due_date end desc 'Remove due date' + explanation 'Removes the due date.' condition do issuable.persisted? && issuable.respond_to?(:due_date) && @@ -243,8 +312,11 @@ module SlashCommands @updates[:due_date] = nil end - desc do - "Toggle the Work In Progress status" + desc 'Toggle the Work In Progress status' + explanation do + verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks' + noun = issuable.to_ability_name.humanize(capitalize: false) + "#{verb} this #{noun} as Work In Progress." end condition do issuable.persisted? && @@ -255,45 +327,72 @@ module SlashCommands @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' end - desc 'Toggle emoji reward' + desc 'Toggle emoji award' + explanation do |name| + "Toggles :#{name}: emoji award." if name + end params ':emoji:' condition do issuable.persisted? end - command :award do |emoji| - name = award_emoji_name(emoji) + parse_params do |emoji_param| + match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern) + match[1] if match + end + command :award do |name| if name && issuable.user_can_award?(current_user, name) @updates[:emoji_award] = name end end desc 'Set time estimate' + explanation do |time_estimate| + time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate) + + "Sets time estimate to #{time_estimate}." if time_estimate + end params '<1w 3d 2h 14m>' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", project) end - command :estimate do |raw_duration| - time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration) - + parse_params do |raw_duration| + Gitlab::TimeTrackingFormatter.parse(raw_duration) + end + command :estimate do |time_estimate| if time_estimate @updates[:time_estimate] = time_estimate end end desc 'Add or substract spent time' + explanation do |time_spent| + if time_spent + if time_spent > 0 + verb = 'Adds' + value = time_spent + else + verb = 'Substracts' + value = -time_spent + end + + "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time." + end + end params '<1h 30m | -1h 30m>' condition do current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) end - command :spend do |raw_duration| - time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration) - + parse_params do |raw_duration| + Gitlab::TimeTrackingFormatter.parse(raw_duration) + end + command :spend do |time_spent| if time_spent @updates[:spend_time] = { duration: time_spent, user: current_user } end end desc 'Remove time estimate' + explanation 'Removes time estimate.' condition do issuable.persisted? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) @@ -303,6 +402,7 @@ module SlashCommands end desc 'Remove spent time' + explanation 'Removes spent time.' condition do issuable.persisted? && current_user.can?(:"admin_#{issuable.to_ability_name}", project) @@ -316,23 +416,78 @@ module SlashCommands params '@user' command :cc - desc 'Defines target branch for MR' + desc 'Define target branch for MR' + explanation do |branch_name| + "Sets target branch to #{branch_name}." + end params '<Local branch name>' condition do issuable.respond_to?(:target_branch) && (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) || issuable.new_record?) end - command :target_branch do |target_branch_param| - branch_name = target_branch_param.strip + parse_params do |target_branch_param| + target_branch_param.strip + end + command :target_branch do |branch_name| @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name) end + desc 'Move issue from one column of the board to another' + explanation do |target_list_name| + label = find_label_references(target_list_name).first + "Moves issue to #{label} column in the board." if label + end + params '~"Target column"' + condition do + issuable.is_a?(Issue) && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) && + issuable.project.boards.count == 1 + end + command :board_move do |target_list_name| + label_ids = find_label_ids(target_list_name) + + if label_ids.size == 1 + label_id = label_ids.first + + # Ensure this label corresponds to a list on the board + next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists? + + @updates[:remove_label_ids] = + issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id) + @updates[:add_label_ids] = [label_id] + end + end + + def find_labels(labels_param) + extract_references(labels_param, :label) | + LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute + end + + def find_label_references(labels_param) + find_labels(labels_param).map(&:to_reference) + end + def find_label_ids(labels_param) - label_ids_by_reference = extract_references(labels_param, :label).map(&:id) - labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id) + find_labels(labels_param).map(&:id) + end + + def explain_commands(commands, opts) + commands.map do |name, arg| + definition = self.class.definition_by_name(name) + next unless definition - label_ids_by_reference | labels_ids_by_name + definition.explain(self, opts, arg) + end.compact + end + + def extract_updates(commands, opts) + commands.each do |name, arg| + definition = self.class.definition_by_name(name) + next unless definition + + definition.execute(self, opts, arg) + end end def extract_references(arg, type) @@ -342,9 +497,13 @@ module SlashCommands ext.references(type) end - def award_emoji_name(emoji) - match = emoji.match(Banzai::Filter::EmojiFilter.emoji_pattern) - match[1] if match + def context + { + issuable: issuable, + current_user: current_user, + project: project, + params: params + } end end end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index af0ddbe5934..ed476fc9d0c 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -51,7 +51,7 @@ class SystemHooksService path: model.path, group_id: model.id, owner_name: owner.respond_to?(:name) ? owner.name : nil, - owner_email: owner.respond_to?(:email) ? owner.email : nil, + owner_email: owner.respond_to?(:email) ? owner.email : nil ) when GroupMember data.merge!(group_member_data(model)) @@ -113,7 +113,7 @@ class SystemHooksService user_name: model.user.name, user_email: model.user.email, user_id: model.user.id, - group_access: model.human_access, + group_access: model.human_access } end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index d3e502b66dd..93bf1fb1615 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -49,6 +49,44 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee')) end + # Called when the assignees of an Issue is changed or removed + # + # issue - Issue object + # project - Project owning noteable + # author - User performing the change + # assignees - Users being assigned, or nil + # + # Example Note text: + # + # "removed all assignees" + # + # "assigned to @user1 additionally to @user2" + # + # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5" + # + # "assigned to @user1 and @user2" + # + # Returns the created Note object + def change_issue_assignees(issue, project, author, old_assignees) + body = + if issue.assignees.any? && old_assignees.any? + unassigned_users = old_assignees - issue.assignees + added_users = issue.assignees.to_a - old_assignees + + text_parts = [] + text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any? + text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any? + + text_parts.join(' and ') + elsif old_assignees.any? + "removed assignee" + elsif issue.assignees.any? + "assigned to #{issue.assignees.map(&:to_reference).to_sentence}" + end + + create_note(NoteSummary.new(issue, project, author, body, action: 'assignee')) + end + # Called when one or more labels on a Noteable are added and/or removed # # noteable - Noteable object @@ -183,7 +221,9 @@ module SystemNoteService body = status.dup body << " via #{source.gfm_reference(project)}" if source - create_note(NoteSummary.new(noteable, project, author, body, action: 'status')) + action = status == 'reopened' ? 'opened' : status + + create_note(NoteSummary.new(noteable, project, author, body, action: action)) end # Called when 'merge when pipeline succeeds' is executed @@ -226,12 +266,10 @@ module SystemNoteService def discussion_continued_in_issue(discussion, project, author, issue) body = "created #{issue.to_reference} to continue this discussion" + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) - note_params = discussion.reply_attributes.merge(project: project, author: author, note: body) - note_params[:type] = note_params.delete(:note_type) - - note = Note.create(note_params.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new({ action: 'discussion' }) + note = Note.create(note_attributes.merge(system: true)) + note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion') note end @@ -253,14 +291,31 @@ module SystemNoteService old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs - marked_old_title = Gitlab::Diff::InlineDiffMarker.new(old_title).mark(old_diffs, mode: :deletion, markdown: true) - marked_new_title = Gitlab::Diff::InlineDiffMarker.new(new_title).mark(new_diffs, mode: :addition, markdown: true) + marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion) + marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition) body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**" create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) end + # Called when the description of a Noteable is changed + # + # noteable - Noteable object that responds to `description` + # project - Project owning noteable + # author - User performing the change + # + # Example Note text: + # + # "changed the description" + # + # Returns the created Note object + def change_description(noteable, project, author) + body = 'changed the description' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'description')) + end + # Called when the confidentiality changes # # issue - Issue object @@ -273,9 +328,15 @@ module SystemNoteService # # Returns the created Note object def change_issue_confidentiality(issue, project, author) - body = issue.confidential ? 'made the issue confidential' : 'made the issue visible to everyone' + if issue.confidential + body = 'made the issue confidential' + action = 'confidential' + else + body = 'made the issue visible to everyone' + action = 'visible' + end - create_note(NoteSummary.new(issue, project, author, body, action: 'confidentiality')) + create_note(NoteSummary.new(issue, project, author, body, action: action)) end # Called when a branch in Noteable is changed diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 2c56cb4c680..322c6286365 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -204,7 +204,7 @@ class TodoService # Only update those that are not really on that state todos = todos.where.not(state: state) todos_ids = todos.pluck(:id) - todos.update_all(state: state) + todos.unscope(:order).update_all(state: state) current_user.update_todos_count_cache todos_ids end @@ -251,9 +251,9 @@ class TodoService end def create_assignment_todo(issuable, author) - if issuable.assignee + if issuable.assignees.any? attributes = attributes_for_todo(issuable.project, issuable, author, Todo::ASSIGNED) - create_todos(issuable.assignee, attributes) + create_todos(issuable.assignees, attributes) end end @@ -281,7 +281,7 @@ class TodoService def attributes_for_target(target) attributes = { - project_id: target.project.id, + project_id: target&.project&.id, target_id: target.id, target_type: target.class.name, commit_id: nil diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb new file mode 100644 index 00000000000..6c5b2baff41 --- /dev/null +++ b/app/services/upload_service.rb @@ -0,0 +1,20 @@ +class UploadService + def initialize(model, file, uploader_class = FileUploader) + @model, @file, @uploader_class = model, file, uploader_class + end + + def execute + return nil unless @file && @file.size <= max_attachment_size + + uploader = @uploader_class.new(@model) + uploader.store!(@file) + + uploader.to_h + end + + private + + def max_attachment_size + current_application_settings.max_attachment_size.megabytes.to_i + end +end diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb new file mode 100644 index 00000000000..facf21a7f5c --- /dev/null +++ b/app/services/users/activity_service.rb @@ -0,0 +1,22 @@ +module Users + class ActivityService + def initialize(author, activity) + @author = author.respond_to?(:user) ? author.user : author + @activity = activity + end + + def execute + return unless @author && @author.is_a?(User) + + record_activity + end + + private + + def record_activity + Gitlab::UserActivities.record(@author.id) + + Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@author.id} (username: #{@author.username}") + end + end +end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb new file mode 100644 index 00000000000..363135ef09b --- /dev/null +++ b/app/services/users/build_service.rb @@ -0,0 +1,107 @@ +module Users + # Service for building a new user. + class BuildService < BaseService + def initialize(current_user, params = {}) + @current_user = current_user + @params = params.dup + end + + def execute(skip_authorization: false) + raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_create_user? + + user_params = build_user_params(skip_authorization: skip_authorization) + user = User.new(user_params) + + if current_user&.admin? + @reset_token = user.generate_reset_token if params[:reset_password] + + if user_params[:force_random_password] + random_password = Devise.friendly_token.first(Devise.password_length.min) + user.password = user.password_confirmation = random_password + end + end + + identity_attrs = params.slice(:extern_uid, :provider) + + if identity_attrs.any? + user.identities.build(identity_attrs) + end + + user + end + + private + + def can_create_user? + (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.admin? + end + + # Allowed params for creating a user (admins only) + def admin_create_params + [ + :access_level, + :admin, + :avatar, + :bio, + :can_create_group, + :color_scheme_id, + :email, + :external, + :force_random_password, + :hide_no_password, + :hide_no_ssh_key, + :key_id, + :linkedin, + :name, + :password, + :password_automatically_set, + :password_expires_at, + :projects_limit, + :remember_me, + :skip_confirmation, + :skype, + :theme_id, + :twitter, + :username, + :website_url + ] + end + + # Allowed params for user signup + def signup_params + [ + :email, + :email_confirmation, + :password_automatically_set, + :name, + :password, + :username + ] + end + + def build_user_params(skip_authorization:) + if current_user&.admin? + user_params = params.slice(*admin_create_params) + user_params[:created_by_id] = current_user&.id + + if params[:reset_password] + user_params.merge!(force_random_password: true, password_expires_at: nil) + end + else + allowed_signup_params = signup_params + allowed_signup_params << :skip_confirmation if skip_authorization + + user_params = params.slice(*allowed_signup_params) + if user_params[:skip_confirmation].nil? + user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting + end + end + + user_params + end + + def skip_user_confirmation_email_from_setting + !current_application_settings.send_user_confirmation_email + end + end +end diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index 193fcd85896..e22f7225ae2 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -6,34 +6,10 @@ module Users @params = params.dup end - def build - raise Gitlab::Access::AccessDeniedError unless can_create_user? + def execute(skip_authorization: false) + user = Users::BuildService.new(current_user, params).execute(skip_authorization: skip_authorization) - user = User.new(build_user_params) - - if current_user&.is_admin? - if params[:reset_password] - @reset_token = user.generate_reset_token - params[:force_random_password] = true - end - - if params[:force_random_password] - random_password = Devise.friendly_token.first(Devise.password_length.min) - user.password = user.password_confirmation = random_password - end - end - - identity_attrs = params.slice(:extern_uid, :provider) - - if identity_attrs.any? - user.identities.build(identity_attrs) - end - - user - end - - def execute - user = build + @reset_token = user.generate_reset_token if user.recently_sent_password_reset? if user.save log_info("User \"#{user.name}\" (#{user.email}) was created") @@ -43,68 +19,5 @@ module Users user end - - private - - def can_create_user? - (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.is_admin? - end - - # Allowed params for creating a user (admins only) - def admin_create_params - [ - :access_level, - :admin, - :avatar, - :bio, - :can_create_group, - :color_scheme_id, - :email, - :external, - :force_random_password, - :hide_no_password, - :hide_no_ssh_key, - :key_id, - :linkedin, - :name, - :password, - :password_expires_at, - :projects_limit, - :remember_me, - :skip_confirmation, - :skype, - :theme_id, - :twitter, - :username, - :website_url - ] - end - - # Allowed params for user signup - def signup_params - [ - :email, - :email_confirmation, - :name, - :password, - :username - ] - end - - def build_user_params - if current_user&.is_admin? - user_params = params.slice(*admin_create_params) - user_params[:created_by_id] = current_user&.id - - if params[:reset_password] - user_params.merge!(force_random_password: true, password_expires_at: nil) - end - else - user_params = params.slice(*signup_params) - user_params[:skip_confirmation] = !current_application_settings.send_user_confirmation_email - end - - user_params - end end end diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index 833da5bc5d1..9eb6a600f6b 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -20,13 +20,13 @@ module Users Groups::DestroyService.new(group, current_user).execute end - user.personal_projects.each do |project| + user.personal_projects.with_deleted.each do |project| # Skip repository removal because we remove directory with namespace # that contain all this repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).async_execute + ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute end - move_issues_to_ghost_user(user) + MigrateToGhostUserService.new(user).execute unless options[:hard_delete] # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing namespace = user.namespace @@ -35,22 +35,5 @@ module Users user_data end - - private - - def move_issues_to_ghost_user(user) - # Block the user before moving issues to prevent a data race. - # If the user creates an issue after `move_issues_to_ghost_user` - # runs and before the user is destroyed, the destroy will fail with - # an exception. We block the user so that issues can't be created - # after `move_issues_to_ghost_user` runs and before the destroy happens. - user.block - - ghost_user = User.ghost - - user.issues.update_all(author_id: ghost_user.id) - - user.reload - end end end diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb new file mode 100644 index 00000000000..4628c4c6f6e --- /dev/null +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -0,0 +1,71 @@ +# When a user is destroyed, some of their associated records are +# moved to a "Ghost User", to prevent these associated records from +# being destroyed. +# +# For example, all the issues/MRs a user has created are _not_ destroyed +# when the user is destroyed. +module Users + class MigrateToGhostUserService + extend ActiveSupport::Concern + + attr_reader :ghost_user, :user + + def initialize(user) + @user = user + end + + def execute + transition = user.block_transition + + user.transaction do + # Block the user before moving records to prevent a data race. + # For example, if the user creates an issue after `migrate_issues` + # runs and before the user is destroyed, the destroy will fail with + # an exception. + user.block + + # Reverse the user block if record migration fails + if !migrate_records && transition + transition.rollback + user.save! + end + end + + user.reload + end + + private + + def migrate_records + user.transaction(requires_new: true) do + @ghost_user = User.ghost + + migrate_issues + migrate_merge_requests + migrate_notes + migrate_abuse_reports + migrate_award_emojis + end + end + + def migrate_issues + user.issues.update_all(author_id: ghost_user.id) + end + + def migrate_merge_requests + user.merge_requests.update_all(author_id: ghost_user.id) + end + + def migrate_notes + user.notes.update_all(author_id: ghost_user.id) + end + + def migrate_abuse_reports + user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) + end + + def migrate_award_emojis + user.award_emoji.update_all(user_id: ghost_user.id) + end + end +end diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb index 2f61be184ce..d232e85cd33 100644 --- a/app/services/validate_new_branch_service.rb +++ b/app/services/validate_new_branch_service.rb @@ -8,10 +8,7 @@ class ValidateNewBranchService < BaseService return error('Branch name is invalid') end - repository = project.repository - existing_branch = repository.find_branch(branch_name) - - if existing_branch + if project.repository.branch_exists?(branch_name) return error('Branch already exists') end diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb index e84944ed411..3e36ec91205 100644 --- a/app/uploaders/artifact_uploader.rb +++ b/app/uploaders/artifact_uploader.rb @@ -30,8 +30,4 @@ class ArtifactUploader < GitlabUploader def filename file.try(:filename) end - - def exists? - file.try(:exists?) - end end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index d6ccf0dc92c..7e94218c23d 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -26,11 +26,11 @@ class FileUploader < GitlabUploader File.join(CarrierWave.root, base_dir, model.path_with_namespace) end - attr_accessor :project + attr_accessor :model attr_reader :secret - def initialize(project, secret = nil) - @project = project + def initialize(model, secret = nil) + @model = model @secret = secret || generate_secret end @@ -38,14 +38,6 @@ class FileUploader < GitlabUploader File.join(dynamic_path_segment, @secret) end - def cache_dir - File.join(base_dir, 'tmp', @project.path_with_namespace, @secret) - end - - def model - project - end - def relative_path self.file.path.sub("#{dynamic_path_segment}/", '') end diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index d662ba6820c..e0a6c9b4067 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -33,4 +33,8 @@ class GitlabUploader < CarrierWave::Uploader::Base def relative_path self.file.path.sub("#{root}/", '') end + + def exists? + file.try(:exists?) + end end diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index faab539b8e0..95a891111e1 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -9,10 +9,6 @@ class LfsObjectUploader < GitlabUploader "#{Gitlab.config.lfs.storage_path}/tmp/cache" end - def exists? - file.try(:exists?) - end - def filename model.oid[4..-1] end diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb new file mode 100644 index 00000000000..969b0a20d38 --- /dev/null +++ b/app/uploaders/personal_file_uploader.rb @@ -0,0 +1,15 @@ +class PersonalFileUploader < FileUploader + def self.dynamic_path_segment(model) + File.join(CarrierWave.root, model_path(model)) + end + + private + + def secure_url + File.join(self.class.model_path(model), secret, file.filename) + end + + def self.model_path(model) + File.join("/#{base_dir}", model.class.to_s.underscore, model.id.to_s) + end +end diff --git a/app/validators/cron_timezone_validator.rb b/app/validators/cron_timezone_validator.rb new file mode 100644 index 00000000000..542c7d006ad --- /dev/null +++ b/app/validators/cron_timezone_validator.rb @@ -0,0 +1,9 @@ +# CronTimezoneValidator +# +# Custom validator for CronTimezone. +class CronTimezoneValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone) + record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_timezone_valid? + end +end diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb new file mode 100644 index 00000000000..981fade47a6 --- /dev/null +++ b/app/validators/cron_validator.rb @@ -0,0 +1,9 @@ +# CronValidator +# +# Custom validator for Cron. +class CronValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone) + record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid? + end +end diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb new file mode 100644 index 00000000000..d992b0c3725 --- /dev/null +++ b/app/validators/dynamic_path_validator.rb @@ -0,0 +1,215 @@ +# DynamicPathValidator +# +# Custom validator for GitLab path values. +# These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project` +# +# Values are checked for formatting and exclusion from a list of reserved path +# names. +class DynamicPathValidator < ActiveModel::EachValidator + # All routes that appear on the top level must be listed here. + # This will make sure that groups cannot be created with these names + # as these routes would be masked by the paths already in place. + # + # Example: + # /api/api-project + # + # the path `api` shouldn't be allowed because it would be masked by `api/*` + # + TOP_LEVEL_ROUTES = %w[ + - + .well-known + abuse_reports + admin + all + api + assets + autocomplete + ci + dashboard + explore + files + groups + health_check + help + hooks + import + invites + issues + jwt + koding + member + merge_requests + new + notes + notification_settings + oauth + profile + projects + public + repository + robots.txt + s + search + sent_notifications + services + snippets + teams + u + unicorn_test + unsubscribes + uploads + users + ].freeze + + # This list should contain all words following `/*namespace_id/:project_id` in + # routes that contain a second wildcard. + # + # Example: + # /*namespace_id/:project_id/badges/*ref/build + # + # If `badges` was allowed as a project/group name, we would not be able to access the + # `badges` route for those projects: + # + # Consider a namespace with path `foo/bar` and a project called `badges`. + # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg` + # + # When accessing this path the route would be matched to the `badges` path + # with the following params: + # - namespace_id: `foo` + # - project_id: `bar` + # - ref: `badges/master` + # + # Failing to find the project, this would result in a 404. + # + # By rejecting `badges` the router can _count_ on the fact that `badges` will + # be preceded by the `namespace/project`. + WILDCARD_ROUTES = %w[ + badges + blame + blob + builds + commits + create + create_dir + edit + environments/folders + files + find_file + gitlab-lfs/objects + info/lfs/objects + new + preview + raw + refs + tree + update + wikis + ].freeze + + # These are all the paths that follow `/groups/*id/ or `/groups/*group_id` + # We need to reject these because we have a `/groups/*id` page that is the same + # as the `/*id`. + # + # If we would allow a subgroup to be created with the name `activity` then + # this group would not be accessible through `/groups/parent/activity` since + # this would map to the activity-page of it's parent. + GROUP_ROUTES = %w[ + activity + analytics + audit_events + avatar + edit + group_members + hooks + issues + labels + ldap + ldap_group_links + merge_requests + milestones + notification_setting + pipeline_quota + projects + subgroups + ].freeze + + CHILD_ROUTES = (WILDCARD_ROUTES | GROUP_ROUTES).freeze + + def self.without_reserved_wildcard_paths_regex + @without_reserved_wildcard_paths_regex ||= regex_excluding_child_paths(WILDCARD_ROUTES) + end + + def self.without_reserved_child_paths_regex + @without_reserved_child_paths_regex ||= regex_excluding_child_paths(CHILD_ROUTES) + end + + # This is used to validate a full path. + # It doesn't match paths + # - Starting with one of the top level words + # - Containing one of the child level words in the middle of a path + def self.regex_excluding_child_paths(child_routes) + reserved_top_level_words = Regexp.union(TOP_LEVEL_ROUTES) + not_starting_in_reserved_word = %r{\A/?(?!(#{reserved_top_level_words})(/|\z))} + + reserved_child_level_words = Regexp.union(child_routes) + not_containing_reserved_child = %r{(?!\S+/(#{reserved_child_level_words})(/|\z))} + + %r{#{not_starting_in_reserved_word} + #{not_containing_reserved_child} + #{Gitlab::Regex.full_namespace_regex}}x + end + + def self.valid?(path) + path =~ Gitlab::Regex.full_namespace_regex && !full_path_reserved?(path) + end + + def self.full_path_reserved?(path) + path = path.to_s.downcase + _project_part, namespace_parts = path.reverse.split('/', 2).map(&:reverse) + + wildcard_reserved?(path) || child_reserved?(namespace_parts) + end + + def self.child_reserved?(path) + return false unless path + + path !~ without_reserved_child_paths_regex + end + + def self.wildcard_reserved?(path) + return false unless path + + path !~ without_reserved_wildcard_paths_regex + end + + delegate :full_path_reserved?, + :child_reserved?, + to: :class + + def path_reserved_for_record?(record, value) + full_path = record.respond_to?(:full_path) ? record.full_path : value + + # For group paths the entire path cannot contain a reserved child word + # The path doesn't contain the last `_project_part` so we need to validate + # if the entire path. + # Example: + # A *group* with full path `parent/activity` is reserved. + # A *project* with full path `parent/activity` is allowed. + if record.is_a? Group + child_reserved?(full_path) + else + full_path_reserved?(full_path) + end + end + + def validate_each(record, attribute, value) + unless value =~ Gitlab::Regex.namespace_regex + record.errors.add(attribute, Gitlab::Regex.namespace_regex_message) + return + end + + if path_reserved_for_record?(record, value) + record.errors.add(attribute, "#{value} is a reserved name") + end + end +end diff --git a/app/validators/namespace_validator.rb b/app/validators/namespace_validator.rb deleted file mode 100644 index 77ca033e97f..00000000000 --- a/app/validators/namespace_validator.rb +++ /dev/null @@ -1,73 +0,0 @@ -# NamespaceValidator -# -# Custom validator for GitLab namespace values. -# -# Values are checked for formatting and exclusion from a list of reserved path -# names. -class NamespaceValidator < ActiveModel::EachValidator - RESERVED = %w[ - .well-known - admin - all - assets - ci - dashboard - files - groups - help - hooks - issues - merge_requests - new - notes - profile - projects - public - repository - robots.txt - s - search - services - snippets - teams - u - unsubscribes - users - ].freeze - - WILDCARD_ROUTES = %w[tree commits wikis new edit create update logs_tree - preview blob blame raw files create_dir find_file - artifacts graphs refs badges].freeze - - STRICT_RESERVED = (RESERVED + WILDCARD_ROUTES).freeze - - def self.valid?(value) - !reserved?(value) && follow_format?(value) - end - - def self.reserved?(value, strict: false) - if strict - STRICT_RESERVED.include?(value) - else - RESERVED.include?(value) - end - end - - def self.follow_format?(value) - value =~ Gitlab::Regex.namespace_regex - end - - delegate :reserved?, :follow_format?, to: :class - - def validate_each(record, attribute, value) - unless follow_format?(value) - record.errors.add(attribute, Gitlab::Regex.namespace_regex_message) - end - - strict = record.is_a?(Group) && record.parent_id - - if reserved?(value, strict: strict) - record.errors.add(attribute, "#{value} is a reserved name") - end - end -end diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb deleted file mode 100644 index ee2ae65be7b..00000000000 --- a/app/validators/project_path_validator.rb +++ /dev/null @@ -1,35 +0,0 @@ -# ProjectPathValidator -# -# Custom validator for GitLab project path values. -# -# Values are checked for formatting and exclusion from a list of reserved path -# names. -class ProjectPathValidator < ActiveModel::EachValidator - # All project routes with wildcard argument must be listed here. - # Otherwise it can lead to routing issues when route considered as project name. - # - # Example: - # /group/project/tree/deploy_keys - # - # without tree as reserved name routing can match 'group/project' as group name, - # 'tree' as project name and 'deploy_keys' as route. - # - RESERVED = (NamespaceValidator::STRICT_RESERVED - - %w[dashboard help ci admin search notes services assets profile public]).freeze - - def self.valid?(value) - !reserved?(value) - end - - def self.reserved?(value) - RESERVED.include?(value) - end - - delegate :reserved?, to: :class - - def validate_each(record, attribute, value) - if reserved?(value) - record.errors.add(attribute, "#{value} is a reserved name") - end - end -end diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index 05f3d9a3b50..18c6c559049 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -30,5 +30,5 @@ = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm btn-block" - else .btn.btn-sm.disabled.btn-block - Already Blocked + Already blocked = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr" diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 3eab065bb9f..e1b4e34cd2b 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -148,7 +148,7 @@ Sign-in enabled - if omniauth_enabled? && button_based_providers.any? .form-group - = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth Sign-In sources', class: 'control-label col-sm-2' + = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2' .col-sm-10 .btn-group{ data: { toggle: 'buttons' } } - oauth_providers_checkboxes.each do |source| @@ -394,8 +394,6 @@ %fieldset %legend Error Reporting and Logging - %p - These settings require a restart to take effect. .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -403,6 +401,7 @@ = f.check_box :sentry_enabled Enable Sentry .help-block + %p This setting requires a restart to take effect. Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here: %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com @@ -411,6 +410,21 @@ .col-sm-10 = f.text_field :sentry_dsn, class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :clientside_sentry_enabled do + = f.check_box :clientside_sentry_enabled + Enable Clientside Sentry + .help-block + Sentry can also be used for reporting and logging clientside exceptions. + %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/ + + .form-group + = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :clientside_sentry_dsn, class: 'form-control' + %fieldset %legend Repository Storage .form-group @@ -477,7 +491,7 @@ diagrams in Asciidoc documents using an external PlantUML service. %fieldset - %legend Usage statistics + %legend#usage-statistics Usage statistics .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -486,6 +500,26 @@ Version check enabled .help-block Let GitLab inform you when an update is available. + .form-group + .col-sm-offset-2.col-sm-10 + - can_be_configured = @application_setting.usage_ping_can_be_configured? + .checkbox + = f.label :usage_ping_enabled do + = f.check_box :usage_ping_enabled, disabled: !can_be_configured + Usage ping enabled + = link_to icon('question-circle'), help_page_path("user/admin_area/settings/usage_statistics", anchor: "usage-ping") + .help-block + - if can_be_configured + Every week GitLab will report license usage back to GitLab, Inc. + Disable this option if you do not want this to occur. To see the + JSON payload that will be sent, visit the + = succeed '.' do + = link_to "Cohorts page", admin_cohorts_path(anchor: 'usage-ping') + - else + The usage ping is disabled, and cannot be configured through this + form. For more information, see the documentation on + = succeed '.' do + = link_to 'deactivating the usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'deactivate-the-usage-ping') %fieldset %legend Email @@ -558,5 +592,20 @@ Maximum time for web terminal websocket connection (in seconds). 0 for unlimited. + %fieldset + %legend Real-time features + .form-group + = f.label :polling_interval_multiplier, 'Polling interval multiplier', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :polling_interval_multiplier, class: 'form-control' + .help-block + Change this value to influence how frequently the GitLab UI polls for updates. + If you set the value to 2 all polling intervals are multiplied + by 2, which means that polling happens half as frequently. + The multiplier can also have a decimal value. + The default value (1) is a reasonable choice for the majority of GitLab + installations. Set to 0 to completely disable polling. + = link_to icon('question-circle'), help_page_path('administration/polling') + .form-actions = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index b3a3b4c1d45..eb4293c7e37 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -4,7 +4,7 @@ %p.light System OAuth applications don't belong to any user and can only be managed by admins %hr -%p= link_to 'New Application', new_admin_application_path, class: 'btn btn-success' +%p= link_to 'New application', new_admin_application_path, class: 'btn btn-success' %table.table.table-striped %thead %tr diff --git a/app/views/admin/cohorts/_cohorts_table.html.haml b/app/views/admin/cohorts/_cohorts_table.html.haml new file mode 100644 index 00000000000..701a4e62b39 --- /dev/null +++ b/app/views/admin/cohorts/_cohorts_table.html.haml @@ -0,0 +1,28 @@ +.bs-callout.clearfix + %p + User cohorts are shown for the last #{@cohorts[:months_included]} + months. Only users with activity are counted in the cohort total; inactive + users are counted separately. + = link_to icon('question-circle'), help_page_path('user/admin_area/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank' + +.table-holder + %table.table + %thead + %tr + %th Registration month + %th Inactive users + %th Cohort total + - @cohorts[:months_included].times do |i| + %th Month #{i} + %tbody + - @cohorts[:cohorts].each do |cohort| + %tr + %td= cohort[:registration_month] + %td= cohort[:inactive] + %td= cohort[:total] + - cohort[:activity_months].each do |activity_month| + %td + - next if cohort[:total] == '0' + = activity_month[:percentage] + %br + = activity_month[:total] diff --git a/app/views/admin/cohorts/_usage_ping.html.haml b/app/views/admin/cohorts/_usage_ping.html.haml new file mode 100644 index 00000000000..73aa95d84f1 --- /dev/null +++ b/app/views/admin/cohorts/_usage_ping.html.haml @@ -0,0 +1,10 @@ +%h2#usage-ping Usage ping + +.bs-callout.clearfix + %p + User cohorts are shown because the usage ping is enabled. The data sent with + this is shown below. To disable this, visit + = succeed '.' do + = link_to 'application settings', admin_application_settings_path(anchor: 'usage-statistics') + +%pre.usage-data.js-syntax-highlight.code.highlight{ data: { endpoint: usage_data_admin_application_settings_path(format: :html, pretty: true) } } diff --git a/app/views/admin/cohorts/index.html.haml b/app/views/admin/cohorts/index.html.haml new file mode 100644 index 00000000000..be8644c0ca6 --- /dev/null +++ b/app/views/admin/cohorts/index.html.haml @@ -0,0 +1,16 @@ +- @no_container = true += render "admin/dashboard/head" + +%div{ class: container_class } + - if @cohorts + = render 'cohorts_table' + = render 'usage_ping' + - else + .bs-callout.bs-callout-warning.clearfix + %p + User cohorts are only shown when the + = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping'), target: '_blank' + is enabled. To enable it and see user cohorts, + visit + = succeed '.' do + = link_to 'application settings', admin_application_settings_path(anchor: 'usage-statistics') diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index 7893c1dee97..163bd5662b0 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -27,3 +27,7 @@ = link_to admin_runners_path, title: 'Runners' do %span Runners + = nav_link path: 'cohorts#index' do + = link_to admin_cohorts_path, title: 'Cohorts' do + %span + Cohorts diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index ebca9beb035..53f0a1e7fde 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -73,6 +73,12 @@ = container_reg %span.light.pull-right = boolean_to_icon Gitlab.config.registry.enabled + - gitlab_pages = 'GitLab Pages' + - gitlab_pages_enabled = Gitlab.config.pages.enabled + %p{ "aria-label" => "#{gitlab_pages}: status " + (gitlab_pages_enabled ? "on" : "off") } + = gitlab_pages + %span.light.pull-right + = boolean_to_icon gitlab_pages_enabled .col-md-4 %h4 @@ -125,7 +131,7 @@ = link_to admin_projects_path do %h1= number_with_delimiter(Project.cached_count) %hr - = link_to('New Project', new_project_path, class: "btn btn-new") + = link_to('New project', new_project_path, class: "btn btn-new") .col-sm-4 .light-well.well-centered %h4 Users @@ -133,7 +139,7 @@ = link_to admin_users_path do %h1= number_with_delimiter(User.count) %hr - = link_to 'New User', new_admin_user_path, class: "btn btn-new" + = link_to 'New user', new_admin_user_path, class: "btn btn-new" .col-sm-4 .light-well.well-centered %h4 Groups @@ -141,7 +147,7 @@ = link_to admin_groups_path do %h1= number_with_delimiter(Group.count) %hr - = link_to 'New Group', new_admin_group_path, class: "btn btn-new" + = link_to 'New group', new_admin_group_path, class: "btn btn-new" .row.prepend-top-10 .col-md-4 diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml index 7b71bb5b287..007da8c1d29 100644 --- a/app/views/admin/deploy_keys/index.html.haml +++ b/app/views/admin/deploy_keys/index.html.haml @@ -3,7 +3,7 @@ %h3.page-title.deploy-keys-title Public deploy keys (#{@deploy_keys.count}) .pull-right - = link_to 'New Deploy Key', new_admin_deploy_key_path, class: 'btn btn-new btn-sm btn-inverted' + = link_to 'New deploy key', new_admin_deploy_key_path, class: 'btn btn-new btn-sm btn-inverted' - if @deploy_keys.any? .table-holder.deploy-keys-list diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 589f4557b52..d9f05003904 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -13,7 +13,7 @@ .col-sm-offset-2.col-sm-10 = render 'shared/allow_request_access', form: f - = render 'groups/group_lfs_settings', f: f + = render 'groups/group_admin_settings', f: f - if @group.new_record? .form-group diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index 07775247cfd..e5f380c78e2 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -30,7 +30,7 @@ = link_to admin_groups_path(sort: sort_value_largest_group, name: project_name) do = sort_title_largest_group = link_to new_admin_group_path, class: "btn btn-new" do - New Group + New group %ul.content-list = render @groups diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 30b3fabdd7e..9149b8e7fb9 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -116,7 +116,7 @@ group members %span.badge= @group.members.size .pull-right - = link_to icon('pencil-square-o', text: 'Manage Access'), polymorphic_url([@group, :members]), class: "btn btn-xs" + = link_to icon('pencil-square-o', text: 'Manage access'), polymorphic_url([@group, :members]), class: "btn btn-xs" %ul.well-list.group-users-list.content-list = render partial: 'shared/members/member', collection: @members, as: :member, locals: { show_controls: false } .panel-footer diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index e79303240f0..4deccf4aa93 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -13,27 +13,18 @@ = button_to reset_health_check_token_admin_application_settings_path, method: :put, class: 'btn btn-default', data: { confirm: 'Are you sure you want to reset the health check token?' } do - = icon('refresh') + = icon('spinner') Reset health check access token %p.light - Health information can be retrieved as plain text, JSON, or XML using: + Health information can be retrieved from the following endpoints. More information is available + = link_to 'here', help_page_path('user/admin_area/monitoring/health_check') %ul %li - %code= health_check_url(token: current_application_settings.health_check_access_token) + %code= readiness_url(token: current_application_settings.health_check_access_token) %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) + %code= liveness_url(token: current_application_settings.health_check_access_token) %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) - - %p.light - You can also ask for the status of specific services: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) + %code= metrics_url(token: current_application_settings.health_check_access_token) %hr .panel.panel-default diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml new file mode 100644 index 00000000000..645005c6deb --- /dev/null +++ b/app/views/admin/hooks/_form.html.haml @@ -0,0 +1,47 @@ += form_errors(hook) + +.form-group + = form.label :url, 'URL', class: 'control-label' + .col-sm-10 + = form.text_field :url, class: 'form-control' +.form-group + = form.label :token, 'Secret Token', class: 'control-label' + .col-sm-10 + = form.text_field :token, class: 'form-control' + %p.help-block + Use this token to validate received payloads +.form-group + = form.label :url, 'Trigger', class: 'control-label' + .col-sm-10.prepend-top-10 + %div + System hook will be triggered on set of events like creating project + or adding ssh key. But you can also enable extra triggers like Push events. + + .prepend-top-default + = form.check_box :repository_update_events, class: 'pull-left' + .prepend-left-20 + = form.label :repository_update_events, class: 'list-label' do + %strong Repository update events + %p.light + This URL will be triggered when repository is updated + %div + = form.check_box :push_events, class: 'pull-left' + .prepend-left-20 + = form.label :push_events, class: 'list-label' do + %strong Push events + %p.light + This URL will be triggered for each branch updated to the repository + %div + = form.check_box :tag_push_events, class: 'pull-left' + .prepend-left-20 + = form.label :tag_push_events, class: 'list-label' do + %strong Tag push events + %p.light + This URL will be triggered when a new tag is pushed to the repository +.form-group + = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox' + .col-sm-10 + .checkbox + = form.label :enable_ssl_verification do + = form.check_box :enable_ssl_verification + %strong Enable SSL verification diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml new file mode 100644 index 00000000000..0777f5e2629 --- /dev/null +++ b/app/views/admin/hooks/edit.html.haml @@ -0,0 +1,14 @@ +- page_title 'Edit System Hook' +%h3.page-title + Edit System Hook + +%p.light + #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be + used for binding events when GitLab creates a User or Project. + +%hr + += form_for @hook, as: :hook, url: admin_hook_path, html: { class: 'form-horizontal' } do |f| + = render partial: 'form', locals: { form: f, hook: @hook } + .form-actions + = f.submit 'Save changes', class: 'btn btn-create' diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index 551edf14361..e92b8bc39f4 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -1,57 +1,17 @@ -- page_title "System Hooks" +- page_title 'System Hooks' %h3.page-title System hooks %p.light - #{link_to "System hooks ", help_page_path("system_hooks/system_hooks"), class: "vlink"} can be + #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be used for binding events when GitLab creates a User or Project. %hr - = form_for @hook, as: :hook, url: admin_hooks_path, html: { class: 'form-horizontal' } do |f| - = form_errors(@hook) - - .form-group - = f.label :url, 'URL', class: 'control-label' - .col-sm-10 - = f.text_field :url, class: 'form-control' - .form-group - = f.label :token, 'Secret Token', class: 'control-label' - .col-sm-10 - = f.text_field :token, class: 'form-control' - %p.help-block - Use this token to validate received payloads - .form-group - = f.label :url, "Trigger", class: 'control-label' - .col-sm-10.prepend-top-10 - %div - System hook will be triggered on set of events like creating project - or adding ssh key. But you can also enable extra triggers like Push events. - - .prepend-top-default - = f.check_box :push_events, class: 'pull-left' - .prepend-left-20 - = f.label :push_events, class: 'list-label' do - %strong Push events - %p.light - This url will be triggered by a push to the repository - %div - = f.check_box :tag_push_events, class: 'pull-left' - .prepend-left-20 - = f.label :tag_push_events, class: 'list-label' do - %strong Tag push events - %p.light - This url will be triggered when a new tag is pushed to the repository - .form-group - = f.label :enable_ssl_verification, "SSL verification", class: 'control-label checkbox' - .col-sm-10 - .checkbox - = f.label :enable_ssl_verification do - = f.check_box :enable_ssl_verification - %strong Enable SSL verification + = render partial: 'form', locals: { form: f, hook: @hook } .form-actions - = f.submit "Add System Hook", class: "btn btn-create" + = f.submit 'Add system hook', class: 'btn btn-create' %hr - if @hooks.any? @@ -62,11 +22,12 @@ - @hooks.each do |hook| %li .controls - = link_to 'Test Hook', admin_hook_test_path(hook), class: "btn btn-sm" - = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-remove btn-sm" + = link_to 'Test hook', test_admin_hook_path(hook), class: 'btn btn-sm' + = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm' + = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' .monospace= hook.url %div - - %w(push_events tag_push_events issues_events note_events merge_requests_events build_events).each do |trigger| + - %w(repository_update_events push_events tag_push_events issues_events note_events merge_requests_events job_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray= trigger.titleize - %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"} + %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index 741d111fb7d..ff67e59cdac 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -1,7 +1,7 @@ - page_title "Identities", @user.name, "Users" = render 'admin/users/head' -= link_to 'New Identity', new_admin_user_identity_path, class: 'pull-right btn btn-new' += link_to 'New identity', new_admin_user_identity_path, class: 'pull-right btn btn-new' - if @identities.present? .table-holder %table.table diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 2967da6e692..08a8f627113 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -159,7 +159,7 @@ %span.badge= @group_members.size .pull-right = link_to admin_group_path(@group), class: 'btn btn-xs' do - = icon('pencil-square-o', text: 'Manage Access') + = icon('pencil-square-o', text: 'Manage access') %ul.well-list.content-list = render partial: 'shared/members/member', collection: @group_members, as: :member, locals: { show_controls: false } .panel-footer @@ -173,7 +173,7 @@ project members %span.badge= @project.users.size .pull-right - = link_to icon('pencil-square-o', text: 'Manage Access'), polymorphic_url([@project, :members]), class: "btn btn-xs" + = link_to icon('pencil-square-o', text: 'Manage access'), polymorphic_url([@project, :members]), class: "btn btn-xs" %ul.well-list.project_members.content-list = render partial: 'shared/members/member', collection: @project_members, as: :member, locals: { show_controls: false } .panel-footer diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 7d26864d0f3..f118804cace 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -21,7 +21,7 @@ = button_to reset_runners_token_admin_application_settings_path, method: :put, class: 'btn btn-default', data: { confirm: 'Are you sure you want to reset registration token?' } do - = icon('refresh') + = icon('spinner') Reset runners registration token .bs-callout diff --git a/app/views/admin/services/index.html.haml b/app/views/admin/services/index.html.haml index 6a5986f496a..50132572096 100644 --- a/app/views/admin/services/index.html.haml +++ b/app/views/admin/services/index.html.haml @@ -13,7 +13,7 @@ - @services.sort_by(&:title).each do |service| %tr %td - = icon("copy", class: 'clgray') + = boolean_to_icon service.activated? %td = link_to edit_admin_application_settings_service_path(service.id) do %strong= service.title diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 33f6d847782..ea6a0c4fb77 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -35,5 +35,5 @@ = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs" - else .btn.btn-xs.disabled - Already Blocked + Already blocked = link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "btn btn-xs btn-close js-remove-tr" diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index a756cb7243a..8862455688f 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -37,6 +37,6 @@ - if user.can_be_removed? && can?(current_user, :destroy_user, @user) %li.divider %li - = link_to 'Delete User', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" }, + = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" }, class: 'btn btn-remove btn-block', method: :delete diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 298cf0fa950..5516134d8a0 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -3,41 +3,43 @@ = render "admin/dashboard/head" %div{ class: container_class } - .top-area - .prepend-top-default - = form_tag admin_users_path, method: :get do - - if params[:filter].present? - = hidden_field_tag "filter", h(params[:filter]) - .search-holder - .search-field-holder - = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false - = icon("search", class: "search-icon") - .dropdown - - toggle_text = if @sort.present? then sort_options_hash[@sort] else sort_title_name end - = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) - %ul.dropdown-menu.dropdown-menu-align-right - %li.dropdown-header - Sort by - %li - = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do - = sort_title_name - = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do - = sort_title_recently_signin - = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do - = sort_title_oldest_signin - = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do - = sort_title_recently_created - = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do - = sort_title_oldest_created - = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do - = sort_title_recently_updated - = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do - = sort_title_oldest_updated - = link_to 'New User', new_admin_user_path, class: 'btn btn-new btn-search' + .prepend-top-default + = form_tag admin_users_path, method: :get do + - if params[:filter].present? + = hidden_field_tag "filter", h(params[:filter]) + .search-holder + .search-field-holder + = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false + = icon("search", class: "search-icon") + .dropdown + - toggle_text = if @sort.present? then sort_options_hash[@sort] else sort_title_name end + = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) + %ul.dropdown-menu.dropdown-menu-align-right + %li.dropdown-header + Sort by + %li + = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do + = sort_title_name + = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do + = sort_title_recently_signin + = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do + = sort_title_oldest_signin + = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do + = sort_title_recently_created + = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do + = sort_title_oldest_created + = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do + = sort_title_recently_updated + = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do + = sort_title_oldest_updated + = link_to 'New user', new_admin_user_path, class: 'btn btn-new btn-search' - .nav-block - %ul.nav-links.wide.scrolling-tabs.white.scrolling-tabs - .fade-left + .top-area.scrolling-tabs-container.inner-page-scroll-tabs + .fade-left + = icon('angle-left') + .fade-right + = icon('angle-right') + %ul.nav-links.scrolling-tabs = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do = link_to admin_users_path do Active @@ -66,7 +68,6 @@ = link_to admin_users_path(filter: "wop") do Without projects %small.badge= number_with_delimiter(User.without_projects.count) - .fade-right %ul.flex-list.content-list - if @users.empty? diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 840d843f069..89d0bbb7126 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -175,11 +175,7 @@ .panel-body - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting a user has the following effects: - %ul - %li All user content like authored issues, snippets, comments will be removed - - rp = @user.personal_projects.count - - unless rp.zero? - %li #{pluralize rp, 'personal project'} will be removed and cannot be restored + = render 'users/deletion_guidance', user: @user %br = link_to 'Remove user', [:admin, @user], data: { confirm: "USER #{@user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-remove" - else diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml index 5aae410a63f..5f07d2720c2 100644 --- a/app/views/award_emoji/_awards_block.html.haml +++ b/app/views/award_emoji/_awards_block.html.haml @@ -1,8 +1,9 @@ - grouped_emojis = awardable.grouped_awards(with_thumbs: inline) +- user_authored = awardable.user_authored?(current_user) .awards.js-awards-block{ class: ("hidden" if !inline && grouped_emojis.empty?), data: { award_url: toggle_award_url(awardable) } } - awards_sort(grouped_emojis).each do |emoji, awards| %button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button", - class: (award_state_class(awards, current_user)), + class: [(award_state_class(awards, current_user)), (award_user_authored_class(emoji) if user_authored)], data: { placement: "bottom", title: award_user_list(awards, current_user) } } = emoji_icon(emoji) %span.award-control-text.js-counter @@ -11,7 +12,10 @@ - if current_user .award-menu-holder.js-award-holder %button.btn.award-control.has-tooltip.js-add-award{ type: 'button', - 'aria-label': 'Add emoji', - data: { title: 'Add emoji', placement: "bottom" } } - = icon('smile-o', class: "award-control-icon award-control-icon-normal") + 'aria-label': 'Add reaction', + class: ("js-user-authored" if user_authored), + data: { title: 'Add reaction', placement: "bottom" } } + %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') + %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') + %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') = icon('spinner spin', class: "award-control-icon award-control-icon-loading") diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml index c00c7f7407e..39c7fb0eba2 100644 --- a/app/views/ci/status/_badge.html.haml +++ b/app/views/ci/status/_badge.html.haml @@ -1,12 +1,13 @@ - status = local_assigns.fetch(:status) -- link = local_assigns.fetch(:link, true) -- css_classes = "ci-status ci-#{status.group}" +- link = local_assigns.fetch(:link, true) +- title = local_assigns.fetch(:title, nil) +- css_classes = "ci-status ci-#{status.group} #{'has-tooltip' if title.present?}" - if link && status.has_details? - = link_to status.details_path, class: css_classes do + = link_to status.details_path, class: css_classes, title: title do = custom_icon(status.icon) = status.text - else - %span{ class: css_classes } + %span{ class: css_classes, title: title } = custom_icon(status.icon) = status.text diff --git a/app/views/ci/status/_graph_badge.html.haml b/app/views/ci/status/_graph_badge.html.haml deleted file mode 100644 index 128b418090f..00000000000 --- a/app/views/ci/status/_graph_badge.html.haml +++ /dev/null @@ -1,20 +0,0 @@ --# Renders the graph node with both the status icon, status name and action icon - -- subject = local_assigns.fetch(:subject) -- status = subject.detailed_status(current_user) -- klass = "ci-status-icon ci-status-icon-#{status.group} js-ci-status-icon-#{status.group}" -- tooltip = "#{subject.name} - #{status.label}" - -- if status.has_details? - = link_to status.details_path, class: 'build-content has-tooltip', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do - %span{ class: klass }= custom_icon(status.icon) - .ci-status-text= subject.name -- else - .build-content.has-tooltip{ data: { toggle: 'tooltip', title: tooltip } } - %span{ class: klass }= custom_icon(status.icon) - .ci-status-text= subject.name - -- if status.has_action? - = link_to status.action_path, class: 'ci-action-icon-container has-tooltip', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do - %i.ci-action-icon-wrapper{ class: "js-#{status.action_icon.dasherize}" } - = custom_icon(status.action_icon) diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index 89d991abe54..e1b270a08c2 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -1,7 +1,7 @@ .hidden-xs = render "events/event_last_push", event: @last_push -.nav-block +.nav-block.activities .controls = link_to dashboard_projects_path(rss_url_options), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do %i.fa.fa-rss diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index 13eaba41f4c..4594c52b34b 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -2,13 +2,13 @@ %ul.nav-links = nav_link(page: dashboard_groups_path) do = link_to dashboard_groups_path, title: 'Your groups' do - Your Groups + Your groups = nav_link(page: explore_groups_path) do - = link_to explore_groups_path, title: 'Explore groups' do - Explore Groups + = link_to explore_groups_path, title: 'Explore public groups' do + Explore public groups .nav-controls = render 'shared/groups/search_form' = render 'shared/groups/dropdown' - if current_user.can_create_group? = link_to new_group_path, class: "btn btn-new" do - New Group + New group diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 4679b9549d1..64b737ee886 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -19,4 +19,4 @@ = render 'shared/projects/dropdown' - if current_user.can_create_project? = link_to new_project_path, class: 'btn btn-new' do - New Project + New project diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index 10867140d4f..faa68468043 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -8,7 +8,7 @@ .nav-controls = link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do = icon('rss') - = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue" + = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue" = render 'shared/issuable/filter', type: :issues = render 'shared/issues' diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index e64c78c4cb8..12966c01950 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -4,7 +4,7 @@ .top-area = render 'shared/issuable/nav', type: :merge_requests .nav-controls - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New Merge Request" + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request" = render 'shared/issuable/filter', type: :merge_requests = render 'shared/merge_requests' diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index 505b475f55b..664ec618b79 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -5,7 +5,7 @@ = render 'shared/milestones_filter', counts: @milestone_states .nav-controls - = render 'shared/new_project_item_select', path: 'milestones/new', label: 'New Milestone', include_groups: true + = render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true .milestones %ul.content-list diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index 1bbd4602ecf..8843d4e7c84 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -1,4 +1,4 @@ -- publicish_project_count = ProjectsFinder.new.execute(current_user).count +- publicish_project_count = ProjectsFinder.new(current_user: current_user).execute.count .blank-state.blank-state-welcome %h2.blank-state-welcome-title Welcome to GitLab diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index d0c12aa57ae..38fd053ae65 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -9,7 +9,7 @@ .title-item.author-name - if todo.author - = link_to_author(todo) + = link_to_author(todo, self_added: todo.self_added?) - else (removed) @@ -22,6 +22,10 @@ - else (removed) + - if todo.self_assigned? + .title-item.action-name + to yourself + .title-item · diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml index 5e189e6dc54..eb0e6701627 100644 --- a/app/views/devise/passwords/edit.html.haml +++ b/app/views/devise/passwords/edit.html.haml @@ -6,10 +6,10 @@ = devise_error_messages! = f.hidden_field :reset_password_token .form-group - = f.label 'New password', for: :password + = f.label 'New password', for: "user_password" = f.password_field :password, class: "form-control top", required: true, title: 'This field is required' .form-group - = f.label 'Confirm new password', for: :password_confirmation + = f.label 'Confirm new password', for: "user_password_confirmation" = f.password_field :password_confirmation, class: "form-control bottom", title: 'This field is required', required: true .clearfix = f.submit "Change your password", class: "btn btn-primary" diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 21c751a23f8..4095f30c369 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,6 +1,6 @@ = form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f| .form-group - = f.label "Username or email", for: :login + = f.label "Username or email", for: "user_login" = f.text_field :login, class: "form-control top", autofocus: "autofocus", autocapitalize: "off", autocorrect: "off", required: true, title: "This field is required." .form-group = f.label :password diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml index ee452add394..e6d307e5568 100644 --- a/app/views/discussions/_diff_discussion.html.haml +++ b/app/views/discussions/_diff_discussion.html.haml @@ -3,4 +3,4 @@ %td.notes_line{ colspan: 2 } %td.notes_content .content{ class: ('hide' unless expanded) } - = render "discussions/notes", discussion: discussion + = render partial: "discussions/notes", collection: discussions, as: :discussion diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 94408b92374..c3f55ff821f 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -3,11 +3,11 @@ .diff-file.file-holder .js-file-title.file-title - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_diff_path(discussion) + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion), show_toggle: false .diff-content.code.js-syntax-highlight %table - - discussions = { discussion.original_line_code => discussion } + - discussions = { discussion.original_line_code => [discussion] } = render partial: "projects/diffs/line", collection: discussion.truncated_diff_lines, as: :line, diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 2d78c55211e..74992e439f3 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -5,7 +5,7 @@ = link_to user_path(discussion.author) do = image_tag avatar_icon(discussion.author), class: "avatar s40" .timeline-content - .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } } + .discussion.js-toggle-container{ data: { discussion_id: discussion.id } } .discussion-header .discussion-actions %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" } @@ -18,21 +18,24 @@ .inline.discussion-headline-light = discussion.author.to_reference - started a discussion on + started a discussion - - if discussion.for_commit? + - url = discussion_path(discussion) + - if discussion.for_commit? && @noteable != discussion.noteable + on - commit = discussion.noteable - if commit commit - = link_to commit.short_id, namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code), class: 'monospace' + = link_to commit.short_id, url, class: 'commit-sha' - else a deleted commit - - else - - if discussion.active? - = link_to diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) do + - elsif discussion.diff_discussion? + on + = conditional_link_to url.present?, url do + - if discussion.active? the diff - - else - an outdated diff + - else + an outdated diff = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") = render "discussions/headline", discussion: discussion diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 2789391819c..7ba3f3f6c42 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,18 +1,21 @@ -%ul.notes{ data: { discussion_id: discussion.id } } - = render partial: "projects/notes/note", collection: discussion.notes, as: :note +.discussion-notes + %ul.notes{ data: { discussion_id: discussion.id } } + = render partial: "shared/notes/note", collection: discussion.notes, as: :note + .flash-container -- if current_user - .discussion-reply-holder - - if discussion.diff_discussion? - - line_type = local_assigns.fetch(:line_type, nil) + - if current_user + .discussion-reply-holder + - if discussion.potentially_resolvable? + - line_type = local_assigns.fetch(:line_type, nil) + + .btn-group-justified.discussion-with-resolve-btn{ role: "group" } + .btn-group{ role: "group" } + = link_to_reply_discussion(discussion, line_type) + + = render "discussions/resolve_all", discussion: discussion - .btn-group-justified.discussion-with-resolve-btn{ role: "group" } - .btn-group{ role: "group" } - = link_to_reply_discussion(discussion, line_type) - = render "discussions/resolve_all", discussion: discussion - - if discussion.for_merge_request? .btn-group.discussion-actions = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable = render "discussions/jump_to_next", discussion: discussion - - else - = link_to_reply_discussion(discussion) + - else + = link_to_reply_discussion(discussion) diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml index 3a19e021643..253cd336882 100644 --- a/app/views/discussions/_parallel_diff_discussion.html.haml +++ b/app/views/discussions/_parallel_diff_discussion.html.haml @@ -1,20 +1,20 @@ -- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?) +- expanded = [*discussions_left, *discussions_right].any?(&:expanded?) %tr.notes_holder{ class: ('hide' unless expanded) } - - if discussion_left + - if discussions_left %td.notes_line.old %td.notes_content.parallel.old - .content{ class: ('hide' unless discussion_left.expanded?) } - = render "discussions/notes", discussion: discussion_left, line_type: 'old' + .content{ class: ('hide' unless discussions_left.any?(&:expanded?)) } + = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old' - else %td.notes_line.old= ("") %td.notes_content.parallel.old .content - - if discussion_right + - if discussions_right %td.notes_line.new %td.notes_content.parallel.new - .content{ class: ('hide' unless discussion_right.expanded?) } - = render "discussions/notes", discussion: discussion_right, line_type: 'new' + .content{ class: ('hide' unless discussions_right.any?(&:expanded?)) } + = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new' - else %td.notes_line.new= ("") %td.notes_content.parallel.new diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml index e30ee1b0e05..689a22acd27 100644 --- a/app/views/discussions/_resolve_all.html.haml +++ b/app/views/discussions/_resolve_all.html.haml @@ -1,9 +1,8 @@ -- if discussion.for_merge_request? - %resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", - ":merge-request-id" => discussion.noteable.iid, - ":can-resolve" => discussion.can_resolve?(current_user), - "inline-template" => true } - .btn-group{ role: "group", "v-if" => "showButton" } - %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" } - = icon("spinner spin", "v-show" => "loading") - {{ buttonText }} +%resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", + ":merge-request-id" => discussion.noteable.iid, + ":can-resolve" => discussion.can_resolve?(current_user), + "inline-template" => true } + .btn-group{ role: "group", "v-if" => "showButton" } + %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" } + = icon("spinner spin", "v-show" => "loading") + {{ buttonText }} diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml index 72508b91134..20b7fa471a0 100644 --- a/app/views/errors/omniauth_error.html.haml +++ b/app/views/errors/omniauth_error.html.haml @@ -1,16 +1,15 @@ - content_for(:title, 'Auth Error') -%img{ :alt => "GitLab Logo", :src => image_path('logo.svg') } - %h1 - 422 + .container + = render "shared/errors/graphic_422.svg" %h3 Sign-in using #{@provider} auth failed - %hr - %p Sign-in failed because #{@error}. - %p There are couple of steps you can take: -%ul - %li Try logging in using your email - %li Try logging in using your username - %li If you have forgotten your password, try recovering it using #{ link_to "Password recovery", new_password_path(resource_name) } + %p.light.subtitle Sign-in failed because #{@error}. + + %p Try logging in using your username or email. If you have forgotten your password, try recovering it -%p If none of the options work, try contacting the GitLab administrator. + = link_to "Sign in", new_session_path(:user), class: 'btn primary' + = link_to "Recover password", new_password_path(resource_name), class: 'btn secondary' + + %hr + %p.light If none of the options work, try contacting a GitLab administrator. diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml index 1bc9f604438..3c64f1be5ff 100644 --- a/app/views/events/_commit.html.haml +++ b/app/views/events/_commit.html.haml @@ -1,5 +1,5 @@ %li.commit .commit-row-title - = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id]) + = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit-sha", alt: '', title: truncate_sha(commit[:id]) · = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line, author: event.author diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder index 158061579f6..e2aec532a9d 100644 --- a/app/views/events/_event.atom.builder +++ b/app/views/events/_event.atom.builder @@ -8,6 +8,7 @@ xml.entry do xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author_email)) xml.author do + xml.username event.author_username xml.name event.author_name xml.email event.author_public_email end diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index a0bd14df209..53a33adc14d 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -3,8 +3,6 @@ .event-item-timestamp #{time_ago_with_tooltip(event.created_at)} - = author_avatar(event, size: 40) - - if event.created_project? = render "events/event/created_project", event: event - elsif event.push? diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml index a1a282178e7..1584695a62b 100644 --- a/app/views/events/_event_last_push.html.haml +++ b/app/views/events/_event_last_push.html.haml @@ -10,5 +10,5 @@ #{time_ago_with_tooltip(event.created_at)} .pull-right - = link_to new_mr_path_from_push_event(event), title: "New Merge Request", class: "btn btn-info btn-sm" do - Create Merge Request + = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do + Create merge request diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 2fb6b5647da..01e72862114 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -1,3 +1,5 @@ += icon_for_profile_event(event) + .event-title %span.author_name= link_to_author event %span{ class: event.action_name } diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index 80cf2344fe1..d8e59be57bb 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -1,3 +1,5 @@ += icon_for_profile_event(event) + .event-title %span.author_name= link_to_author event %span{ class: event.action_name } diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index 64b5a733b77..df4b9562215 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -1,3 +1,5 @@ += icon_for_profile_event(event) + .event-title %span.author_name= link_to_author event = event.action_name diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index efd13aabf20..c0943100ae3 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -1,5 +1,7 @@ - project = event.project += icon_for_profile_event(event) + .event-title %span.author_name= link_to_author event %span.pushed #{event.action_name} #{event.ref_type} @@ -48,4 +50,3 @@ .event-body %ul.well-list.event_commits = render "events/commit", commit: last_commit, project: project, event: event - diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index bb2cd0d44c8..ffe07b217a7 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -7,6 +7,15 @@ = render 'explore/head' = render 'nav' +- if cookies[:explore_groups_landing_dismissed] != 'true' + .explore-groups.landing.content-block.js-explore-groups-landing.hidden + %button.dismiss-button{ type: 'button', 'aria-label' => 'Dismiss' }= icon('times') + .svg-container + = custom_icon('icon_explore_groups_splash') + .inner-content + %p Below you will find all the groups that are public. + %p You can easily contribute to them by requesting to join these groups. + - if @groups.present? = render 'groups' - else diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml new file mode 100644 index 00000000000..2ace1e2dd1e --- /dev/null +++ b/app/views/groups/_group_admin_settings.html.haml @@ -0,0 +1,28 @@ +- if current_user.admin? + .form-group + = f.label :lfs_enabled, 'Large File Storage', class: 'control-label' + .col-sm-10 + .checkbox + = f.label :lfs_enabled do + = f.check_box :lfs_enabled, checked: @group.lfs_enabled? + %strong + Allow projects within this group to use Git LFS + = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') + %br/ + %span.descr This setting can be overridden in each project. + +- if can? current_user, :admin_group, @group + .form-group + = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2' + .col-sm-10 + .checkbox + = f.label :require_two_factor_authentication do + = f.check_box :require_two_factor_authentication + %strong + Require all users in this group to setup Two-factor authentication + = link_to icon('question-circle'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group') + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.text_field :two_factor_grace_period, class: 'form-control' + .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication diff --git a/app/views/groups/_group_lfs_settings.html.haml b/app/views/groups/_group_lfs_settings.html.haml deleted file mode 100644 index 3c622ca5c3c..00000000000 --- a/app/views/groups/_group_lfs_settings.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- if current_user.admin? - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :lfs_enabled do - = f.check_box :lfs_enabled, checked: @group.lfs_enabled? - %strong - Allow projects within this group to use Git LFS - = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs') - %br/ - %span.descr This setting can be overridden in each project. diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 80a77dab97f..7d5add3cc1c 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -27,7 +27,7 @@ .col-sm-offset-2.col-sm-10 = render 'shared/allow_request_access', form: f - = render 'group_lfs_settings', f: f + = render 'group_admin_settings', f: f .form-group %hr @@ -51,4 +51,4 @@ %strong Removed group can not be restored! .form-actions - = link_to 'Remove Group', @group, data: {confirm: 'Removed group can not be restored! Are you sure?'}, method: :delete, class: "btn btn-remove" + = link_to 'Remove group', @group, data: {confirm: 'Removed group can not be restored! Are you sure?'}, method: :delete, class: "btn btn-remove" diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index f4c17dc2d16..182dbe2f98a 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -11,7 +11,7 @@ = icon('rss') %span.icon-label Subscribe - = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue" + = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue" = render 'shared/issuable/filter', type: :issues diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 6ad76d23df5..8fe0bd149f3 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -1,18 +1,22 @@ - page_title "Merge Requests" -.top-area - = render 'shared/issuable/nav', type: :merge_requests - - if current_user - .nav-controls - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New Merge Request" +- if @group_merge_requests.empty? + = render 'shared/empty_states/merge_requests', project_select_button: true +- else + .top-area + = render 'shared/issuable/nav', type: :merge_requests + - if current_user + .nav-controls + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request" -= render 'shared/issuable/filter', type: :merge_requests + = render 'shared/issuable/filter', type: :merge_requests -.row-content-block.second-block - Only merge requests from - %strong= @group.name - group are listed here. - - if current_user - To see all merge requests you should visit #{link_to 'dashboard', merge_requests_dashboard_path} page. + .row-content-block.second-block + Only merge requests from + %strong= @group.name + group are listed here. + - if current_user + To see all merge requests you should visit #{link_to 'dashboard', merge_requests_dashboard_path} page. -= render 'shared/merge_requests' + .prepend-top-default + = render 'shared/merge_requests' diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index 6893168f039..f91bee0b610 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -7,7 +7,7 @@ .nav-controls - if can?(current_user, :admin_milestones, @group) = link_to new_group_milestone_path(@group), class: "btn btn-new" do - New Milestone + New milestone .row-content-block Only milestones from diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index 63cadfca530..7c7573862d0 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -26,7 +26,7 @@ .form-group.milestone-description = f.label :description, "Description", class: "control-label" .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: '' } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' .clearfix .error-alert @@ -39,5 +39,5 @@ = render "shared/milestones/form_dates", f: f .form-actions - = f.submit 'Create Milestone', class: "btn-create btn" + = f.submit 'Create milestone', class: "btn-create btn" = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel" diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index 8e83b2002b2..33e68bc766e 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -1,8 +1,4 @@ = render "header_title" - -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? - = render 'shared/milestones/top', milestone: @milestone, group: @group = render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true = render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102 diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 83bdd654f27..62ad47972b9 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -7,7 +7,7 @@ - if can? current_user, :admin_group, @group .controls = link_to new_project_path(namespace_id: @group.id), class: "btn btn-sm btn-success" do - New Project + New project %ul.well-list - @projects.each do |project| %li diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml index be809083139..8f0724c0677 100644 --- a/app/views/groups/subgroups.html.haml +++ b/app/views/groups/subgroups.html.haml @@ -9,7 +9,7 @@ .nav-controls = form_tag request.path, method: :get do |f| = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false - - if can? current_user, :admin_group, @group + - if can?(current_user, :create_subgroup, @group) = link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do New Subgroup diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 8e6da3fad90..ea8bbe92d86 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -17,6 +17,10 @@ %th Global Shortcuts %tr %td.shortcut + .key n + %td Main Navigation + %tr + %td.shortcut .key s %td Focus Search %tr @@ -39,24 +43,46 @@ .key %i.fa.fa-arrow-up %td Edit last comment (when focused on an empty textarea) - %tbody %tr - %th - %th Project Files browsing + %td.shortcut + .key shift t + %td + Go to todos %tr %td.shortcut - .key - %i.fa.fa-arrow-up - %td Move selection up + .key shift a + %td + Go to the activity feed %tr %td.shortcut - .key - %i.fa.fa-arrow-down - %td Move selection down + .key shift p + %td + Go to projects %tr %td.shortcut - .key enter - %td Open Selection + .key shift i + %td + Go to issues + %tr + %td.shortcut + .key shift m + %td + Go to merge requests + %tr + %td.shortcut + .key shift g + %td + Go to groups + %tr + %td.shortcut + .key shift l + %td + Go to milestones + %tr + %td.shortcut + .key shift s + %td + Go to snippets %tbody %tr %th @@ -79,51 +105,8 @@ %td.shortcut .key esc %td Go back - %tbody - %tr - %th - %th Project File - %tr - %td.shortcut - .key y - %td Go to file permalink - .col-lg-4 %table.shortcut-mappings - %tbody.hidden-shortcut.project{ style: 'display:none' } - %tr - %th - %th Global Dashboard - %tr - %td.shortcut - .key g - .key a - %td - Go to the activity feed - %tr - %td.shortcut - .key g - .key p - %td - Go to projects - %tr - %td.shortcut - .key g - .key i - %td - Go to issues - %tr - %td.shortcut - .key g - .key m - %td - Go to merge requests - %tr - %td.shortcut - .key g - .key t - %td - Go to todos %tbody %tr %th @@ -155,7 +138,7 @@ %tr %td.shortcut .key g - .key b + .key j %td Go to jobs %tr @@ -167,7 +150,7 @@ %tr %td.shortcut .key g - .key g + .key d %td Go to repository charts %tr @@ -179,7 +162,7 @@ %tr %td.shortcut .key g - .key l + .key b %td Go to issue boards %tr @@ -196,12 +179,45 @@ Go to snippets %tr %td.shortcut + .key g + .key w + %td + Go to wiki + %tr + %td.shortcut .key t %td Go to finding file %tr %td.shortcut .key i %td New issue + + %tbody + %tr + %th + %th Project Files browsing + %tr + %td.shortcut + .key + %i.fa.fa-arrow-up + %td Move selection up + %tr + %td.shortcut + .key + %i.fa.fa-arrow-down + %td Move selection down + %tr + %td.shortcut + .key enter + %td Open Selection + %tbody + %tr + %th + %th Project File + %tr + %td.shortcut + .key y + %td Go to file permalink .col-lg-4 %table.shortcut-mappings %tbody.hidden-shortcut.network{ style: 'display:none' } @@ -302,3 +318,11 @@ %td.shortcut .key l %td Change Label + %tbody.hidden-shortcut.wiki{ style: 'display:none' } + %tr + %th + %th Wiki pages + %tr + %td.shortcut + .key e + %td Edit wiki page diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index f93b6b63426..b20e3a22133 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -27,8 +27,7 @@ .row .col-md-8 .documentation-index - = preserve do - = markdown(@help_index) + = markdown(@help_index) .col-md-4 .panel.panel-default .panel-heading diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 1fb2c6271ad..615dd56afbd 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -225,7 +225,7 @@ %ul.dropdown-menu %li %a{ href: "#" } - Dropdown Option + Dropdown option .dropdown.inline.pull-right %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } Dropdown @@ -233,7 +233,7 @@ %ul.dropdown-menu.dropdown-menu-align-right %li %a{ href: "#" } - Dropdown Option + Dropdown option .example %div .dropdown.inline @@ -243,7 +243,7 @@ %ul.dropdown-menu.dropdown-menu-selectable %li %a.is-active{ href: "#" } - Dropdown Option + Dropdown option .example %div .dropdown.inline @@ -252,7 +252,7 @@ = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable .dropdown-title - %span Dropdown Title + %span Dropdown title %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } = icon('times') .dropdown-input @@ -262,26 +262,26 @@ %ul %li %a.is-active{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option %li.divider %li %a{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option .dropdown-footer %strong Tip: If an author is not a member of this project, you can still filter by his name while using the search field. @@ -291,7 +291,7 @@ = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable.is-loading .dropdown-title - %span Dropdown Title + %span Dropdown title %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } = icon('times') .dropdown-input @@ -301,26 +301,26 @@ %ul %li %a.is-active{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option %li.divider %li %a{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option %li %a{ href: "#" } - Dropdown Option + Dropdown option .dropdown-footer %strong Tip: If an author is not a member of this project, you can still filter by his name while using the search field. @@ -335,7 +335,7 @@ = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user .dropdown-title - %span Dropdown Title + %span Dropdown title %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } = icon('times') .dropdown-input @@ -362,7 +362,7 @@ .dropdown-title %button.dropdown-title-button.dropdown-menu-back{ aria: { label: "Go back" } } = icon('arrow-left') - %span Dropdown Title + %span Dropdown title %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } = icon('times') .dropdown-input diff --git a/app/views/import/base/create.js.haml b/app/views/import/base/create.js.haml index 8e929538351..57e8c3ca1e1 100644 --- a/app/views/import/base/create.js.haml +++ b/app/views/import/base/create.js.haml @@ -10,4 +10,4 @@ - else :plain job = $("tr#repo_#{@repo_id}") - job.find(".import-actions").html("<i class='fa fa-exclamation-circle'></i> Error saving project: #{escape_javascript(@project.errors.full_messages.join(','))}") + job.find(".import-actions").html("<i class='fa fa-exclamation-circle'></i> Error saving project: #{escape_javascript(h(@project.errors.full_messages.join(',')))}") diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 9999a4362c6..c52a515226e 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -46,6 +46,3 @@ .form-actions = submit_tag 'Continue to the next step', class: 'btn btn-create' - -:javascript - new UsersSelect(); diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 4c6af0b7908..9c2da3a3eec 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -9,7 +9,7 @@ To import a GitHub project, you first need to authorize GitLab to access the list of your GitHub repositories: - = link_to 'List Your GitHub Repositories', status_import_github_path, class: 'btn btn-success' + = link_to 'List your GitHub repositories', status_import_github_path, class: 'btn btn-success' %hr @@ -28,7 +28,7 @@ = form_tag personal_access_token_import_github_path, method: :post, class: 'form-inline' do .form-group = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: "Personal Access Token", size: 40 - = submit_tag 'List Your GitHub Repositories', class: 'btn btn-success' + = submit_tag 'List your GitHub repositories', class: 'btn btn-success' - unless github_import_configured? %hr diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 23a88448055..2ed78bb3b65 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -23,10 +23,19 @@ xml.entry do end end - if issue.assignee + if issue.assignees.any? + xml.assignees do + issue.assignees.each do |assignee| + xml.assignee do + xml.name assignee.name + xml.email assignee.public_email + end + end + end + xml.assignee do - xml.name issue.assignee.name - xml.email issue.assignee_public_email + xml.name issue.assignees.first.name + xml.email issue.assignees.first.public_email end end end diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index f6d8bb08a64..9e354987401 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -23,14 +23,19 @@ %title= page_title(site_name) %meta{ name: "description", content: page_description } - = favicon_link_tag favicon + = favicon_link_tag favicon, id: 'favicon' = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" + = stylesheet_link_tag "test", media: "all" if Rails.env.test? - = javascript_include_tag(*webpack_asset_paths("runtime")) - = javascript_include_tag(*webpack_asset_paths("common")) - = javascript_include_tag(*webpack_asset_paths("main")) + = Gon::Base.render_data + + = webpack_bundle_tag "runtime" + = webpack_bundle_tag "common" + = webpack_bundle_tag "main" + = webpack_bundle_tag "raven" if current_application_settings.clientside_sentry_enabled + = webpack_bundle_tag "test" if Rails.env.test? - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index 769f6fb0151..6caaba240bb 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -3,6 +3,7 @@ - if project :javascript + gl.GfmAutoComplete = gl.GfmAutoComplete || {}; gl.GfmAutoComplete.dataSources = { members: "#{members_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}", issues: "#{issues_namespace_project_autocomplete_sources_path(project.namespace, project)}", @@ -11,5 +12,3 @@ milestones: "#{milestones_namespace_project_autocomplete_sources_path(project.namespace, project)}", commands: "#{commands_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}" }; - - gl.GfmAutoComplete.setup(); diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 0e64ebd71b8..b689991bb6d 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -13,7 +13,7 @@ .location-badge= label .search-input-wrap .dropdown{ data: { url: search_autocomplete_path } } - = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url } + = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { toggle: 'dropdown', issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }, aria: { label: 'Search' } .dropdown-menu.dropdown-select = dropdown_content do %ul diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 36543edc040..03688e9ff21 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,11 +1,9 @@ !!! 5 -%html{ lang: "en", class: "#{page_class}" } +%html{ lang: I18n.locale, class: "#{page_class}" } = render "layouts/head" %body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } } - = Gon::Base.render_data - + = render "layouts/init_auto_complete" if @gfm_form = render "layouts/header/default", title: header_title = render 'layouts/page', sidebar: sidebar, nav: nav = yield :scripts_body - = render "layouts/init_auto_complete" if @gfm_form diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 3368a9beb29..52fb46eb8c9 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -3,7 +3,6 @@ = render "layouts/head" %body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } } .page-wrap - = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 7466423a934..ed6731bde95 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -2,7 +2,6 @@ %html{ lang: "en" } = render "layouts/head" %body.ui_charcoal.login-page.application.navless - = Gon::Base.render_data = render "layouts/header/empty" = render "layouts/broadcast" .container.navless-container diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 43abd44d89f..9db98451f1d 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,4 +1,5 @@ %header.navbar.navbar-gitlab{ class: nav_header_class } + .navbar-border %a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content .container-fluid .header-content @@ -29,42 +30,49 @@ - if current_user - if session[:impersonator_id] %li.impersonation - = link_to admin_impersonation_path, method: :delete, title: "Stop Impersonation", aria: { label: 'Stop Impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = icon('user-secret fw') - - if current_user.is_admin? + - if current_user.admin? %li - = link_to admin_root_path, title: 'Admin Area', aria: { label: "Admin Area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('wrench fw') + - if current_user.can_create_project? + %li + = link_to new_project_path, title: 'New project', aria: { label: "New project" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = icon('plus fw') + - if Gitlab::Sherlock.enabled? + %li + = link_to sherlock_transactions_path, title: 'Sherlock Transactions', + data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = icon('tachometer fw') %li = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('hashtag fw') - %span.badge.issues-count - = number_with_delimiter(cached_assigned_issuables_count(current_user, :issues, :opened)) + - issues_count = assigned_issuables_count(:issues) + %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } + = number_with_delimiter(issues_count) %li = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('mr_bold') - %span.badge.merge-requests-count - = number_with_delimiter(cached_assigned_issuables_count(current_user, :merge_requests, :opened)) + - merge_requests_count = assigned_issuables_count(:merge_requests) + %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } + = number_with_delimiter(merge_requests_count) %li = link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('check-circle fw') - %span.badge.todos-count + %span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) } = todos_count_format(todos_pending_count) - - if current_user.can_create_project? - %li - = link_to new_project_path, title: 'New project', aria: { label: "New project" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = icon('plus fw') - - if Gitlab::Sherlock.enabled? - %li - = link_to sherlock_transactions_path, title: 'Sherlock Transactions', - data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = icon('tachometer fw') %li.header-user.dropdown = link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do = image_tag avatar_icon(current_user, 26), width: 26, height: 26, class: "header-user-avatar" = icon('caret-down') .dropdown-menu-nav.dropdown-menu-align-right %ul + %li.current-user + .user-name.bold + = current_user.name + @#{current_user.username} + %li.divider %li = link_to "Profile", current_user, class: 'profile-link', aria: { label: "Profile" }, data: { user: current_user.username } %li diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 00000000000..198f30a1dc4 --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1,4 @@ +<%= yield -%> + +--- +You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>. diff --git a/app/views/layouts/mailer.text.haml b/app/views/layouts/mailer.text.haml deleted file mode 100644 index 6a9c6ced9cc..00000000000 --- a/app/views/layouts/mailer.text.haml +++ /dev/null @@ -1,5 +0,0 @@ -= yield - -You're receiving this email because of your account on #{Gitlab.config.gitlab.host}. -Manage all notifications: #{profile_notifications_url} -Help: #{help_url} diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 15285ee32a3..ac222ad8c82 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,10 +1,18 @@ %ul = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "#{project_tab_class} home"}) do = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + P %span Projects = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + A %span Activity - if koding_enabled? @@ -13,25 +21,45 @@ %span Koding = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do - = link_to dashboard_groups_path, title: 'Groups' do + = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + G %span Groups = nav_link(controller: 'dashboard/milestones') do - = link_to dashboard_milestones_path, title: 'Milestones' do + = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + L %span Milestones = nav_link(path: 'dashboard#issues') do = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + I %span Issues - .badge= number_with_delimiter(cached_assigned_issuables_count(current_user, :issues, :opened)) + .badge= number_with_delimiter(assigned_issuables_count(:issues)) = nav_link(path: 'dashboard#merge_requests') do = link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + M %span Merge Requests - .badge= number_with_delimiter(cached_assigned_issuables_count(current_user, :merge_requests, :opened)) + .badge= number_with_delimiter(assigned_issuables_count(:merge_requests)) = nav_link(controller: 'dashboard/snippets') do - = link_to dashboard_snippets_path, title: 'Snippets' do + = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + S %span Snippets %li.divider diff --git a/app/views/layouts/nav/_explore.html.haml b/app/views/layouts/nav/_explore.html.haml index 3a1fcd00e9c..0cb367452f7 100644 --- a/app/views/layouts/nav/_explore.html.haml +++ b/app/views/layouts/nav/_explore.html.haml @@ -1,16 +1,29 @@ %ul = nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do - = link_to explore_root_path, title: 'Projects' do + = link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + P %span Projects = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do - = link_to explore_groups_path, title: 'Groups' do + = link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + G %span Groups = nav_link(controller: :snippets) do - = link_to explore_snippets_path, title: 'Snippets' do + = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do + .shortcut-mappings + .key + = icon('arrow-up', 'aria-label' => 'hidden') + S %span Snippets + %li.divider = nav_link(controller: :help) do = link_to help_path, title: 'Help' do %span diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index e06301bda14..ae1e1361f0f 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -48,6 +48,6 @@ %span Preferences = nav_link(path: 'profiles#audit_log') do - = link_to audit_log_profile_path, title: 'Audit Log' do + = link_to audit_log_profile_path, title: 'Authentication log' do %span - Audit Log + Authentication log diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 299dace3406..e4dfe0c8c08 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -11,19 +11,19 @@ Project - if project_nav_tab? :files - = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare repositories tags branches releases graphs network)) do + = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do = link_to project_files_path(@project), title: 'Repository', class: 'shortcuts-tree' do %span Repository - if project_nav_tab? :container_registry - = nav_link(controller: %w(container_registry)) do + = nav_link(controller: %w[projects/registry/repositories]) do = link_to project_container_registry_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do %span Registry - if project_nav_tab? :issues - = nav_link(controller: [:issues, :labels, :milestones, :boards]) do + = nav_link(controller: @project.default_issues_tracker? ? [:issues, :labels, :milestones, :boards] : :issues) do = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do %span Issues @@ -31,14 +31,14 @@ %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :merge_requests - = nav_link(controller: :merge_requests) do + = nav_link(controller: @project.default_issues_tracker? ? :merge_requests : [:merge_requests, :labels, :milestones]) do = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do %span Merge Requests - %span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) + %span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :environments]) do + = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines @@ -56,7 +56,7 @@ Snippets - if project_nav_tab? :settings - = nav_link(path: %w[projects#edit members#show integrations#show repository#show ci_cd#show pages#show]) do + = nav_link(path: %w[projects#edit members#show integrations#show services#edit repository#show ci_cd#show pages#show]) do = link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do %span Settings diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 76268c1b705..40bf45cece7 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -25,8 +25,8 @@ - if @labels_url adjust your #{link_to 'label subscriptions', @labels_url}. - else - - if @sent_notification_url - = link_to "unsubscribe", @sent_notification_url + - if @unsubscribe_url + = link_to "unsubscribe", @unsubscribe_url from this thread or adjust your notification settings. diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb new file mode 100644 index 00000000000..b4ce02eead8 --- /dev/null +++ b/app/views/layouts/notify.text.erb @@ -0,0 +1,12 @@ +<%= yield -%> + +--- +<% if @target_url -%> +<% if @reply_by_email -%> +<%= "Reply to this email directly or view it on GitLab: #{@target_url}" -%> +<% else -%> +<%= "View it on GitLab: #{@target_url}" -%> +<% end -%> +<% end -%> + +You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>. diff --git a/app/views/layouts/oauth_error.html.haml b/app/views/layouts/oauth_error.html.haml new file mode 100644 index 00000000000..34bcd2a8b3a --- /dev/null +++ b/app/views/layouts/oauth_error.html.haml @@ -0,0 +1,127 @@ +!!! 5 +%html{ lang: "en" } + %head + %meta{ :content => "width=device-width, initial-scale=1, maximum-scale=1", :name => "viewport" } + %title= yield(:title) + :css + body { + color: #666; + text-align: center; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + margin: auto; + font-size: 16px; + } + + .container { + margin: auto 20px; + } + + h3 { + color: #456; + font-size: 22px; + font-weight: bold; + margin-bottom: 6px; + } + + p { + max-width: 470px; + margin: 16px auto; + } + + .subtitle { + margin: 0 auto 20px; + } + + svg { + width: 280px; + height: 280px; + display: block; + margin: 40px auto; + } + + .tv-screen path { + animation: move-lines 1s linear infinite; + } + + + @keyframes move-lines { + 0% {transform: translateY(0)} + 50% {transform: translateY(-10px)} + 100% {transform: translateY(-20px)} + } + + .tv-screen path:nth-child(1) { + animation-delay: .2s + } + + .tv-screen path:nth-child(2) { + animation-delay: .4s + } + + .tv-screen path:nth-child(3) { + animation-delay: .6s + } + + .tv-screen path:nth-child(4) { + animation-delay: .8s + } + + .tv-screen path:nth-child(5) { + animation-delay: 2s + } + + .text-422 { + animation: flicker 1s infinite; + } + + @keyframes flicker { + 0% {opacity: 0.3;} + 10% {opacity: 1;} + 15% {opacity: .3;} + 20% {opacity: .5;} + 25% {opacity: 1;} + } + + .light { + color: #8D8D8D; + } + + hr { + max-width: 600px; + margin: 18px auto; + border: 0; + border-top: 1px solid #EEE; + } + + .btn { + padding: 8px 14px; + border-radius: 3px; + border: 1px solid; + display: inline-block; + text-decoration: none; + margin: 4px 8px; + font-size: 14px; + } + + .primary { + color: #fff; + background-color: #1aaa55; + border-color: #168f48; + } + + .primary:hover { + background-color: #168f48; + } + + .secondary { + color: #1aaa55; + background-color: #fff; + border-color: #1aaa55; + } + + .secondary:hover { + background-color: #f3fff8; + } + +%body + = yield diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index f5e7ea7710d..3f5b0c54e50 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -5,14 +5,9 @@ - content_for :project_javascripts do - project = @target_project || @project - - if @project_wiki && @page - - preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug) - - else - - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project) - if current_user :javascript - window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; - window.preview_markdown_path = "#{preview_markdown_path}"; + window.uploads_path = "#{namespace_project_uploads_path project.namespace,project}"; - content_for :header_content do .js-dropdown-menu-projects diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml index 02ca3ee7a28..98b75cea03f 100644 --- a/app/views/layouts/snippets.html.haml +++ b/app/views/layouts/snippets.html.haml @@ -1,3 +1,9 @@ - header_title "Snippets", snippets_path +- content_for :page_specific_javascripts do + - if @snippet&.persisted? && current_user + :javascript + window.uploads_path = "#{upload_path('personal_snippet', @snippet)}"; + window.preview_markdown_path = "#{preview_markdown_snippet_path(@snippet)}"; + = render template: "layouts/application" diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml new file mode 100644 index 00000000000..a80518f7986 --- /dev/null +++ b/app/views/notify/_note_email.html.haml @@ -0,0 +1,37 @@ +- discussion = @note.discussion if @note.part_of_discussion? +- if discussion + %p.details + = succeed ':' do + = link_to @note.author_name, user_url(@note.author) + + - if discussion.diff_discussion? + - if discussion.new_discussion? + started a new discussion + - else + commented on a discussion + + on #{link_to discussion.file_path, @target_url} + - else + - if discussion.new_discussion? + started a new discussion + - else + commented on a #{link_to 'discussion', @target_url} + +- elsif current_application_settings.email_author_in_body + %p.details + #{link_to @note.author_name, user_url(@note.author)} commented: + +- if discussion&.diff_discussion? + = content_for :head do + = stylesheet_link_tag 'mailers/highlighted_diff_email' + + %table + = render partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: discussion.diff_file, + plain: true, + email: true } + +%div + = markdown(@note.note, pipeline: :email, author: @note.author) diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb new file mode 100644 index 00000000000..cb2e7fab6d5 --- /dev/null +++ b/app/views/notify/_note_email.text.erb @@ -0,0 +1,26 @@ +<% discussion = @note.discussion if @note.part_of_discussion? -%> +<% if discussion && !discussion.individual_note? -%> +<%= @note.author_name -%> +<% if discussion.new_discussion? -%> +<%= " started a new discussion" -%> +<% else -%> +<%= " commented on a discussion" -%> +<% end -%> +<% if discussion.diff_discussion? -%> +<%= " on #{discussion.file_path}" -%> +<% end -%> +<%= ":" -%> + + +<% elsif current_application_settings.email_author_in_body -%> +<%= "#{@note.author_name} commented:" -%> + + +<% end -%> +<% if discussion&.diff_discussion? -%> +<% discussion.truncated_diff_lines(highlight: false).each do |line| -%> +<%= "> #{line.text}\n" -%> +<% end -%> + +<% end -%> +<%= @note.note -%> diff --git a/app/views/notify/_note_message.html.haml b/app/views/notify/_note_message.html.haml deleted file mode 100644 index e9c66170877..00000000000 --- a/app/views/notify/_note_message.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -- if current_application_settings.email_author_in_body - %div - #{link_to @note.author_name, user_url(@note.author)} wrote: -%div - = markdown(@note.note, pipeline: :email, author: @note.author) diff --git a/app/views/notify/_note_message.text.erb b/app/views/notify/_note_message.text.erb deleted file mode 100644 index f82cbc9a3fc..00000000000 --- a/app/views/notify/_note_message.text.erb +++ /dev/null @@ -1,5 +0,0 @@ -<% if current_application_settings.email_author_in_body %> - <%= @note.author_name %> wrote: -<% end -%> - -<%= @note.note %> diff --git a/app/views/notify/_note_mr_or_commit_email.html.haml b/app/views/notify/_note_mr_or_commit_email.html.haml deleted file mode 100644 index edf8dfe7e9e..00000000000 --- a/app/views/notify/_note_mr_or_commit_email.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -= content_for :head do - = stylesheet_link_tag 'mailers/highlighted_diff_email' - -New comment - -- if @discussion && @discussion.diff_file - on - = link_to @note.diff_file.file_path, @target_url, class: 'details' - \: - %table - = render partial: "projects/diffs/line", - collection: @discussion.truncated_diff_lines, - as: :line, - locals: { diff_file: @note.diff_file, - plain: true, - email: true } - -= render 'note_message' diff --git a/app/views/notify/_note_mr_or_commit_email.text.erb b/app/views/notify/_note_mr_or_commit_email.text.erb deleted file mode 100644 index b4fcdf6b1e9..00000000000 --- a/app/views/notify/_note_mr_or_commit_email.text.erb +++ /dev/null @@ -1,8 +0,0 @@ -<% if @discussion && @discussion.diff_file -%> - on <%= @note.diff_file.file_path -%> -<% end -%>: - -<%= url %> - -<%= render 'simple_diff' if @discussion -%> -<%= render 'note_message' %> diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml deleted file mode 100644 index fd35713f79c..00000000000 --- a/app/views/notify/_reassigned_issuable_email.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%p - Assignee changed - - if @previous_assignee - from - %strong= @previous_assignee.name - to - - if issuable.assignee_id - %strong= issuable.assignee_name - - else - %strong Unassigned diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb deleted file mode 100644 index daf20a226dd..00000000000 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ /dev/null @@ -1,6 +0,0 @@ -Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> - -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> - -Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> - to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/_simple_diff.text.erb b/app/views/notify/_simple_diff.text.erb deleted file mode 100644 index c28d1cc34d3..00000000000 --- a/app/views/notify/_simple_diff.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% @discussion.truncated_diff_lines(highlight: false).each do |line| %> -> <%= line.text %> -<% end %> diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index d1855568215..eb5157ccac9 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -1,9 +1,11 @@ - if current_application_settings.email_author_in_body - %div - #{link_to @issue.author_name, user_url(@issue.author)} wrote: -- if @issue.description - = markdown(@issue.description, pipeline: :email, author: @issue.author) + %p.details + #{link_to @issue.author_name, user_url(@issue.author)} created an issue: -- if @issue.assignee_id.present? +- if @issue.assignees.any? %p - Assignee: #{@issue.assignee_name} + Assignee: #{@issue.assignee_list} + +- if @issue.description + %div + = markdown(@issue.description, pipeline: :email, author: @issue.author) diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index ca5c2f2688c..13f1ac08e94 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -2,6 +2,6 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml index 02f21baa368..6b45ac265f7 100644 --- a/app/views/notify/new_mention_in_issue_email.html.haml +++ b/app/views/notify/new_mention_in_issue_email.html.haml @@ -1,12 +1,4 @@ %p You have been mentioned in an issue. -- if current_application_settings.email_author_in_body - %div - #{link_to @issue.author_name, user_url(@issue.author)} wrote: -- if @issue.description - = markdown(@issue.description, pipeline: :email, author: @issue.author) - -- if @issue.assignee_id.present? - %p - Assignee: #{@issue.assignee_name} += render template: 'notify/new_issue_email' diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb index 457e94b4800..f19ac3adfc7 100644 --- a/app/views/notify/new_mention_in_issue_email.text.erb +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -2,6 +2,6 @@ You have been mentioned in an issue. Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %> Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_name %> +Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml index cbd434be02a..b061f9c106e 100644 --- a/app/views/notify/new_mention_in_merge_request_email.html.haml +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -1,15 +1,4 @@ %p You have been mentioned in Merge Request #{@merge_request.to_reference} -- if current_application_settings.email_author_in_body - %div - #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: -%p.details - != merge_path_description(@merge_request, '→') - -- if @merge_request.assignee_id.present? - %p - Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} - -- if @merge_request.description - = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) += render template: 'notify/new_merge_request_email' diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 8890b300f7d..951c96bdb9c 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -1,12 +1,14 @@ - if current_application_settings.email_author_in_body - %div - #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: + %p.details + #{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request: + %p.details != merge_path_description(@merge_request, '→') - if @merge_request.assignee_id.present? %p - Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} + Assignee: #{@merge_request.assignee_name} - if @merge_request.description - = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) + %div + = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) diff --git a/app/views/notify/note_commit_email.html.haml b/app/views/notify/note_commit_email.html.haml index 0a650e3b2ca..5e69f01a486 100644 --- a/app/views/notify/note_commit_email.html.haml +++ b/app/views/notify/note_commit_email.html.haml @@ -1,2 +1 @@ -%p.details - = render 'note_mr_or_commit_email' += render 'note_email' diff --git a/app/views/notify/note_commit_email.text.erb b/app/views/notify/note_commit_email.text.erb index 6aa085a172e..413d9e6e9ac 100644 --- a/app/views/notify/note_commit_email.text.erb +++ b/app/views/notify/note_commit_email.text.erb @@ -1,2 +1 @@ -New comment for Commit <%= @commit.short_id -%> -<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url } %> +<%= render 'note_email' %> diff --git a/app/views/notify/note_issue_email.html.haml b/app/views/notify/note_issue_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_issue_email.html.haml +++ b/app/views/notify/note_issue_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_issue_email.text.erb b/app/views/notify/note_issue_email.text.erb index e33cbcd70f2..413d9e6e9ac 100644 --- a/app/views/notify/note_issue_email.text.erb +++ b/app/views/notify/note_issue_email.text.erb @@ -1,9 +1 @@ -New comment for Issue <%= @issue.iid %> - -<%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> - +<%= render 'note_email' %> diff --git a/app/views/notify/note_merge_request_email.html.haml b/app/views/notify/note_merge_request_email.html.haml index 0a650e3b2ca..5e69f01a486 100644 --- a/app/views/notify/note_merge_request_email.html.haml +++ b/app/views/notify/note_merge_request_email.html.haml @@ -1,2 +1 @@ -%p.details - = render 'note_mr_or_commit_email' += render 'note_email' diff --git a/app/views/notify/note_merge_request_email.text.erb b/app/views/notify/note_merge_request_email.text.erb index 2ce64c494cf..413d9e6e9ac 100644 --- a/app/views/notify/note_merge_request_email.text.erb +++ b/app/views/notify/note_merge_request_email.text.erb @@ -1,2 +1 @@ -New comment for Merge Request <%= @merge_request.to_reference -%> -<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url }%> +<%= render 'note_email' %> diff --git a/app/views/notify/note_personal_snippet_email.html.haml b/app/views/notify/note_personal_snippet_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_personal_snippet_email.html.haml +++ b/app/views/notify/note_personal_snippet_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_personal_snippet_email.text.erb b/app/views/notify/note_personal_snippet_email.text.erb index b2a8809a23b..413d9e6e9ac 100644 --- a/app/views/notify/note_personal_snippet_email.text.erb +++ b/app/views/notify/note_personal_snippet_email.text.erb @@ -1,8 +1 @@ -New comment for Snippet <%= @snippet.id %> - -<%= url_for(snippet_url(@snippet, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> +<%= render 'note_email' %> diff --git a/app/views/notify/note_snippet_email.html.haml b/app/views/notify/note_snippet_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_snippet_email.html.haml +++ b/app/views/notify/note_snippet_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_snippet_email.text.erb b/app/views/notify/note_snippet_email.text.erb index 4d5a406f4b0..413d9e6e9ac 100644 --- a/app/views/notify/note_snippet_email.text.erb +++ b/app/views/notify/note_snippet_email.text.erb @@ -1,8 +1 @@ -New comment for Snippet <%= @snippet.id %> - -<%= url_for(namespace_project_snippet_url(@snippet.project.namespace, @snippet.project, @snippet, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> +<%= render 'note_email' %> diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml index 85a1aea3a61..a83faa839df 100644 --- a/app/views/notify/pipeline_failed_email.html.haml +++ b/app/views/notify/pipeline_failed_email.html.haml @@ -3,8 +3,8 @@ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" } - %img{ alt: "x", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red-inverted.gif'), style: "display:block;", width: "13" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;line-height:1;" } + %img{ alt: "✖", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red-inverted.gif'), style: "display:block;", width: "13" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } Your pipeline has failed. %tr.spacer @@ -16,7 +16,7 @@ %tbody %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;" } - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) %a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" } @@ -26,7 +26,7 @@ = @project.name %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody %tr @@ -37,7 +37,7 @@ = @pipeline.ref %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody %tr @@ -52,13 +52,13 @@ = @merge_request.to_reference .commit{ style: "color:#5c5c5c;font-weight:300;" } = @pipeline.git_commit_message.truncate(50) + - commit = @pipeline.commit %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit Author + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody %tr - - commit = @pipeline.commit %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } @@ -68,15 +68,48 @@ - else %span = commit.author_name + - if commit.different_committer? + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Committed by + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + - if commit.committer + %a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" } + = commit.committer.name + - else + %span + = commit.committer_name + %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } -- failed = @pipeline.statuses.latest.failed %tr.pre-section - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:400;line-height:1.4;padding:15px 0;" } - Pipeline - %a{ href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" } - = "\##{@pipeline.id}" + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:400;line-height:1.4;padding:15px 5px 0 5px;text-align:center;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" } + Pipeline + %a{ href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" } + = "\##{@pipeline.id}" + triggered by + - if @pipeline.user + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" } + %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" } + %a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" } + = @pipeline.user.name + - else + %td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" } + API +- failed = @pipeline.statuses.latest.failed +%tr + %td{ colspan: 2, style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:300;line-height:1.4;padding:15px 5px;text-align:center;" } had = failed.size failed @@ -94,8 +127,8 @@ %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#8c8c8c;font-weight:500;font-size:15px;vertical-align:middle;padding-right:5px;" } - %img{ alt: "x", height: "10", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red.gif'), style: "display:block;", width: "10" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#d22f57;font-weight:500;font-size:15px;vertical-align:middle;padding-right:5px;line-height:10px" } + %img{ alt: "✖", height: "10", src: image_url('mailers/ci_pipeline_notif_v1/icon-x-red.gif'), style: "display:block;", width: "10" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#8c8c8c;font-weight:500;font-size:15px;vertical-align:middle;" } = build.stage %td{ align: "right", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:20px 0;color:#8c8c8c;font-weight:500;font-size:15px;" } @@ -104,6 +137,6 @@ - if build.has_trace? %td{ colspan: "2", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 0 15px;" } %pre{ style: "font-family:Monaco,'Lucida Console','Courier New',Courier,monospace;background-color:#fafafa;border-radius:3px;overflow:hidden;white-space:pre-wrap;word-break:break-all;font-size:13px;line-height:1.4;padding:12px;color:#333333;margin:0;" } - = build.trace_html(last_lines: 10).html_safe + = build.trace.html(last_lines: 10).html_safe - else %td{ colspan: "2" } diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index 520a2fc7d68..294238eee51 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -14,16 +14,28 @@ Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> ) <% else -%> Commit Author: <%= commit.author_name %> <% end -%> +<% if commit.different_committer? -%> +<% if commit.committer -%> +Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> ) +<% else -%> +Committed by: <%= commit.committer_name %> +<% end -%> +<% end -%> +<% if @pipeline.user -%> +Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) +<% else -%> +Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API +<% end -%> <% failed = @pipeline.statuses.latest.failed -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. +had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. <% failed.each do |build| -%> <%= render "notify/links/#{build.to_partial_path}", pipeline: @pipeline, build: build %> Stage: <%= build.stage %> Name: <%= build.name %> <% if build.has_trace? -%> -Trace: <%= build.trace_with_state(last_lines: 10)[:text] %> +Trace: <%= build.trace.raw(last_lines: 10) %> <% end -%> <% end -%> diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml index 19d4add06f5..9c2e2a599b2 100644 --- a/app/views/notify/pipeline_success_email.html.haml +++ b/app/views/notify/pipeline_success_email.html.haml @@ -16,7 +16,7 @@ %tbody %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;" } - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) %a.muted{ href: namespace_url, style: "color:#333333;text-decoration:none;" } @@ -26,7 +26,7 @@ = @project.name %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody %tr @@ -37,7 +37,7 @@ = @pipeline.ref %tr %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody %tr @@ -52,13 +52,13 @@ = @merge_request.to_reference .commit{ style: "color:#5c5c5c;font-weight:300;" } = @pipeline.git_commit_message.truncate(50) + - commit = @pipeline.commit %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit Author + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody %tr - - commit = @pipeline.commit %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } %img.avatar{ height: "24", src: avatar_icon(commit.author || commit.author_email, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } @@ -68,17 +68,50 @@ - else %span = commit.author_name + - if commit.different_committer? + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Committed by + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:500;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;" } + %img.avatar{ height: "24", src: avatar_icon(commit.committer || commit.committer_email, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" } + - if commit.committer + %a.muted{ href: user_url(commit.committer), style: "color:#333333;text-decoration:none;" } + = commit.committer.name + - else + %span + = commit.committer_name + %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" } %tr.success-message - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:400;line-height:1.4;padding:15px 5px;text-align:center;" } - - build_count = @pipeline.statuses.latest.size + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:400;line-height:1.4;padding:15px 5px 0 5px;text-align:center;" } + %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;margin:0 auto;" } + %tbody + %tr + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" } + Pipeline + %a{ href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" } + = "\##{@pipeline.id}" + triggered by + - if @pipeline.user + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;padding-right:5px;padding-left:5px", width: "24" } + %img.avatar{ height: "24", src: avatar_icon(@pipeline.user, 24), style: "display:block;border-radius:12px;margin:-2px 0;", width: "24", alt: "Avatar" }/ + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;font-weight:500;line-height:1.4;vertical-align:baseline;" } + %a.muted{ href: user_url(@pipeline.user), style: "color:#333333;text-decoration:none;" } + = @pipeline.user.name + - else + %td{ style: "font-family:'Menlo','Liberation Mono','Consolas','DejaVu Sans Mono','Ubuntu Mono','Courier New','andale mono','lucida console',monospace;font-size:14px;line-height:1.4;vertical-align:baseline;padding:0 5px;" } + API +%tr + %td{ colspan: 2, style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;color:#333333;font-size:15px;font-weight:300;line-height:1.4;padding:15px 5px;text-align:center;" } + - job_count = @pipeline.statuses.latest.size - stage_count = @pipeline.stages_count - Pipeline - %a{ href: pipeline_url(@pipeline), style: "color:#3777b0;text-decoration:none;" } - = "\##{@pipeline.id}" successfully completed - #{build_count} #{'build'.pluralize(build_count)} + #{job_count} #{'job'.pluralize(job_count)} in #{stage_count} #{'stage'.pluralize(stage_count)}. diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb index 0970a3a4e09..ddced2279e1 100644 --- a/app/views/notify/pipeline_success_email.text.erb +++ b/app/views/notify/pipeline_success_email.text.erb @@ -14,7 +14,19 @@ Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> ) <% else -%> Commit Author: <%= commit.author_name %> <% end -%> +<% if commit.different_committer? -%> +<% if commit.committer -%> +Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> ) +<% else -%> +Committed by: <%= commit.committer_name %> +<% end -%> +<% end -%> <% build_count = @pipeline.statuses.latest.size -%> <% stage_count = @pipeline.stages_count -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) successfully completed <%= build_count %> <%= 'build'.pluralize(build_count) %> in <%= stage_count %> <%= 'stage'.pluralize(stage_count) %>. +<% if @pipeline.user -%> +Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) +<% else -%> +Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API +<% end -%> +successfully completed <%= build_count %> <%= 'build'.pluralize(build_count) %> in <%= stage_count %> <%= 'stage'.pluralize(stage_count) %>. diff --git a/app/views/notify/project_was_exported_email.html.haml b/app/views/notify/project_was_exported_email.html.haml index b28fea35ad5..3def26342a1 100644 --- a/app/views/notify/project_was_exported_email.html.haml +++ b/app/views/notify/project_was_exported_email.html.haml @@ -2,7 +2,7 @@ Project #{@project.name} was exported successfully. %p The project export can be downloaded from: - = link_to download_export_namespace_project_url(@project.namespace, @project) do + = link_to download_export_namespace_project_url(@project.namespace, @project), rel: 'nofollow', download: '' do = @project.name_with_namespace + " export" %p The download link will expire in 24 hours. diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index 498ba8b8365..ee2f40e1683 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -1 +1,10 @@ -= render 'reassigned_issuable_email', issuable: @issue +%p + Assignee changed + - if @previous_assignees.any? + from + %strong= @previous_assignees.map(&:name).to_sentence + to + - if @issue.assignees.any? + %strong= @issue.assignee_list + - else + %strong Unassigned diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index 710253be984..6c357f1074a 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @issue %> +Reassigned Issue <%= @issue.iid %> + +<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%> + to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 2a650130f59..24c2b08810b 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -1 +1,10 @@ -= render 'reassigned_issuable_email', issuable: @merge_request +%p + Assignee changed + - if @previous_assignee + from + %strong= @previous_assignee.name + to + - if @merge_request.assignee_id + %strong= @merge_request.assignee_name + - else + %strong Unassigned diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index b5b4f1ff99a..998a40fefde 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -1 +1,6 @@ -<%= render 'reassigned_issuable_email', issuable: @merge_request %> +Reassigned Merge Request <%= @merge_request.iid %> + +<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> + +Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> + to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index c6b1db17f91..02eb7c8462c 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -74,7 +74,7 @@ - else %hr - blob = diff_file.blob - - if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob) + - if blob && blob.readable_text? %table.code.white = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true } - else diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml index 879fc170f92..d0ad90ac6cc 100644 --- a/app/views/profiles/_event_table.html.haml +++ b/app/views/profiles/_event_table.html.haml @@ -9,7 +9,6 @@ Signed in with = event.details[:with] authentication - %span.pull-right - #{time_ago_in_words event.created_at} ago + %span.pull-right= time_ago_with_tooltip(event.created_at) = paginate events, theme: "gitlab" diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 8a994f6d600..73f33e69d68 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -49,14 +49,14 @@ %p Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'} - if current_user.two_factor_enabled? - = link_to 'Manage Two-Factor Authentication', profile_two_factor_auth_path, class: 'btn btn-info' + = link_to 'Manage two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-info' = link_to 'Disable', profile_two_factor_auth_path, method: :delete, data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." }, class: 'btn btn-danger' - else .append-bottom-10 - = link_to 'Enable Two-Factor Authentication', profile_two_factor_auth_path, class: 'btn btn-success' + = link_to 'Enable two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-success' %hr - if button_based_providers.any? @@ -75,12 +75,12 @@ .provider-btn-image = provider_image_tag(provider) - if auth_active?(provider) - - if provider.to_s == 'saml' - %a.provider-btn - Active - - else + - if unlink_allowed?(provider) = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do Disconnect + - else + %a.provider-btn + Active - else = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do Connect @@ -118,11 +118,7 @@ - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting an account has the following effects: - %ul - %li All user content like authored issues, snippets, comments will be removed - - rp = current_user.personal_projects.count - - unless rp.zero? - %li #{pluralize rp, 'personal project'} will be removed and cannot be restored + = render 'users/deletion_guidance', user: current_user = link_to 'Delete account', user_registration_path, data: { confirm: "REMOVE #{current_user.name}? Are you sure?" }, method: :delete, class: "btn btn-remove" - else - if @user.solo_owned_groups.present? diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index 9fe86e6b291..a24b7fd101d 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,4 +1,4 @@ -- page_title "Audit Log" +- page_title "Authentication log" = render 'profiles/head' .row.prepend-top-default diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index dc499be885b..f5a323dbaf8 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -33,17 +33,17 @@ %li = @primary %span.pull-right - %span.label.label-success Primary Email + %span.label.label-success Primary email - if @primary === current_user.public_email - %span.label.label-info Public Email + %span.label.label-info Public email - if @primary === current_user.notification_email - %span.label.label-info Notification Email + %span.label.label-info Notification email - @emails.each do |email| %li = email.email %span.pull-right - if email.email === current_user.public_email - %span.label.label-info Public Email + %span.label.label-info Public email - if email.email === current_user.notification_email - %span.label.label-info Notification Email + %span.label.label-info Notification email = link_to 'Remove', profile_email_path(email), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-warning prepend-left-10' diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 0645ecad496..c852107e69a 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -19,7 +19,7 @@ Your New Personal Access Token .form-group = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block" - = clipboard_button(clipboard_text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left") + = clipboard_button(text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left") %span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again. %hr diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index c74b3249a13..4a1438aa68e 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -73,6 +73,11 @@ = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), { include_blank: 'Do not show on profile' }, class: "select2" %span.help-block This email will be displayed on your public profile. .form-group + = f.label :preferred_language, class: "label-light" + = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, + {}, class: "select2" + %span.help-block This feature is experimental and translations are not complete yet. + .form-group = f.label :skype, class: "label-light" = f.text_field :skype, class: "form-control" .form-group diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 7ade5f00d47..0ff05098cd7 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -44,7 +44,7 @@ = label_tag :pin_code, nil, class: "label-light" = text_field_tag :pin_code, nil, class: "form-control", required: true .prepend-top-default - = submit_tag 'Register with Two-Factor App', class: 'btn btn-success' + = submit_tag 'Register with two-factor app', class: 'btn btn-success' %hr diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index aa0cb3e1a50..f5bb7364d4a 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -1,7 +1,7 @@ - @no_container = true %div{ class: container_class } - .nav-block.activity-filter-block + .nav-block.activity-filter-block.activities .controls = link_to namespace_project_path(@project.namespace, @project, rss_url_options), title: "Subscribe", class: 'btn rss-btn has-tooltip' do = icon('rss') diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index 640612ca433..b55dc3dce5c 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -1,5 +1,5 @@ .form-actions - = button_tag 'Commit Changes', class: 'btn commit-btn js-commit-button btn-create' + = button_tag 'Commit changes', class: 'btn commit-btn js-commit-button btn-create' = link_to 'Cancel', cancel_path, class: 'btn btn-cancel', data: {confirm: leave_edit_message} diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 96c2fa87f45..426085b3e1c 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,6 +1,14 @@ +- commit = local_assigns.fetch(:commit) { @repository.commit } +- ref = local_assigns.fetch(:ref) { current_ref } +- project = local_assigns.fetch(:project) { @project } #tree-holder.tree-holder.clearfix .nav-block = render 'projects/tree/tree_header', tree: @tree - = render 'projects/tree/tree_content', tree: @tree + - if commit + .info-well.hidden-xs.project-last-commit.append-bottom-default + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: commit, ref: ref, project: project + = render 'projects/tree/tree_content', tree: @tree diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml index dbb33090670..3feb11645a0 100644 --- a/app/views/projects/_find_file_link.html.haml +++ b/app/views/projects/_find_file_link.html.haml @@ -1,3 +1,3 @@ = link_to namespace_project_find_file_path(@project.namespace, @project, @ref), class: 'btn btn-grouped shortcuts-find-file', rel: 'nofollow' do = icon('search') - %span Find File + %span Find file diff --git a/app/views/projects/_fork_suggestion.html.haml b/app/views/projects/_fork_suggestion.html.haml new file mode 100644 index 00000000000..c855bfaf067 --- /dev/null +++ b/app/views/projects/_fork_suggestion.html.haml @@ -0,0 +1,11 @@ +- if current_user + .js-file-fork-suggestion-section.file-fork-suggestion.hidden + %span.file-fork-suggestion-note + You're not allowed to + %span.js-file-fork-suggestion-section-action + edit + files in this project directly. Please fork this project, + make your changes there, and submit a merge request. + = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button btn btn-grouped btn-inverted btn-new' + %button.js-cancel-fork-suggestion-button.btn.btn-grouped{ type: 'button' } + Cancel diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 79a0dc1b959..0fd19780570 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -1,6 +1,6 @@ - empty_repo = @project.empty_repo? .project-home-panel.text-center{ class: ("empty-project" if empty_repo) } - %div{ class: container_class } + .limit-container-width{ class: container_class } .avatar-container.s70.project-avatar = project_icon(@project, alt: @project.name, class: 'avatar s70 avatar-tile') %h1.project-title diff --git a/app/views/projects/_last_commit.html.haml b/app/views/projects/_last_commit.html.haml deleted file mode 100644 index e1fea8ccf3d..00000000000 --- a/app/views/projects/_last_commit.html.haml +++ /dev/null @@ -1,13 +0,0 @@ - -- ref = local_assigns.fetch(:ref) -- status = commit.status(ref) -- if status - = link_to pipelines_namespace_project_commit_path(commit.project.namespace, commit.project, commit), class: "ci-status ci-#{status}" do - = ci_icon_for_status(status) - = ci_label_for_status(status) - -= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" -= link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message" -· -#{time_ago_with_tooltip(commit.committed_date)} by -= commit_author_link(commit, avatar: true, size: 24) diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index a08436715d2..f8a6e98d280 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -5,14 +5,14 @@ .event-last-push .event-last-push-text %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name, class: 'commit-sha') do %strong= event.ref_name - if @project && event.project != @project %span at %strong= link_to_project event.project - = clipboard_button(clipboard_text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard') + = clipboard_button(text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard') #{time_ago_with_tooltip(event.created_at)} .pull-right - = link_to new_mr_path_from_push_event(event), title: "New Merge Request", class: "btn btn-info btn-sm" do - Create Merge Request + = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do + Create merge request diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 23e27c1105c..d0698285f84 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -1,3 +1,5 @@ +- referenced_users = local_assigns.fetch(:referenced_users, nil) + .md-area .md-header %ul.nav-links.clearfix @@ -28,9 +30,10 @@ .md-write-holder = yield - .md.md-preview-holder.js-md-preview.hide{ class: (preview_class if defined?(preview_class)) } + .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } } + .referenced-commands.hide - - if defined?(referenced_users) && referenced_users + - if referenced_users .referenced-users.hide %span = icon("exclamation-triangle") diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml deleted file mode 100644 index b6fb08b68e9..00000000000 --- a/app/views/projects/_readme.html.haml +++ /dev/null @@ -1,22 +0,0 @@ -- if readme = @repository.readme - %article.readme-holder - .pull-right - - if can?(current_user, :push_code, @project) - = link_to icon('pencil'), namespace_project_edit_blob_path(@project.namespace, @project, tree_join(@repository.root_ref, readme.name)), class: 'light edit-project-readme' - .file-content.wiki - = cache(readme_cache_key) do - = render_readme(readme) -- else - .row-content-block.second-block.center - %h3.page-title - This project does not have a README yet - - if can?(current_user, :push_code, @project) - %p - A - %code README - file contains information about other files in a repository and is commonly - distributed with computer software, forming part of its documentation. - %p - We recommend you to - = link_to "add a README", add_special_file_path(@project, file_name: 'README.md'), class: 'underlined-link' - file to the repository and GitLab will render it here instead of this message. diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml index 41d42740f61..2bab22e125d 100644 --- a/app/views/projects/_wiki.html.haml +++ b/app/views/projects/_wiki.html.haml @@ -2,8 +2,7 @@ %div{ class: container_class } .wiki-holder.prepend-top-default.append-bottom-default .wiki - = preserve do - = render_wiki_content(@wiki_home) + = render_wiki_content(@wiki_home) - else - can_create_wiki = can?(current_user, :create_wiki, @project) .project-home-empty{ class: [('row-content-block' if can_create_wiki), ('content-block' unless can_create_wiki)] } diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index 0c8241053e7..3b3d08ddd3c 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -1,10 +1,11 @@ - @gfm_form = true +- current_text ||= nil - supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) .zen-backdrop - classes << ' js-gfm-input js-autosize markdown-area' - if defined?(f) && f = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands } - else - = text_area_tag attr, nil, class: classes, placeholder: placeholder + = text_area_tag attr, current_text, class: classes, placeholder: placeholder %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } = icon('compress') diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml index 9e49c93388a..34d5c3b7285 100644 --- a/app/views/projects/artifacts/_tree_directory.html.haml +++ b/app/views/projects/artifacts/_tree_directory.html.haml @@ -3,6 +3,6 @@ %tr.tree-item{ 'data-link' => path_to_directory } %td.tree-item-file-name = tree_icon('folder', '755', directory.name) - %span.str-truncated - = link_to directory.name, path_to_directory + = link_to path_to_directory do + %span.str-truncated= directory.name %td diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index 36fb4c998c9..ce7e25d774b 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,9 +1,10 @@ - path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } + - blob = file.blob %td.tree-item-file-name - = tree_icon('file', '664', file.name) - %span.str-truncated - = link_to file.name, path_to_file + = tree_icon('file', blob.mode, blob.name) + = link_to path_to_file do + %span.str-truncated= blob.name %td - = number_to_human_size(file.metadata[:size], precision: 2) + = number_to_human_size(blob.size, precision: 2) diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index edf55d59f28..9fbb30f7c7c 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,13 +1,23 @@ -- page_title 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' +- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" -.top-block.row-content-block.clearfix - .pull-right - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), - class: 'btn btn-default download' do - = icon('download') - Download artifacts archive += render "projects/builds/header", show_controls: false .tree-holder + .nav-block + .tree-controls + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), + rel: 'nofollow', download: '', class: 'btn btn-default download' do + = icon('download') + Download artifacts archive + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + .tree-content-holder %table.table.tree-table %thead diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml new file mode 100644 index 00000000000..d8da83b9a80 --- /dev/null +++ b/app/views/projects/artifacts/file.html.haml @@ -0,0 +1,33 @@ +- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" + += render "projects/builds/header", show_controls: false + +#tree-holder.tree-holder + .nav-block + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + %strong= title + - else + = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + + + %article.file-holder + - blob = @entry.blob + .js-file-title.file-title-flex-parent + = render 'projects/blob/header_content', blob: blob + + .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob + + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) + = open_raw_blob_button(blob) + + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 4ad77b6266d..a2ec3d44185 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -3,11 +3,11 @@ = render "projects/commits/head" %div{ class: container_class } - %h3.page-title Blame view - #blob-content-holder.tree-holder + = render "projects/blob/breadcrumb", blob: @blob, blame: true + .file-holder - = render "projects/blob/header", blob: @blob + = render "projects/blob/header", blob: @blob, blame: true .table-responsive.file-content.blame.code.js-syntax-highlight %table @@ -22,7 +22,7 @@ %strong = link_to_gfm truncate(commit.title, length: 35), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "cdark" .pull-right - = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "monospace" + = link_to commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit), class: "commit-sha" .light = commit_author_link(commit, avatar: false) diff --git a/app/views/projects/blob/_auxiliary_viewer.html.haml b/app/views/projects/blob/_auxiliary_viewer.html.haml new file mode 100644 index 00000000000..9749afdc580 --- /dev/null +++ b/app/views/projects/blob/_auxiliary_viewer.html.haml @@ -0,0 +1,5 @@ +- blob = local_assigns.fetch(:blob) +- auxiliary_viewer = blob.auxiliary_viewer +- if auxiliary_viewer && auxiliary_viewer.render_error.nil? && auxiliary_viewer.visible_to?(current_user) + .well-segment.blob-auxiliary-viewer + = render 'projects/blob/viewer', viewer: auxiliary_viewer diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 2b2ee6ed987..8bd336269ff 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -1,28 +1,13 @@ -.nav-block - .tree-ref-holder - = render 'shared/ref_switcher', destination: 'blob', path: @path += render "projects/blob/breadcrumb", blob: blob - %ul.breadcrumb.repo-breadcrumb - %li - = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do - = @project.path - - tree_breadcrumbs(@tree, 6) do |title, path| - %li - - if path - - if path.end_with?(@path) - = link_to namespace_project_blob_path(@project.namespace, @project, path) do - %strong - = truncate(title, length: 40) - - else - = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path) - - else - = link_to title, '#' +.info-well.hidden-xs + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: @last_commit, project: @project, ref: @ref -%ul.blob-commit-info.hidden-xs - - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) - = render blob_commit, project: @project, ref: @ref + = render "projects/blob/auxiliary_viewer", blob: blob #blob-content-holder.blob-content-holder %article.file-holder = render "projects/blob/header", blob: blob - = render blob.to_partial_path(@project), blob: blob + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml new file mode 100644 index 00000000000..3f58e8d232f --- /dev/null +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -0,0 +1,36 @@ +- blame = local_assigns.fetch(:blame, false) +.nav-block + .tree-controls + = render 'projects/find_file_link' + + .btn-group.prepend-left-10{ role: "group" }< + -# only show normal/blame view links for text files + - if blob.readable_text? + - if blame + = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), + class: 'btn' + - else + = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), + class: 'btn js-blob-blame-link' unless blob.empty? + + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), + class: 'btn' + + = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, + tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url' + + .tree-ref-holder + = render 'shared/ref_switcher', destination: 'blob', path: @path + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do + %strong= title + - else + = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml new file mode 100644 index 00000000000..7afbd85cd6d --- /dev/null +++ b/app/views/projects/blob/_content.html.haml @@ -0,0 +1,8 @@ +- simple_viewer = blob.simple_viewer +- rich_viewer = blob.rich_viewer +- rich_viewer_active = rich_viewer && params[:viewer] != 'simple' + += render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active + +- if rich_viewer + = render 'projects/blob/viewer', viewer: rich_viewer, hidden: !rich_viewer_active diff --git a/app/views/projects/blob/_download.html.haml b/app/views/projects/blob/_download.html.haml deleted file mode 100644 index 7908fcae3de..00000000000 --- a/app/views/projects/blob/_download.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -.file-content.blob_file.blob-no-preview - .center - = link_to namespace_project_raw_path(@project.namespace, @project, @id) do - %h1.light - %i.fa.fa-download - %h4 - Download (#{number_to_human_size blob_size(blob)}) diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index e7adef5558a..4b344b2edb9 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -1,29 +1,23 @@ +- action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create' + .file-holder.file.append-bottom-default - .js-file-title.file-title.clearfix + .js-file-title.file-title.clearfix{ data: { current_action: action } } .editor-ref = icon('code-fork') = ref %span.editor-file-name - if current_action?(:edit) || current_action?(:update) = text_field_tag 'file_path', (params[:file_path] || @path), - class: 'form-control new-file-path' + class: 'form-control new-file-path js-file-path-name-input' - if current_action?(:new) || current_action?(:create) %span.editor-file-name \/ = text_field_tag 'file_name', params[:file_name], placeholder: "File name", - required: true, class: 'form-control new-file-name' + required: true, class: 'form-control new-file-name js-file-path-name-input' .pull-right.file-buttons - .license-selector.js-license-selector-wrap.hidden - = dropdown_tag("Choose a License template", options: { toggle_class: 'btn js-license-selector', title: "Choose a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } ) - .gitignore-selector.js-gitignore-selector-wrap.hidden - = dropdown_tag("Choose a .gitignore template", options: { toggle_class: 'btn js-gitignore-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } ) - .gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.hidden - = dropdown_tag("Choose a GitLab CI Yaml template", options: { toggle_class: 'btn js-gitlab-ci-yml-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls } } ) - .dockerfile-selector.js-dockerfile-selector-wrap.hidden - = dropdown_tag("Choose a Dockerfile template", options: { toggle_class: 'btn js-dockerfile-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names } } ) - = button_tag class: 'soft-wrap-toggle btn', type: 'button' do + = button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do %span.no-wrap = custom_icon('icon_no_wrap') No wrap @@ -31,7 +25,7 @@ = custom_icon('icon_soft_wrap') Soft wrap .encoding-selector - = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2' + = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2', tabindex: '-1' .file-editor.code %pre.js-edit-mode-pane#editor= params[:content] || local_assigns[:blob_data] diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index deeeae3d64a..0be15cc179f 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -1,39 +1,19 @@ +- blame = local_assigns.fetch(:blame, false) .js-file-title.file-title-flex-parent - .file-header-content - = blob_icon blob.mode, blob.name - - %strong.file-title-name - = blob.name - - = copy_file_path_button(blob.path) - - %small - = number_to_human_size(blob_size(blob)) + = render 'projects/blob/header_content', blob: blob .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob unless blame + .btn-group{ role: "group" }< - = copy_blob_content_button(blob) if blob_text_viewable?(blob) - = open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id)) + = copy_blob_source_button(blob) unless blame + = open_raw_blob_button(blob) = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< - -# only show normal/blame view links for text files - - if blob_text_viewable?(blob) - - if current_page? namespace_project_blame_path(@project.namespace, @project, @id) - = link_to 'Normal View', namespace_project_blob_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - else - = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), - class: 'btn btn-sm js-blob-blame-link' unless blob.empty? - - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), - class: 'btn btn-sm' - - = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, - tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' - - - if current_user - .btn-group{ role: "group" }< - = edit_blob_link if blob_text_viewable?(blob) + = edit_blob_link + - if current_user = replace_blob_link = delete_blob_link + += render 'projects/fork_suggestion' diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml new file mode 100644 index 00000000000..98bedae650a --- /dev/null +++ b/app/views/projects/blob/_header_content.html.haml @@ -0,0 +1,10 @@ +.file-header-content + = blob_icon blob.mode, blob.name + + %strong.file-title-name + = blob.name + + = copy_file_path_button(blob.path) + + %small + = number_to_human_size(blob.raw_size) diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml deleted file mode 100644 index ea3cecb86a9..00000000000 --- a/app/views/projects/blob/_image.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -.file-content.image_file - - if blob.svg? - - if blob.size_within_svg_limits? - -# We need to scrub SVG but we cannot do so in the RawController: it would - -# be wrong/strange if RawController modified the data. - - blob.load_all_data!(@repository) - - blob = sanitize_svg(blob) - %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}", alt: "#{blob.name}" } - - else - .nothing-here-block - The SVG could not be displayed as it is too large, you can - #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer')} - instead. - - else - %img{ src: namespace_project_raw_path(@project.namespace, @project, tree_join(@commit.id, blob.path)), alt: "#{blob.name}" } diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml new file mode 100644 index 00000000000..9eef6cafd04 --- /dev/null +++ b/app/views/projects/blob/_render_error.html.haml @@ -0,0 +1,7 @@ +.file-content.code + .nothing-here-block + The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}. + + You can + = blob_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe + instead. diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml new file mode 100644 index 00000000000..2a178325041 --- /dev/null +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -0,0 +1,17 @@ +.template-selectors-menu + .templates-selectors-label + Template + .template-selector-dropdowns-wrap + .template-type-selector.js-template-type-selector-wrap.hidden + = dropdown_tag("Choose type", options: { toggle_class: 'btn js-template-type-selector', title: "Choose a template type" } ) + .license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden + = dropdown_tag("Apply a license template", options: { toggle_class: 'btn js-license-selector', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } ) + .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden + = dropdown_tag("Apply a .gitignore template", options: { toggle_class: 'btn js-gitignore-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } ) + .gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden + = dropdown_tag("Apply a GitLab CI Yaml template", options: { toggle_class: 'btn js-gitlab-ci-yml-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls } } ) + .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden + = dropdown_tag("Apply a Dockerfile template", options: { toggle_class: 'btn js-dockerfile-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names } } ) + .template-selectors-undo-menu.hidden + %span.text-info Template applied + %button.btn.btn-sm.btn-info Undo diff --git a/app/views/projects/blob/_text.html.haml b/app/views/projects/blob/_text.html.haml deleted file mode 100644 index 7b16d266982..00000000000 --- a/app/views/projects/blob/_text.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -- if blob.only_display_raw? - .file-content.code - .nothing-here-block - File too large, you can - = succeed '.' do - = link_to 'view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer' - -- else - - blob.load_all_data!(@repository) - - - if blob.empty? - .file-content.code - .nothing-here-block Empty file - - else - - if markup?(blob.name) - .file-content.wiki - = render_markup(blob.name, blob.data) - - else - = render 'shared/file_highlight', blob: blob, repository: @repository diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml new file mode 100644 index 00000000000..4252f27d007 --- /dev/null +++ b/app/views/projects/blob/_viewer.html.haml @@ -0,0 +1,13 @@ +- hidden = local_assigns.fetch(:hidden, false) +- render_error = viewer.render_error +- load_async = local_assigns.fetch(:load_async, viewer.load_async?) + +- viewer_url = local_assigns.fetch(:viewer_url) { url_for(params.merge(viewer: viewer.type, format: :json)) } if load_async +.blob-viewer{ data: { type: viewer.type, url: viewer_url }, class: ('hidden' if hidden) } + - if load_async + = render viewer.loading_partial_path, viewer: viewer + - elsif render_error + = render 'projects/blob/render_error', viewer: viewer + - else + - viewer.prepare! + = render viewer.partial_path, viewer: viewer diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml new file mode 100644 index 00000000000..6a521069418 --- /dev/null +++ b/app/views/projects/blob/_viewer_switcher.html.haml @@ -0,0 +1,12 @@ +- if blob.show_viewer_switcher? + - simple_viewer = blob.simple_viewer + - rich_viewer = blob.rich_viewer + + .btn-group.js-blob-viewer-switcher{ role: "group" } + - simple_label = "Display #{simple_viewer.switcher_title}" + %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }> + = icon(simple_viewer.switcher_icon) + + - rich_label = "Display #{rich_viewer.switcher_title}" + %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }> + = icon(rich_viewer.switcher_icon) diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index afe0b5dba45..4af62461151 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -9,14 +9,17 @@ - if @conflict .alert.alert-danger Someone edited the file the same time you did. Please check out - = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank", rel: 'noopener noreferrer' + = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer' and make sure your changes will not unintentionally remove theirs. - + .editor-title-row + %h3.page-title.blob-edit-page-title + Edit file + = render 'template_selectors' .file-editor %ul.nav-links.no-bottom.js-edit-mode %li.active = link_to '#editor' do - Edit File + Write %li = link_to '#preview', 'data-preview-url' => namespace_project_preview_blob_path(@project.namespace, @project, @id) do diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index 4c449e040ee..2afb909572a 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -2,10 +2,10 @@ - content_for :page_specific_javascripts do = page_specific_javascript_tag('lib/ace.js') = page_specific_javascript_bundle_tag('blob') - -%h3.page-title - New File - +.editor-title-row + %h3.page-title.blob-new-page-title + New file + = render 'template_selectors' .file-editor = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do = render 'projects/blob/editor', ref: @ref diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml index 5cafb644b40..da2cef17e8a 100644 --- a/app/views/projects/blob/preview.html.haml +++ b/app/views/projects/blob/preview.html.haml @@ -1,12 +1,8 @@ -.diff-file +.diff-file.file-holder .diff-content - - if gitlab_markdown?(@blob.name) + - if markup?(@blob.name) .file-content.wiki - = preserve do - = markdown(@content) - - elsif markup?(@blob.name) - .file-content.wiki - = raw render_markup(@blob.name, @content) + = markup(@blob.name, @content) - else .file-content.code.js-syntax-highlight - unless @diff_lines.empty? diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index b6738c3380f..67f57b5e4b9 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -2,13 +2,16 @@ - page_title @blob.path, @ref = render "projects/commits/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('blob') + %div{ class: container_class } = render 'projects/last_push' #tree-holder.tree-holder = render 'blob', blob: @blob - - if can_edit_blob?(@blob) + - if can_modify_blob?(@blob) = render 'projects/blob/remove' - title = "Replace #{@blob.name}" diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml new file mode 100644 index 00000000000..28670e7de97 --- /dev/null +++ b/app/views/projects/blob/viewers/_balsamiq.html.haml @@ -0,0 +1,4 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('balsamiq_viewer') + +.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/blob/viewers/_changelog.html.haml b/app/views/projects/blob/viewers/_changelog.html.haml new file mode 100644 index 00000000000..53921e63b5f --- /dev/null +++ b/app/views/projects/blob/viewers/_changelog.html.haml @@ -0,0 +1,4 @@ += icon('history fw') += succeed '.' do + To find the state of this project's repository at the time of any of these versions, check out + = link_to "the tags", namespace_project_tags_path(viewer.project.namespace, viewer.project) diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml new file mode 100644 index 00000000000..c78f04c9c7c --- /dev/null +++ b/app/views/projects/blob/viewers/_contributing.html.haml @@ -0,0 +1,9 @@ += icon('book fw') +After you've reviewed these contribution guidelines, you'll be all set to + +- options = contribution_options(viewer.project) +- if options.any? + = succeed '.' do + = options.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe +- else + contribute to this project. diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml new file mode 100644 index 00000000000..a0f0215a5ff --- /dev/null +++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml @@ -0,0 +1,11 @@ += icon('cubes fw') += succeed '.' do + This project manages its dependencies using + %strong= viewer.manager_name + + - if viewer.package_name + and defines a #{viewer.package_type} named + %strong< + = link_to viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' + += link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml new file mode 100644 index 00000000000..684240d02c7 --- /dev/null +++ b/app/views/projects/blob/viewers/_download.html.haml @@ -0,0 +1,7 @@ +.file-content.blob_file.blob-no-preview + .center + = link_to blob_raw_url do + %h1.light + = icon('download') + %h4 + Download (#{number_to_human_size(viewer.blob.raw_size)}) diff --git a/app/views/projects/blob/viewers/_empty.html.haml b/app/views/projects/blob/viewers/_empty.html.haml new file mode 100644 index 00000000000..a293a8de231 --- /dev/null +++ b/app/views/projects/blob/viewers/_empty.html.haml @@ -0,0 +1,3 @@ +.file-content.code + .nothing-here-block + Empty file diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml new file mode 100644 index 00000000000..28c5be6ebf3 --- /dev/null +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml @@ -0,0 +1,9 @@ +- if viewer.valid? + = icon('check fw') + This GitLab CI configuration is valid. +- else + = icon('warning fw') + This GitLab CI configuration is invalid: + = viewer.validation_message + += link_to 'Learn more', help_page_path('ci/yaml/README') diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml new file mode 100644 index 00000000000..10cbf6a2f7a --- /dev/null +++ b/app/views/projects/blob/viewers/_gitlab_ci_yml_loading.html.haml @@ -0,0 +1,4 @@ += icon('spinner spin fw') +Validating GitLab CI configuration… + += link_to 'Learn more', help_page_path('ci/yaml/README') diff --git a/app/views/projects/blob/viewers/_image.html.haml b/app/views/projects/blob/viewers/_image.html.haml new file mode 100644 index 00000000000..640d59b3174 --- /dev/null +++ b/app/views/projects/blob/viewers/_image.html.haml @@ -0,0 +1,2 @@ +.file-content.image_file + %img{ src: blob_raw_url, alt: viewer.blob.name } diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml new file mode 100644 index 00000000000..fb9d0b99d09 --- /dev/null +++ b/app/views/projects/blob/viewers/_license.html.haml @@ -0,0 +1,8 @@ +- license = viewer.license + += icon('balance-scale fw') +This project is licensed under the += succeed '.' do + %strong= license.name + += link_to 'Learn more', license.url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml new file mode 100644 index 00000000000..120c0540335 --- /dev/null +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -0,0 +1,2 @@ +.text-center.prepend-top-default.append-bottom-default + = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…') diff --git a/app/views/projects/blob/viewers/_loading_auxiliary.html.haml b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml new file mode 100644 index 00000000000..c7dc9e3250a --- /dev/null +++ b/app/views/projects/blob/viewers/_loading_auxiliary.html.haml @@ -0,0 +1,2 @@ += icon('spinner spin fw') +Analyzing file… diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml new file mode 100644 index 00000000000..230305b488d --- /dev/null +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -0,0 +1,4 @@ +- blob = viewer.blob +- rendered_markup = blob.rendered_markup if blob.respond_to?(:rendered_markup) +.file-content.wiki + = markup(blob.name, blob.data, rendered: rendered_markup) diff --git a/app/views/projects/blob/_notebook.html.haml b/app/views/projects/blob/viewers/_notebook.html.haml index ab1cf933944..2399fb16265 100644 --- a/app/views/projects/blob/_notebook.html.haml +++ b/app/views/projects/blob/viewers/_notebook.html.haml @@ -2,4 +2,4 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('notebook_viewer') -.file-content#js-notebook-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/blob/viewers/_pdf.html.haml b/app/views/projects/blob/viewers/_pdf.html.haml new file mode 100644 index 00000000000..1dd179c4fdc --- /dev/null +++ b/app/views/projects/blob/viewers/_pdf.html.haml @@ -0,0 +1,5 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('pdf_viewer') + +.file-content#js-pdf-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml new file mode 100644 index 00000000000..334b33faf48 --- /dev/null +++ b/app/views/projects/blob/viewers/_readme.html.haml @@ -0,0 +1,4 @@ += icon('info-circle fw') += succeed '.' do + To learn more about this project, read + = link_to "the wiki", namespace_project_wikis_path(viewer.project.namespace, viewer.project) diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml new file mode 100644 index 00000000000..d0fcd55f6c1 --- /dev/null +++ b/app/views/projects/blob/viewers/_route_map.html.haml @@ -0,0 +1,9 @@ +- if viewer.valid? + = icon('check fw') + This Route Map is valid. +- else + = icon('warning fw') + This Route Map is invalid: + = viewer.validation_message + += link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map') diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml new file mode 100644 index 00000000000..2318cf82f58 --- /dev/null +++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml @@ -0,0 +1,4 @@ += icon('spinner spin fw') +Validating Route Map… + += link_to 'Learn more', help_page_path('ci/environments', anchor: 'route-map') diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml new file mode 100644 index 00000000000..49f716c2c59 --- /dev/null +++ b/app/views/projects/blob/viewers/_sketch.html.haml @@ -0,0 +1,7 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('sketch_viewer') + +.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_url } } + .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' } + = icon('spinner spin 2x', 'aria-hidden' => 'true'); diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml new file mode 100644 index 00000000000..e4e9d746176 --- /dev/null +++ b/app/views/projects/blob/viewers/_stl.html.haml @@ -0,0 +1,12 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('stl_viewer') + +.file-content.is-stl-loading + .text-center#js-stl-viewer{ data: { endpoint: blob_raw_url } } + = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading') + .text-center.prepend-top-default.append-bottom-default.stl-controls + .btn-group + %button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } } + Wireframe + %button.btn.btn-default.btn-sm.active.js-material-changer{ data: { type: 'default' } } + Solid diff --git a/app/views/projects/blob/viewers/_svg.html.haml b/app/views/projects/blob/viewers/_svg.html.haml new file mode 100644 index 00000000000..62f647581b6 --- /dev/null +++ b/app/views/projects/blob/viewers/_svg.html.haml @@ -0,0 +1,4 @@ +- blob = viewer.blob +- data = sanitize_svg_data(blob.data) +.file-content.image_file + %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(data)}", alt: blob.name } diff --git a/app/views/projects/blob/viewers/_text.html.haml b/app/views/projects/blob/viewers/_text.html.haml new file mode 100644 index 00000000000..a91df321ca0 --- /dev/null +++ b/app/views/projects/blob/viewers/_text.html.haml @@ -0,0 +1 @@ += render 'shared/file_highlight', blob: viewer.blob, repository: @repository diff --git a/app/views/projects/blob/viewers/_video.html.haml b/app/views/projects/blob/viewers/_video.html.haml new file mode 100644 index 00000000000..595a890a27d --- /dev/null +++ b/app/views/projects/blob/viewers/_video.html.haml @@ -0,0 +1,2 @@ +.file-content.video + %video{ src: blob_raw_url, controls: true, data: { setup: '{}' } } diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index added3f669b..efec69662f3 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -3,13 +3,11 @@ - page_title "Boards" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('filtered_search') - = page_specific_javascript_bundle_tag('boards') - = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' + = webpack_bundle_tag 'boards' %script#js-board-template{ type: "text/x-template" }= render "projects/boards/components/board" - %script#js-board-list-template{ type: "text/x-template" }= render "projects/boards/components/board_list" %script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal = render "projects/issues/head" diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml index 5a4eaf92b16..bc5c727bf0d 100644 --- a/app/views/projects/boards/components/_board.html.haml +++ b/app/views/projects/boards/components/_board.html.haml @@ -13,8 +13,8 @@ %button.btn.btn-small.btn-default.pull-right.has-tooltip{ type: "button", "@click" => "showNewIssueForm", "v-if" => 'list.type !== "closed"', - "aria-label" => "Add an issue", - "title" => "Add an issue", + "aria-label" => "New issue", + "title" => "New issue", data: { placement: "top", container: "body" } } = icon("plus") - if can?(current_user, :admin_list, @project) diff --git a/app/views/projects/boards/components/_board_list.html.haml b/app/views/projects/boards/components/_board_list.html.haml deleted file mode 100644 index 4a0b2110601..00000000000 --- a/app/views/projects/boards/components/_board_list.html.haml +++ /dev/null @@ -1,26 +0,0 @@ -.board-list-component - .board-list-loading.text-center{ "v-if" => "loading" } - = icon("spinner spin") - - if can? current_user, :create_issue, @project - %board-new-issue{ ":list" => "list", - "v-if" => 'list.type !== "closed" && showIssueForm' } - %ul.board-list{ "ref" => "list", - "v-show" => "!loading", - ":data-board" => "list.id", - ":class" => '{ "is-smaller": showIssueForm }' } - %board-card{ "v-for" => "(issue, index) in issues", - "ref" => "issue", - ":index" => "index", - ":list" => "list", - ":issue" => "issue", - ":issue-link-base" => "issueLinkBase", - ":root-path" => "rootPath", - ":disabled" => "disabled", - ":key" => "issue.id" } - %li.board-list-count.text-center{ "v-if" => "showCount", - "data-issue-id" => "-1" } - = icon("spinner spin", "v-show" => "list.loadingMore" ) - %span{ "v-if" => "list.issues.length === list.issuesSize" } - Showing all issues - %span{ "v-else" => true } - Showing {{ list.issues.length }} of {{ list.issuesSize }} issues diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index e75ce305440..48f8c656080 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -1,39 +1,27 @@ -.block.assignee - .title.hide-collapsed - Assignee - - if can?(current_user, :admin_issue, @project) - = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" - .value.hide-collapsed - %span.assign-yourself.no-value{ "v-if" => "!issue.assignee" } - No assignee - - if can?(current_user, :admin_issue, @project) - \- - %a.js-assign-yourself{ href: "#" } - assign yourself - %a.author_link.bold{ ":href" => "'#{root_url}' + issue.assignee.username", - "v-if" => "issue.assignee" } - %img.avatar.avatar-inline.s32{ ":src" => "issue.assignee.avatar", - width: "32", alt: "Avatar" } - %span.author - {{ issue.assignee.name }} - %span.username - = precede "@" do - {{ issue.assignee.username }} +.block.assignee{ ref: "assigneeBlock" } + %template{ "v-if" => "issue.assignees" } + %assignee-title{ ":number-of-assignees" => "issue.assignees.length", + ":loading" => "loadingAssignees", + ":editable" => can?(current_user, :admin_issue, @project) } + %assignees.value{ "root-path" => "#{root_url}", + ":users" => "issue.assignees", + ":editable" => can?(current_user, :admin_issue, @project), + "@assign-self" => "assignSelf" } + - if can?(current_user, :admin_issue, @project) .selectbox.hide-collapsed - %input{ type: "hidden", - name: "issue[assignee_id]", - id: "issue_assignee_id", - ":value" => "issue.assignee.id", - "v-if" => "issue.assignee" } + %input.js-vue{ type: "hidden", + name: "issue[assignee_ids][]", + ":value" => "assignee.id", + "v-if" => "issue.assignees", + "v-for" => "assignee in issue.assignees" } .dropdown - %button.dropdown-menu-toggle.js-user-search.js-author-search.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", field_name: "issue[assignee_id]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true" }, + %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", 'max-select' => 1, dropdown: { header: 'Assignee' } }, ":data-issuable-id" => "issue.id", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } Select assignee = icon("chevron-down") - .dropdown-menu.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author + .dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author = dropdown_title("Assign to") = dropdown_filter("Search users") = dropdown_content diff --git a/app/views/projects/boards/components/sidebar/_labels.html.haml b/app/views/projects/boards/components/sidebar/_labels.html.haml index 0f0a84c156d..bee0f3dd065 100644 --- a/app/views/projects/boards/components/sidebar/_labels.html.haml +++ b/app/views/projects/boards/components/sidebar/_labels.html.haml @@ -19,7 +19,7 @@ ":value" => "label.id" } .dropdown %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button", - data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: namespace_project_labels_path(@project.namespace, @project, :json), namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) }, + data: { toggle: "dropdown", field_name: "issue[label_names][]", show_no: "true", show_any: "true", project_id: @project.id, labels: namespace_project_labels_path(@project.namespace, @project, :json), namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path) }, ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } %span.dropdown-toggle-text Label diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml index 008d1186478..4e46351bf8a 100644 --- a/app/views/projects/boards/components/sidebar/_milestone.html.haml +++ b/app/views/projects/boards/components/sidebar/_milestone.html.haml @@ -16,13 +16,14 @@ name: "issue[milestone_id]", "v-if" => "issue.milestone" } .dropdown - %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true" }, + %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: "issue", use_id: "true", default_no: "true" }, + ":data-selected" => "milestoneTitle", ":data-issuable-id" => "issue.id", ":data-issue-update" => "'#{namespace_project_issues_path(@project.namespace, @project)}/' + issue.id + '.json'" } Milestone = icon("chevron-down") .dropdown-menu.dropdown-select.dropdown-menu-selectable - = dropdown_title("Assignee milestone") + = dropdown_title("Assign milestone") = dropdown_filter("Search milestones") = dropdown_content = dropdown_loading diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 9eb610ba9c0..304c512e1b5 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -6,7 +6,8 @@ - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) %li{ class: "js-branch-#{branch.name}" } %div - = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated' do + = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated ref-name' do + = icon('code-fork') = branch.name - if branch.name == @repository.root_ref @@ -15,13 +16,13 @@ %span.label.label-info.has-tooltip{ title: "Merged into #{@repository.root_ref}" } merged - - if @project.protected_branch? branch.name + - if protected_branch?(@project, branch) %span.label.label-success protected .controls.hidden-xs< - if merge_project && create_mr_button?(@repository.root_ref, branch.name) = link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do - Merge Request + Merge request - if branch.name != @repository.root_ref = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", method: :post, title: "Compare" do @@ -30,13 +31,34 @@ = render 'projects/buttons/download', project: @project, ref: branch.name, pipeline: @refs_pipelines[branch.name] - if can?(current_user, :push_code, @project) - = link_to namespace_project_branch_path(@project.namespace, @project, branch.name), - class: "btn btn-remove remove-row js-ajax-loading-spinner #{can_remove_branch?(@project, branch.name) ? '' : 'disabled'}", - method: :delete, - data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" }, - remote: true, - "aria-label" => "Delete branch" do - = icon("trash-o") + - if branch.name == @project.repository.root_ref + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", + disabled: true, + title: "The default branch cannot be deleted" } + = icon("trash-o") + - elsif protected_branch?(@project, branch) + - if can?(current_user, :delete_protected_branch, @project) + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", + title: "Delete protected branch", + data: { toggle: "modal", + target: "#modal-delete-branch", + delete_path: namespace_project_branch_path(@project.namespace, @project, branch.name), + branch_name: branch.name } } + = icon("trash-o") + - else + %button{ class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip disabled", + disabled: true, + title: "Only a project master or owner can delete a protected branch" } + = icon("trash-o") + - else + = link_to namespace_project_branch_path(@project.namespace, @project, branch.name), + class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip", + title: "Delete branch", + method: :delete, + data: { confirm: "Deleting the '#{branch.name}' branch cannot be undone. Are you sure?" }, + remote: true, + "aria-label" => "Delete branch" do + = icon("trash-o") - if branch.name != @repository.root_ref .divergence-graph{ title: "#{number_commits_ahead} commits ahead, #{number_commits_behind} commits behind #{@repository.root_ref}" } diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml index de607772df6..ad8f9da0621 100644 --- a/app/views/projects/branches/_commit.html.haml +++ b/app/views/projects/branches/_commit.html.haml @@ -1,7 +1,7 @@ .branch-commit .icon-container.commit-icon = custom_icon("icon_commit") - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-id monospace" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-sha" · %span.str-truncated = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" diff --git a/app/views/projects/branches/_delete_protected_modal.html.haml b/app/views/projects/branches/_delete_protected_modal.html.haml new file mode 100644 index 00000000000..c5888afa54d --- /dev/null +++ b/app/views/projects/branches/_delete_protected_modal.html.haml @@ -0,0 +1,34 @@ +#modal-delete-branch.modal{ tabindex: -1 } + .modal-dialog + .modal-content + .modal-header + %button.close{ data: { dismiss: 'modal' } } × + %h3.page-title + Delete protected branch + = surround "'", "'?" do + %span.js-branch-name>[branch name] + + .modal-body + %p + You’re about to permanently delete the protected branch + = succeed '.' do + %strong.js-branch-name [branch name] + %p + Once you confirm and press + = succeed ',' do + %strong Delete protected branch + it cannot be undone or recovered. + %p + %strong To confirm, type + %kbd.js-branch-name [branch name] + + .form-group + = text_field_tag 'delete_branch_input', '', class: 'form-control js-delete-branch-input' + + .modal-footer + %button.btn{ data: { dismiss: 'modal' } } Cancel + = link_to 'Delete protected branch', '', + class: "btn btn-danger js-delete-branch", + title: 'Delete branch', + method: :delete, + "aria-label" => "Delete" diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index bd1f2d96f56..4bade77a077 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -15,16 +15,14 @@ .dropdown.inline> %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.light - = projects_sort_options_hash[@sort] + = branches_sort_options_hash[@sort] = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to filter_branches_path(sort: sort_value_name) do - = sort_title_name - = link_to filter_branches_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to filter_branches_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - branches_sort_options_hash.each do |value, title| + %li + = link_to title, filter_branches_path(sort: value), class: ("is-active" if @sort == value) - if can? current_user, :push_code, @project = link_to namespace_project_merged_branches_path(@project.namespace, @project), class: 'btn btn-inverted btn-remove has-tooltip', title: "Delete all branches that are merged into '#{@project.repository.root_ref}'", method: :delete, data: { confirm: "Deleting the merged branches cannot be undone. Are you sure?", container: 'body' } do @@ -39,3 +37,5 @@ = paginate @branches, theme: 'gitlab' - else .nothing-here-block No branches to show + += render 'projects/branches/delete_protected_modal' diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml index d3c3e40d518..5a0eba3551f 100644 --- a/app/views/projects/branches/new.html.haml +++ b/app/views/projects/branches/new.html.haml @@ -1,4 +1,5 @@ - page_title "New Branch" +- default_ref = params[:ref] || @project.default_branch - if @error .alert.alert-danger @@ -16,12 +17,13 @@ .help-block.text-danger.js-branch-name-error .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10 - = hidden_field_tag :ref, params[:ref] || @project.default_branch - = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches", - data: { selected: params[:ref] || @project.default_branch, field_name: 'ref' } }) + .col-sm-10.create-from + .dropdown + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select git-revision-dropdown-toggle', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = icon('chevron-down') + = render 'shared/ref_dropdown', dropdown_class: 'wide' .help-block Existing branch name, tag, or commit SHA .form-actions = button_tag 'Create branch', class: 'btn btn-create', tabindex: 3 diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index 7eb17e887e7..d4cdb709b97 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -1,25 +1,31 @@ +- show_controls = local_assigns.fetch(:show_controls, true) +- pipeline = @build.pipeline + .content-block.build-header.top-area .header-content - = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false - Job - %strong.js-build-id ##{@build.id} + = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title + %strong + Job + = link_to "##{@build.id}", namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' in pipeline - = link_to pipeline_path(@build.pipeline) do - %strong ##{@build.pipeline.id} - for commit - = link_to namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha) do - %strong= @build.pipeline.short_sha + %strong + = link_to "##{pipeline.id}", pipeline_path(pipeline) + for + %strong + = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: 'commit-sha' from - = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do - %code - = @build.ref - - if @build.user - = render "user" + %strong + = link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name' + + = render "projects/builds/user" if @build.user + = time_ago_with_tooltip(@build.created_at) - .nav-controls - - if can?(current_user, :create_issue, @project) && @build.failed? - = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post - %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } - = icon('angle-double-left') + + - if show_controls + .nav-controls + - if can?(current_user, :create_issue, @project) && @build.failed? + = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' + - if can?(current_user, :update_build, @build) && @build.retryable? + = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post + %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } + = icon('angle-double-left') diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index b597c7f7a12..8032d81cd91 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -1,6 +1,6 @@ - builds = @build.pipeline.builds.to_a -%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "153", "spy" => "affix" } } +%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } .block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default Job %strong ##{@build.id} @@ -33,7 +33,7 @@ = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do Keep - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do Download - if @build.artifacts_metadata? @@ -48,7 +48,7 @@ - if @build.merge_request %p.build-detail-row %span.build-light-text Merge Request: - = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request) + = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'bold' - if @build.duration %p.build-detail-row %span.build-light-text Duration: @@ -68,7 +68,7 @@ - elsif @build.runner \##{@build.runner.id} .btn-group.btn-group-justified{ role: :group } - - if @build.has_trace_file? + - if @build.has_trace? = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' - if @build.active? = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/builds/_table.html.haml index acfdb250aff..82806f022ee 100644 --- a/app/views/projects/builds/_table.html.haml +++ b/app/views/projects/builds/_table.html.haml @@ -20,6 +20,6 @@ %th Coverage %th - = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, coverage: admin || project.build_coverage_enabled?, admin: admin } + = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin } = paginate builds, theme: 'gitlab' diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/builds/index.html.haml index 5ffc0e20d10..a8c8afe2695 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/builds/index.html.haml @@ -14,10 +14,10 @@ data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - unless @repository.gitlab_ci_yml - = link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' + = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' = link_to ci_lint_path, class: 'btn btn-default' do - %span CI Lint + %span CI lint .content-list.builds-content-list = render "table", builds: @builds, project: @project diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index d5fe771613c..7cb2ec83cc7 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -71,6 +71,11 @@ = custom_icon('scroll_down_hover_active') #up-build-trace %pre.build-trace#build-trace + .js-truncated-info.truncated-info.hidden< + Showing last + %span.js-truncated-info-size.truncated-info-size>< + KiB of log - + %a.js-raw-link.raw-link{ :href => raw_namespace_project_build_path(@project.namespace, @project, @build) }>< Complete Raw %code.bash.js-build-output .build-loader-animation.js-build-refresh diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 09286a1b3c6..e796920ac82 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -1,109 +1,110 @@ +- job = build.present(current_user: current_user) +- pipeline = job.pipeline - admin = local_assigns.fetch(:admin, false) - ref = local_assigns.fetch(:ref, nil) - commit_sha = local_assigns.fetch(:commit_sha, nil) - retried = local_assigns.fetch(:retried, false) - pipeline_link = local_assigns.fetch(:pipeline_link, false) - stage = local_assigns.fetch(:stage, false) -- coverage = local_assigns.fetch(:coverage, false) - allow_retry = local_assigns.fetch(:allow_retry, false) %tr.build.commit{ class: ('retried' if retried) } %td.status - = render "ci/status/badge", status: build.detailed_status(current_user) + = render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title %td.branch-commit - - if can?(current_user, :read_build, build) - = link_to namespace_project_build_url(build.project.namespace, build.project, build) do - %span.build-link ##{build.id} + - if can?(current_user, :read_build, job) + = link_to namespace_project_build_url(job.project.namespace, job.project, job) do + %span.build-link ##{job.id} - else - %span.build-link ##{build.id} + %span.build-link ##{job.id} - if ref - - if build.ref + - if job.ref .icon-container - = build.tag? ? icon('tag') : icon('code-fork') - = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name" + = job.tag? ? icon('tag') : icon('code-fork') + = link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name" - else .light none .icon-container.commit-icon = custom_icon("icon_commit") - if commit_sha - = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "commit-id monospace" + = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-sha" - - if build.stuck? + - if job.stuck? = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') - if retried - = icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried') + = icon('spinner', class: 'text-warning has-tooltip', title: 'Job was retried') .label-container - - if build.tags.any? - - build.tags.each do |tag| + - if job.tags.any? + - job.tags.each do |tag| %span.label.label-primary = tag - - if build.try(:trigger_request) + - if job.try(:trigger_request) %span.label.label-info triggered - - if build.try(:allow_failure) + - if job.try(:allow_failure) %span.label.label-danger allowed to fail - - if build.action? + - if job.action? %span.label.label-info manual - if pipeline_link %td - = link_to pipeline_path(build.pipeline) do - %span.pipeline-id ##{build.pipeline.id} + = link_to pipeline_path(pipeline) do + %span.pipeline-id ##{pipeline.id} %span by - - if build.pipeline.user - = user_avatar(user: build.pipeline.user, size: 20) + - if pipeline.user + = user_avatar(user: pipeline.user, size: 20) - else - %span.monospace API + %span.api API - if admin %td - - if build.project - = link_to build.project.name_with_namespace, admin_namespace_project_path(build.project.namespace, build.project) + - if job.project + = link_to job.project.name_with_namespace, admin_namespace_project_path(job.project.namespace, job.project) %td - - if build.try(:runner) - = runner_link(build.runner) + - if job.try(:runner) + = runner_link(job.runner) - else .light none - if stage %td - = build.stage + = job.stage %td - = build.name + = job.name %td - - if build.duration + - if job.duration %p.duration = custom_icon("icon_timer") - = duration_in_numbers(build.duration) + = duration_in_numbers(job.duration) - - if build.finished_at + - if job.finished_at %p.finished-at = icon("calendar") - %span= time_ago_with_tooltip(build.finished_at) + %span= time_ago_with_tooltip(job.finished_at) %td.coverage - - if coverage && build.try(:coverage) - #{build.coverage}% + - if job.try(:coverage) + #{job.coverage}% %td .pull-right - - if can?(current_user, :read_build, build) && build.artifacts? - = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts', class: 'btn btn-build' do + - if can?(current_user, :read_build, job) && job.artifacts? + = link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do = icon('download') - - if can?(current_user, :update_build, build) - - if build.active? - = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do + - if can?(current_user, :update_build, job) + - if job.active? + = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - - if build.playable? && !admin - = link_to play_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do + - if job.playable? && !admin && can?(current_user, :update_build, job) + = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - - elsif build.retryable? - = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do + - elsif job.retryable? + = link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do = icon('repeat') diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index a0a292d0508..0aef5822f81 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,7 +1,9 @@ .page-content-header .header-main-content - %strong Commit #{@commit.short_id} - = clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard") + %strong + Commit + %span.commit-sha= @commit.short_id + = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard") %span.hidden-xs authored #{time_ago_with_tooltip(@commit.authored_date)} %span by @@ -20,7 +22,7 @@ = icon('comment') = @notes_count = link_to namespace_project_tree_path(@project.namespace, @project, @commit), class: "btn btn-default append-right-10 hidden-xs hidden-sm" do - Browse Files + Browse files .dropdown.inline %a.btn.btn-default.dropdown-toggle{ data: { toggle: "dropdown" } } %span Options @@ -57,23 +59,25 @@ = custom_icon("icon_commit") %span.cgray= pluralize(@commit.parents.count, "parent") - @commit.parents.each do |parent| - = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "monospace" + = link_to parent.short_id, namespace_project_commit_path(@project.namespace, @project, parent), class: "commit-sha" %span.commit-info.branches %i.fa.fa-spinner.fa-spin - - if @commit.status + - if @commit.last_pipeline + - last_pipeline = @commit.last_pipeline .well-segment.pipeline-info .status-icon-container{ class: "ci-status-icon-#{@commit.status}" } - = link_to namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id) do - = ci_icon_for_status(@commit.status) + = link_to namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) do + = ci_icon_for_status(last_pipeline.status) Pipeline - = link_to "##{@commit.latest_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @commit.latest_pipeline.id), class: "monospace" - = ci_label_for_status(@commit.status) - - if @commit.latest_pipeline.stages.any? + = link_to "##{last_pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, last_pipeline.id) + = ci_label_for_status(last_pipeline.status) + - if last_pipeline.stages.any? + with #{"stage".pluralize(last_pipeline.stages.count)} .mr-widget-pipeline-graph - = render 'shared/mini_pipeline_graph', pipeline: @commit.latest_pipeline, klass: 'js-commit-pipeline-graph' + = render 'shared/mini_pipeline_graph', pipeline: last_pipeline, klass: 'js-commit-pipeline-graph' in - = time_interval_in_words @commit.pipelines.total_duration + = time_interval_in_words last_pipeline.duration :javascript $(".commit-info.branches").load("#{branches_namespace_project_commit_path(@project.namespace, @project, @commit.id)}"); diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml deleted file mode 100644 index c2b32a22170..00000000000 --- a/app/views/projects/commit/_pipeline.html.haml +++ /dev/null @@ -1,53 +0,0 @@ -.pipeline-graph-container - .row-content-block.build-content.middle-block.pipeline-actions - .pull-right - - if can?(current_user, :update_pipeline, pipeline.project) - - if pipeline.builds.latest.failed.any?(&:retryable?) - = link_to "Retry", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'js-retry-button btn btn-grouped btn-primary', method: :post - - - if pipeline.builds.running_or_pending.any? - = link_to "Cancel running", cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-grouped btn-danger', method: :post - - .oneline.clearfix - - if defined?(pipeline_details) && pipeline_details - Pipeline - = link_to "##{pipeline.id}", namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: "monospace" - with - = pluralize pipeline.statuses.count(:id), "job" - - if pipeline.ref - for - = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace" - - if defined?(link_to_commit) && link_to_commit - for commit - = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "monospace" - - if pipeline.duration - in - = time_interval_in_words pipeline.duration - - .row-content-block.build-content.middle-block.js-pipeline-graph.hidden - = render "projects/pipelines/graph", pipeline: pipeline - -- if pipeline.yaml_errors.present? - .bs-callout.bs-callout-danger - %h4 Found errors in your .gitlab-ci.yml: - %ul - - pipeline.yaml_errors.split(",").each do |error| - %li= error - You can also test your .gitlab-ci.yml in the #{link_to "Lint", ci_lint_path} - -- if pipeline.project.builds_enabled? && !pipeline.ci_yaml_file - .bs-callout.bs-callout-warning - \.gitlab-ci.yml not found in this commit - -.table-holder.pipeline-holder - %table.table.ci-table.pipeline - %thead - %tr - %th Status - %th Job ID - %th Name - %th - - if pipeline.project.build_coverage_enabled? - %th Coverage - %th - = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml index 2b0c9a4b4de..911c9ddce06 100644 --- a/app/views/projects/commit/branches.html.haml +++ b/app/views/projects/commit/branches.html.haml @@ -1,15 +1,15 @@ -- if @branches.any? - %span - - branch = commit_default_branch(@project, @branches) - = link_to(namespace_project_tree_path(@project.namespace, @project, branch)) do - %span.label.label-gray - = branch - - if @branches.any? || @tags.any? - = link_to("#", class: "js-details-expand") do - %span.label.label-gray - \... +- if @branches.any? || @tags.any? + - branch = commit_default_branch(@project, @branches) + = link_to(project_ref_path(@project, branch), class: "label label-gray ref-name") do + = icon('code-fork') + = branch + + -# `commit_default_branch` deletes the default branch from `@branches`, + -# so only render this if we have more branches left + - if @branches.any? || @tags.any? + %span + = link_to "…", "#", class: "js-details-expand label label-gray" + %span.js-details-content.hide - - if @branches.any? - = commit_branches_links(@project, @branches) - - if @tags.any? - = commit_tags_links(@project, @tags) + = commit_branches_links(@project, @branches) if @branches.any? + = commit_tags_links(@project, @tags) if @tags.any? diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index d5fc283aa8d..6051ea2f1ce 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -1,16 +1,19 @@ - @no_container = true +- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : '' +- limited_container_width = fluid_layout || diff_view == :inline ? '' : 'limit-container-width' - page_title "#{@commit.title} (#{@commit.short_id})", "Commits" - page_description @commit.description = render "projects/commits/head" -%div{ class: container_class } +.container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" - if @commit.status = render "ci_menu" - else .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - = render "projects/notes/notes_with_form" + + = render "shared/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| = render "projects/commit/change", type: type, commit: @commit, title: @commit.title diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 4b1ff75541a..3350a0ec152 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -37,6 +37,6 @@ .commit-actions.flex-row.hidden-xs - if commit.status(ref) = render_commit_status(commit, ref: ref) - = clipboard_button(clipboard_text: commit.id, title: "Copy commit SHA to clipboard") - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha btn btn-transparent" + = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard") = link_to_browse_code(project, commit) diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml index c03bc3f9df9..5fb89935467 100644 --- a/app/views/projects/commits/_inline_commit.html.haml +++ b/app/views/projects/commits/_inline_commit.html.haml @@ -1,6 +1,6 @@ %li.commit.inline-commit .commit-row-title - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-sha" %span.str-truncated = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 38dbf2ac10b..c1c2fb3d299 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -18,16 +18,16 @@ .block-controls.hidden-xs.hidden-sm - if @merge_request.present? .control - = link_to "View Open Merge Request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn' + = link_to "View open merge request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn' - elsif create_mr_button?(@repository.root_ref, @ref) .control - = link_to "Create Merge Request", create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' + = link_to "Create merge request", create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' .control = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do = search_field_tag :search, params[:search], { placeholder: 'Filter by commit message', id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false } .control - = link_to namespace_project_commits_path(@project.namespace, @project, @ref, rss_url_options), title: "Commits Feed", class: 'btn' do + = link_to namespace_project_commits_path(@project.namespace, @project, @ref, rss_url_options), title: "Commits feed", class: 'btn' do = icon("rss") %div{ id: dom_id(@project) } diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 08236216421..adb724c1b8d 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -7,20 +7,20 @@ .input-group.inline-input-group %span.input-group-addon from = hidden_field_tag :from, params[:from] - = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do + = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do .dropdown-toggle-text.str-truncated= params[:from] || 'Select branch/tag' - = render "ref_dropdown" + = render 'shared/ref_dropdown' .compare-ellipsis.inline ... .form-group.dropdown.compare-form-group.to.js-compare-to-dropdown .input-group.inline-input-group %span.input-group-addon to = hidden_field_tag :to, params[:to] - = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do + = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip git-revision-dropdown-toggle", required: true, data: { refs_url: refs_namespace_project_path(@project.namespace, @project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do .dropdown-toggle-text.str-truncated= params[:to] || 'Select branch/tag' - = render "ref_dropdown" + = render 'shared/ref_dropdown' = button_tag "Compare", class: "btn btn-create commits-compare-btn" - if @merge_request.present? - = link_to "View Open Merge Request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'prepend-left-10 btn' + = link_to "View open merge request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'prepend-left-10 btn' - elsif create_mr_button? - = link_to "Create Merge Request", create_mr_path, class: 'prepend-left-10 btn' + = link_to "Create merge request", create_mr_path, class: 'prepend-left-10 btn' diff --git a/app/views/projects/compare/_ref_dropdown.html.haml b/app/views/projects/compare/_ref_dropdown.html.haml deleted file mode 100644 index 05fb37cdc0f..00000000000 --- a/app/views/projects/compare/_ref_dropdown.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -.dropdown-menu.dropdown-menu-selectable - = dropdown_title "Select Git revision" - = dropdown_filter "Filter by Git revision" - = dropdown_content - = dropdown_loading diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 45be6581cfc..2cf14859f30 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -6,10 +6,10 @@ .sub-header-block Compare Git revisions. %br - Fill input field with commit id like - %code.label-branch 4eedf23 + Fill input field with commit SHA like + %code.ref-name 4eedf23 or branch/tag name like - %code.label-branch master + %code.ref-name master and press compare button for the commits list and a code diff. %br Changes are shown <b>from</b> the version in the first field <b>to</b> the version in the second field. diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 0dfc9fe20ed..a1bca2cf83a 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -16,9 +16,9 @@ There isn't anything to compare. %p.slead - if params[:to] == params[:from] - %span.label-branch= params[:from] + %span.ref-name= params[:from] and - %span.label-branch= params[:to] + %span.ref-name= params[:to] are the same. - else You'll need to use different branch names to get a valid comparison. diff --git a/app/views/projects/cycle_analytics/_empty_stage.html.haml b/app/views/projects/cycle_analytics/_empty_stage.html.haml index c3f95860e92..cdad0bc7231 100644 --- a/app/views/projects/cycle_analytics/_empty_stage.html.haml +++ b/app/views/projects/cycle_analytics/_empty_stage.html.haml @@ -2,6 +2,6 @@ .empty-stage .icon-no-data = custom_icon ('icon_no_data') - %h4 We don't have enough data to show this stage. + %h4 {{ __('We don\'t have enough data to show this stage.') }} %p {{currentStage.emptyStageText}} diff --git a/app/views/projects/cycle_analytics/_no_access.html.haml b/app/views/projects/cycle_analytics/_no_access.html.haml index 0ffc79b3181..c3eda398234 100644 --- a/app/views/projects/cycle_analytics/_no_access.html.haml +++ b/app/views/projects/cycle_analytics/_no_access.html.haml @@ -2,6 +2,6 @@ .no-access-stage .icon-lock = custom_icon ('icon_lock') - %h4 You need permission. + %h4 {{ __('You need permission.') }} %p - Want to see the data? Please ask administrator for access. + {{ __('Want to see the data? Please ask an administrator for access.') }} diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index dd3fa814716..74255167352 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -2,29 +2,30 @@ - page_title "Cycle Analytics" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('locale') = page_specific_javascript_bundle_tag('cycle_analytics') = render "projects/head" #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data - .bordered-box.landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } - = icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()") - .row - .col-sm-3.col-xs-12.svg-container - = custom_icon('icon_cycle_analytics_splash') - .col-sm-8.col-xs-12.inner-content - %h4 - Introducing Cycle Analytics - %p - Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project. - - = link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' + .landing.content-block{ "v-if" => "!isOverviewDialogDismissed" } + %button.dismiss-button{ type: 'button', 'aria-label': 'Dismiss Cycle Analytics introduction box' } + = icon("times", "@click" => "dismissOverviewDialog()") + .svg-container + = custom_icon('icon_cycle_analytics_splash') + .inner-content + %h4 + {{ __('Introducing Cycle Analytics') }} + %p + {{ __('Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.') }} + %p + = link_to _('Read more'), help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn' = icon("spinner spin", "v-show" => "isLoading") .wrapper{ "v-show" => "!isLoading && !hasError" } .panel.panel-default .panel-heading - Pipeline Health + {{ __('Pipeline Health') }} .content-block .container-fluid .row @@ -34,15 +35,15 @@ .col-sm-3.col-xs-12.column .dropdown.inline.js-ca-dropdown %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } - %span.dropdown-label Last 30 days + %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }} %i.fa.fa-chevron-down %ul.dropdown-menu.dropdown-menu-align-right %li %a{ "href" => "#", "data-value" => "30" } - Last 30 days + {{ n__('Last %d day', 'Last %d days', 30) }} %li %a{ "href" => "#", "data-value" => "90" } - Last 90 days + {{ n__('Last %d day', 'Last %d days', 90) }} .stage-panel-container .panel.panel-default.stage-panel .panel-heading @@ -50,20 +51,20 @@ %ul %li.stage-header %span.stage-name - Stage - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" } + {{ s__('ProjectLifecycle|Stage') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header %span.stage-name - Median - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" } + {{ __('Median') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" } %li.event-header %span.stage-name - {{ currentStage ? currentStage.legend : 'Related Issues' }} - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" } + {{ currentStage ? __(currentStage.legend) : __('Related Issues') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } %li.total-time-header %span.stage-name - Total Time - %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" } + {{ __('Total Time') }} + %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav %ul @@ -75,10 +76,10 @@ %span{ "v-if" => "stage.value" } {{ stage.value }} %span.stage-empty{ "v-else" => true } - Not enough data + {{ __('Not enough data') }} %template{ "v-else" => true } %span.not-available - Not available + {{ __('Not available') }} .section.stage-events %template{ "v-if" => "isLoadingStage" } = icon("spinner spin") diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 4cfbd9add00..74756b58439 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -10,25 +10,4 @@ = render @deploy_keys.form_partial_path .col-lg-9.col-lg-offset-3 %hr - .col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys - %h5.prepend-top-0 - Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size}) - - if @deploy_keys.any_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys found. Create one with the form above. - %h5.prepend-top-default - Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size}) - - if @deploy_keys.any_available_project_keys_enabled? - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key - - else - .settings-message.text-center - No deploy keys from your projects could be found. Create one with the form above or add existing one below. - - if @deploy_keys.any_available_public_keys_enabled? - %h5.prepend-top-default - Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size}) - %ul.well-list - = render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key + #js-deploy-keys{ data: { endpoint: namespace_project_deploy_keys_path } } diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml index 170d786ecbf..31fd982c522 100644 --- a/app/views/projects/deployments/_commit.html.haml +++ b/app/views/projects/deployments/_commit.html.haml @@ -2,10 +2,10 @@ - if deployment.ref .icon-container = deployment.tag? ? icon('tag') : icon('code-fork') - = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace branch-name" + = link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name" .icon-container.commit-icon = custom_icon("icon_commit") - = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" + = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-sha" %p.commit-title %span diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index 5c38b5ad9c0..c781e423c4d 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -3,9 +3,9 @@ - return unless blob.respond_to?(:text?) - if diff_file.too_large? .nothing-here-block This diff could not be displayed because it is too large. - - elsif blob.only_display_raw? - .nothing-here-block This file is too large to display. - - elsif blob_text_viewable?(blob) + - elsif blob.too_large? + .nothing-here-block The file could not be displayed because it is too large. + - elsif blob.readable_text? - if !project.repository.diffable?(blob) .nothing-here-block This diff was suppressed by a .gitattributes entry. - elsif diff_file.collapsed? diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 4b49bed835f..71a1b9e6c05 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -27,7 +27,7 @@ - diff_commit = commit_for_diff(diff_file) - blob = diff_file.blob(diff_commit) - next unless blob - - blob.load_all_data!(diffs.project.repository) unless blob.only_display_raw? + - blob.load_all_data!(diffs.project.repository) unless blob.too_large? - file_hash = hexdigest(diff_file.file_path) = render 'projects/diffs/file', file_hash: file_hash, project: diffs.project, diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 0232a09b4a8..f22b385fc0f 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -6,7 +6,7 @@ - unless diff_file.submodule? .file-actions.hidden-xs - - if blob_text_viewable?(blob) + - if blob.readable_text? = link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: "Toggle comments for this file", disabled: @diff_notes_disabled do = icon('comment') \ @@ -18,4 +18,6 @@ = view_file_button(diff_commit.id, diff_file.new_path, project) = view_on_environment_button(diff_commit.id, diff_file.new_path, environment) if environment + = render 'projects/fork_suggestion' + = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index 7d6b3701f95..4e4fdb73ae3 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -1,4 +1,8 @@ -%i.fa.diff-toggle-caret.fa-fw +- show_toggle = local_assigns.fetch(:show_toggle, true) + +- if show_toggle + %i.fa.diff-toggle-caret.fa-fw + - if defined?(blob) && blob && diff_file.submodule? %span = icon('archive fw') diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index c09c7b87e24..7439b8a66f7 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -4,7 +4,7 @@ - type = line.type - line_code = diff_file.line_code(line) - if discussions && !line.meta? - - discussion = discussions[line_code] + - line_discussions = discussions[line_code] %tr.line_holder{ class: type, id: (line_code unless plain) } - case type - when 'match' @@ -20,6 +20,7 @@ = link_text - else %a{ href: "##{line_code}", data: { linenumber: link_text } } + - discussion = line_discussions.try(:first) - if discussion && discussion.resolvable? && !plain %diff-note-avatars{ "discussion-id" => discussion.id } %td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } } @@ -34,6 +35,6 @@ - else = diff_line_content(line.text) -- if discussion - - discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?) - = render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded +- if line_discussions&.any? + - discussion_expanded = local_assigns.fetch(:discussion_expanded, line_discussions.any?(&:expanded?)) + = render "discussions/diff_discussion", discussions: line_discussions, expanded: discussion_expanded diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index b7346f27ddb..45c95f7ab6a 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -5,8 +5,7 @@ - left = line[:left] - right = line[:right] - last_line = right.new_pos if right - - unless @diff_notes_disabled - - discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file) + - discussions_left, discussions_right = parallel_diff_discussions(left, right, diff_file) %tr.line_holder.parallel - if left - case left.type @@ -20,6 +19,7 @@ - left_position = diff_file.position(left) %td.old_line.diff-line-num.js-avatar-container{ id: left_line_code, class: left.type, data: { linenumber: left.old_pos } } %a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } } + - discussion_left = discussions_left.try(:first) - if discussion_left && discussion_left.resolvable? %diff-note-avatars{ "discussion-id" => discussion_left.id } %td.line_content.parallel.noteable_line{ class: left.type, data: diff_view_line_data(left_line_code, left_position, 'old') }= diff_line_content(left.text) @@ -39,6 +39,7 @@ - right_position = diff_file.position(right) %td.new_line.diff-line-num.js-avatar-container{ id: right_line_code, class: right.type, data: { linenumber: right.new_pos } } %a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } } + - discussion_right = discussions_right.try(:first) - if discussion_right && discussion_right.resolvable? %diff-note-avatars{ "discussion-id" => discussion_right.id } %td.line_content.parallel.noteable_line{ class: right.type, data: diff_view_line_data(right_line_code, right_position, 'new') }= diff_line_content(right.text) @@ -46,8 +47,8 @@ %td.old_line.diff-line-num.empty-cell %td.line_content.parallel - - if discussion_left || discussion_right - = render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right + - if discussions_left || discussions_right + = render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right - if !diff_file.new_file && !diff_file.deleted_file && diff_file.diff_lines.any? - last_line = diff_file.diff_lines.last - if last_line.new_pos < total_lines diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index ebd1a914ee7..5f3968b6709 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -4,11 +4,10 @@ %a.show-suppressed-diff.js-show-suppressed-diff Changes suppressed. Click to show. %table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } - - discussions = @grouped_diff_discussions unless @diff_notes_disabled = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, - locals: { diff_file: diff_file, discussions: discussions } + locals: { diff_file: diff_file, discussions: @grouped_diff_discussions } - if !diff_file.new_file && !diff_file.deleted_file && diff_file.highlighted_diff_lines.any? - last_line = diff_file.highlighted_diff_lines.last diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 82e0d0025ec..dd27e0866de 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -40,8 +40,8 @@ .form_group.prepend-top-20.sharing-and-permissions .row.js-visibility-select .col-md-9 - %label.label-light - = label_tag :project_visibility, 'Project Visibility', class: 'label-light' + .label-light + = label_tag :project_visibility, 'Project Visibility', class: 'label-light', for: :project_visibility_level = link_to "(?)", help_page_path("public_access/public_access") %span.help-block .col-md-3.visibility-select-container @@ -65,7 +65,7 @@ .row .col-md-9.project-feature.nested = feature_fields.label :builds_access_level, "Pipelines", class: 'label-light' - %span.help-block Submit, test and deploy your changes before merge + %span.help-block Build, test, and deploy your changes .col-md-3 = project_feature_access_select(:builds_access_level) @@ -163,7 +163,7 @@ - if @project.export_project_path = link_to 'Download export', download_export_namespace_project_path(@project.namespace, @project), - method: :get, class: "btn btn-default" + rel: 'nofollow', download: '', method: :get, class: "btn btn-default" = link_to 'Generate new export', generate_new_export_namespace_project_path(@project.namespace, @project), method: :post, class: "btn btn-default" - else @@ -238,6 +238,8 @@ %ul %li Be careful. Renaming a project's repository can have unintended side effects. %li You will need to update your local repositories to point to the new location. + - if @project.deployment_services.any? + %li Your deployment services will be broken, you will need to manually fix the services after renaming. = f.submit 'Rename project', class: "btn btn-warning" - if can?(current_user, :change_namespace, @project) %hr diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 85e442e115c..50e0bad3ccf 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -60,7 +60,7 @@ git init git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'clone')} git add . - git commit + git commit -m "Initial commit" git push -u origin master %fieldset diff --git a/app/views/projects/environments/_external_url.html.haml b/app/views/projects/environments/_external_url.html.haml index bf0f1819073..a82ef5ee5bb 100644 --- a/app/views/projects/environments/_external_url.html.haml +++ b/app/views/projects/environments/_external_url.html.haml @@ -1,3 +1,4 @@ - if environment.external_url && can?(current_user, :read_environment, environment) = link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url' do = icon('external-link') + View deployment diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml index acbac1869fd..b4102fcf103 100644 --- a/app/views/projects/environments/_metrics_button.html.haml +++ b/app/views/projects/environments/_metrics_button.html.haml @@ -1,6 +1,7 @@ - environment = local_assigns.fetch(:environment) -- return unless environment.has_metrics? && can?(current_user, :read_environment, environment) +- return unless can?(current_user, :read_environment, environment) = link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do = icon('area-chart') + Monitoring diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml index 4b101447bc0..f7e3733ba0b 100644 --- a/app/views/projects/environments/folder.html.haml +++ b/app/views/projects/environments/folder.html.haml @@ -8,7 +8,4 @@ #environments-folder-list-view{ data: { "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, "can-read-environment" => can?(current_user, :read_environment, @project).to_s, - "css-class" => container_class, - "commit-icon-svg" => custom_icon("icon_commit"), - "terminal-icon-svg" => custom_icon("icon_terminal"), - "play-icon-svg" => custom_icon("icon_play") } } + "css-class" => container_class } } diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 3b45162df52..e8f8fbbcf09 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -5,24 +5,76 @@ = page_specific_javascript_bundle_tag('monitoring') = render "projects/pipelines/head" -%div{ class: container_class } +#js-metrics.prometheus-container{ class: container_class, data: { has_metrics: "#{@environment.has_metrics?}", deployment_endpoint: namespace_project_environment_deployments_path(@project.namespace, @project, @environment, format: :json) } } .top-area .row .col-sm-6 %h3.page-title Environment: - = @environment.name + = link_to @environment.name, environment_path(@environment) - .col-sm-6 - .nav-controls - = render 'projects/deployments/actions', deployment: @environment.last_deployment - .row - .col-sm-12 - %h4 - CPU utilization - %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } - .row - .col-sm-12 - %h4 - Memory usage - %svg.prometheus-graph{ 'graph-type' => 'memory_values' } + .prometheus-state + .js-getting-started.hidden + .row + .col-md-4.col-md-offset-4.state-svg + = render "shared/empty_states/monitoring/getting_started.svg" + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Get started with performance monitoring + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments. + = link_to help_page_path('administration/monitoring/prometheus/index.md') do + Learn more about performance monitoring + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus'), class: 'btn btn-success' do + Configure Prometheus + .js-loading.hidden + .row + .col-md-4.col-md-offset-4.state-svg + = render "shared/empty_states/monitoring/loading.svg" + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Waiting for performance data + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available. + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + = link_to help_page_path('administration/monitoring/prometheus/index.md'), class: 'btn btn-success' do + View documentation + .js-unable-to-connect.hidden + .row + .col-md-4.col-md-offset-4.state-svg + = render "shared/empty_states/monitoring/unable_to_connect.svg" + .row + .col-md-6.col-md-offset-3 + %h4.text-center.state-title + Unable to connect to Prometheus server + .row + .col-md-6.col-md-offset-3 + .description-text.text-center.state-description + Ensure connectivity is available from the GitLab server to the + = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus') do + Prometheus server + .row.state-button-section + .col-md-4.col-md-offset-4.text-center.state-button + = link_to help_page_path('administration/monitoring/prometheus/index.md'), class:'btn btn-success' do + View documentation + + .prometheus-graphs + .row + .col-sm-12 + %h4 + CPU utilization + %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } + .row + .col-sm-12 + %h4 + Memory usage + %svg.prometheus-graph{ 'graph-type' => 'memory_values' } diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index f463a429f65..7315e671056 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -4,13 +4,13 @@ %div{ class: container_class } .top-area.adjust - .col-md-9 + .col-md-7 %h3.page-title= @environment.name - .col-md-3 + .col-md-5 .nav-controls - = render 'projects/environments/metrics_button', environment: @environment = render 'projects/environments/terminal_button', environment: @environment = render 'projects/environments/external_url', environment: @environment + = render 'projects/environments/metrics_button', environment: @environment - if can?(current_user, :update_environment, @environment) = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' - if can?(current_user, :create_deployment, @environment) && @environment.can_stop? diff --git a/app/views/projects/environments/terminal.html.haml b/app/views/projects/environments/terminal.html.haml index c8363087d6a..4c4aa0baff3 100644 --- a/app/views/projects/environments/terminal.html.haml +++ b/app/views/projects/environments/terminal.html.haml @@ -16,8 +16,9 @@ .col-sm-6 .nav-controls - = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do - = icon('external-link') + - if @environment.external_url.present? + = link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do + = icon('external-link') = render 'projects/deployments/actions', deployment: @environment.last_deployment .terminal-container{ class: container_class } diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 4cdb44325b3..be0462f91cd 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -1,4 +1,5 @@ - page_title "Find File", @ref += render "projects/commits/head" .file-finder-holder.tree-holder.clearfix .nav-block diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml index 98d81308407..524b77783ef 100644 --- a/app/views/projects/forks/error.html.haml +++ b/app/views/projects/forks/error.html.haml @@ -22,4 +22,4 @@ %p = link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork", class: "btn" do %i.fa.fa-code-fork - Try to Fork again + Try to fork again diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index 07fb80750d6..b23bbadbdb4 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -4,7 +4,6 @@ - retried = local_assigns.fetch(:retried, false) - pipeline_link = local_assigns.fetch(:pipeline_link, false) - stage = local_assigns.fetch(:stage, false) -- coverage = local_assigns.fetch(:coverage, false) %tr.generic_commit_status{ class: ('retried' if retried) } %td.status @@ -28,7 +27,7 @@ = custom_icon("icon_commit") - if commit_sha - = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-id monospace" + = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "commit-sha" - if retried = icon('warning', class: 'text-warning has-tooltip', title: 'Status was retried.') @@ -49,7 +48,7 @@ - if generic_commit_status.pipeline.user = user_avatar(user: generic_commit_status.pipeline.user, size: 20) - else - %span.monospace API + %span.api API - if admin %td @@ -80,7 +79,7 @@ %span= time_ago_with_tooltip(generic_commit_status.finished_at) %td.coverage - - if coverage && generic_commit_status.try(:coverage) + - if generic_commit_status.try(:coverage) #{generic_commit_status.coverage}% %td diff --git a/app/views/projects/group_links/_index.html.haml b/app/views/projects/group_links/_index.html.haml index b6116dbec41..debb0214d06 100644 --- a/app/views/projects/group_links/_index.html.haml +++ b/app/views/projects/group_links/_index.html.haml @@ -6,11 +6,9 @@ %p Projects can be stored in only one group at once. However you can share a project with other groups here. .col-lg-9 - %h5.prepend-top-0 - Set a group to share = form_tag namespace_project_group_links_path(@project.namespace, @project), class: 'js-requires-input', method: :post do .form-group - = label_tag :link_group_id, "Group", class: "label-light" + = label_tag :link_group_id, "Select a group to share with", class: "label-light" = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, required: true) .form-group = label_tag :link_group_access, "Max access level", class: "label-light" diff --git a/app/views/projects/hooks/_index.html.haml b/app/views/projects/hooks/_index.html.haml index 8faad351463..676b7c345bc 100644 --- a/app/views/projects/hooks/_index.html.haml +++ b/app/views/projects/hooks/_index.html.haml @@ -1 +1,23 @@ -= render 'shared/web_hooks/form', hook: @hook, hooks: @hooks, url_components: [@project.namespace.becomes(Namespace), @project] +.row.prepend-top-default + .col-lg-3 + %h4.prepend-top-0 + = page_title + %p + #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be + used for binding events when something is happening within the project. + + .col-lg-9.append-bottom-default + = form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f| + = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } + = f.submit 'Add webhook', class: 'btn btn-create' + + %hr + %h5.prepend-top-default + Webhooks (#{@hooks.count}) + - if @hooks.any? + %ul.well-list + - @hooks.each do |hook| + = render 'project_hook', hook: hook + - else + %p.settings-message.text-center.append-bottom-0 + No webhooks found, add one in the form above. diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml new file mode 100644 index 00000000000..7998713be1f --- /dev/null +++ b/app/views/projects/hooks/edit.html.haml @@ -0,0 +1,14 @@ += render 'projects/settings/head' + +.row.prepend-top-default + .col-lg-3 + %h4.prepend-top-0 + = page_title + %p + #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be + used for binding events when something is happening within the project. + .col-lg-9.append-bottom-default + = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: namespace_project_hook_path do |f| + = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } + = f.submit 'Save changes', class: 'btn btn-create' + diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml index 2cd8d03e30e..25a87411cac 100644 --- a/app/views/projects/imports/new.html.haml +++ b/app/views/projects/imports/new.html.haml @@ -10,7 +10,7 @@ .panel-body %pre :preserve - #{sanitize_repo_path(@project, @project.import_error)} + #{h(sanitize_repo_path(@project, @project.import_error))} = form_for @project, url: namespace_project_import_path(@project.namespace, @project), method: :post, html: { class: 'form-horizontal' } do |f| = render "shared/import_form", f: f diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 5d4e593e4ef..4dfda54feb5 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -4,4 +4,4 @@ = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' #notes - = render 'projects/notes/notes_with_form' + = render 'shared/notes/notes_with_form' diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 0e3902c066a..c184e0e0022 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -13,9 +13,9 @@ %li CLOSED - - if issue.assignee + - if issue.assignees.any? %li - = link_to_member(@project, issue.assignee, name: false, title: "Assigned to :name") + = render 'shared/issuable/assignees', project: @project, issue: issue = render 'shared/issuable_meta_data', issuable: issue diff --git a/app/views/projects/issues/_issue_by_email.html.haml b/app/views/projects/issues/_issue_by_email.html.haml index d2038a2be68..da65157a10b 100644 --- a/app/views/projects/issues/_issue_by_email.html.haml +++ b/app/views/projects/issues/_issue_by_email.html.haml @@ -16,7 +16,7 @@ .email-modal-input-group.input-group = text_field_tag :issue_email, email, class: "monospace js-select-on-focus form-control", readonly: true .input-group-btn - = clipboard_button(clipboard_target: '#issue_email') + = clipboard_button(target: '#issue_email') %p The subject will be used as the title of the new issue, and the message will be the description. diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 13e2150f997..dba092c8844 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,9 +1,29 @@ - if can?(current_user, :push_code, @project) - .pull-right - #new-branch.new-branch{ 'data-path' => can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue) } - = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid), - method: :post, class: 'btn btn-new btn-inverted btn-grouped has-tooltip available hide', title: @issue.to_branch_name do - New branch - = link_to '#', class: 'unavailable btn btn-grouped hide', disabled: 'disabled' do - = icon('exclamation-triangle') - New branch unavailable + .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue), create_mr_path: create_merge_request_namespace_project_issue_path(@project.namespace, @project, @issue), create_branch_path: namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } } + .btn-group.unavailable + %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } + = icon('spinner', class: 'fa-spin') + %span.text + Checking branch availability… + .btn-group.available.hide + %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: 'Create a merge request', data: { action: 'create-mr' } } + %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } } + = icon('caret-down') + %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } } + %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a merge request + %span + Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'. + %li.divider.droplab-item-ignore + %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a branch + %span + Creates a branch named after this issue, from '#{@project.default_branch}'. diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 1892ebb512f..8c9f6f3b4df 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -11,5 +11,4 @@ = render_pipeline_status(pipeline) %span.related-branch-info %strong - = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "label-branch" do - = branch + = link_to branch, namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "ref-name" diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index f3a429d12d9..60900e9d660 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -7,7 +7,8 @@ = render "projects/issues/head" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('filtered_search') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues") @@ -24,9 +25,9 @@ issue: { assignee_id: issues_finder.assignee.try(:id), milestone_id: issues_finder.milestones.first.try(:id) }), class: "btn btn-new", - title: "New Issue", + title: "New issue", id: "new_issue_link" do - New Issue + New issue = render 'shared/issuable/search_bar', type: :issues .issues-holder diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 6ac05bf3afe..b2401442620 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -49,19 +49,19 @@ = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' - .issue-details.issuable-details - .detail-page-description.content-block{ class: ('hide-bottom-border' unless @issue.description.present? ) } - %h2.title - = markdown_field(@issue, :title) - - if @issue.description.present? - .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } - .wiki - = preserve do - = markdown_field(@issue, :description) - %textarea.hidden.js-task-list-field - = @issue.description - = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') + .detail-page-description.content-block + #js-issuable-app{ "data" => { "endpoint" => realtime_changes_namespace_project_issue_path(@project.namespace, @project, @issue), + "can-update" => can?(current_user, :update_issue, @issue).to_s, + "issuable-ref" => @issue.to_reference, + } } + %h2.title= markdown_field(@issue, :title) + - if @issue.description.present? + .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } + .wiki= markdown_field(@issue, :description) + %textarea.hidden.js-task-list-field= @issue.description + + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. @@ -70,10 +70,16 @@ // This element is filled in using JavaScript. .content-block.content-block-small - = render 'new_branch' unless @issue.confidential? - = render 'award_emoji/awards_block', awardable: @issue, inline: true + .row + .col-sm-6 + = render 'award_emoji/awards_block', awardable: @issue, inline: true + .col-sm-6.new-branch-col + = render 'new_branch' unless @issue.confidential? %section.issuable-discussion = render 'projects/issues/discussion' = render 'shared/issuable/sidebar', issuable: @issue + += page_specific_javascript_bundle_tag('common_vue') += page_specific_javascript_bundle_tag('issue_show') diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml index a80a07b52e6..7f0059cdcda 100644 --- a/app/views/projects/labels/edit.html.haml +++ b/app/views/projects/labels/edit.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "Edit", @label.name, "Labels" -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } %h3.page-title diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 8d4a91cb64c..fc72c4fb635 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -1,10 +1,7 @@ - @no_container = true - page_title "Labels" - hide_class = '' -= render "projects/issues/head" - -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? += render "shared/mr_head" - if @labels.exists? || @prioritized_labels.exists? %div{ class: container_class } diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml index f0d9be744d1..8f6c085a361 100644 --- a/app/views/projects/labels/new.html.haml +++ b/app/views/projects/labels/new.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "New Label" -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } %h3.page-title diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index cfb44bd206c..2e6420db212 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -1,11 +1,11 @@ - content_for :note_actions do - if can?(current_user, :update_merge_request, @merge_request) - if @merge_request.open? - = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"} + = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"} - if @merge_request.reopenable? - = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} + = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} %comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" } %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } {{ buttonText }} -#notes= render "projects/notes/notes_with_form" +#notes= render "shared/notes/notes_with_form" diff --git a/app/views/projects/merge_requests/_head.html.haml b/app/views/projects/merge_requests/_head.html.haml new file mode 100644 index 00000000000..b7f73fe5339 --- /dev/null +++ b/app/views/projects/merge_requests/_head.html.haml @@ -0,0 +1,21 @@ += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } + = nav_link(controller: :merge_requests) do + = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do + %span + List + + - if project_nav_tab? :labels + = nav_link(controller: :labels) do + = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do + %span + Labels + + - if project_nav_tab? :milestones + = nav_link(controller: :milestones) do + = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do + %span + Milestones diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 11b7aaec704..94b9577e9eb 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -37,7 +37,7 @@ by #{link_to_member(@project, merge_request.author, avatar: false)} - if merge_request.target_project.default_branch != merge_request.target_branch - = link_to namespace_project_commits_path(merge_request.project.namespace, merge_request.project, merge_request.target_branch) do + = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do = icon('code-fork') = merge_request.target_branch diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml index fe82f751f53..4e97f74dd6a 100644 --- a/app/views/projects/merge_requests/_merge_requests.html.haml +++ b/app/views/projects/merge_requests/_merge_requests.html.haml @@ -1,8 +1,8 @@ %ul.content-list.mr-list.issuable-list - = render @merge_requests - - if @merge_requests.blank? - %li - .nothing-here-block No merge requests to show + - if @merge_requests.exists? + = render @merge_requests + - else + = render 'shared/empty_states/merge_requests' - if @merge_requests.present? = paginate @merge_requests, theme: "gitlab" diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml index 8d134aaac67..0f37abb579c 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/_new_compare.html.haml @@ -21,8 +21,8 @@ selected: f.object.source_project_id .merge-request-select.dropdown = f.hidden_field :source_branch - = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } - .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch + = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch.git-revision-dropdown = dropdown_title("Select source branch") = dropdown_filter("Search branches") = dropdown_content do @@ -38,7 +38,7 @@ .panel-heading Target branch .panel-body.clearfix - - projects = @project.forked_from_project.nil? ? [@project] : [@project, @project.forked_from_project] + - projects = target_projects(@project) .merge-request-select.dropdown = f.hidden_field :target_project_id = dropdown_toggle f.object.target_project.path_with_namespace, { toggle: "dropdown", field_name: "#{f.object_name}[target_project_id]", disabled: @merge_request.persisted? }, { toggle_class: "js-compare-dropdown js-target-project" } @@ -51,8 +51,8 @@ selected: f.object.target_project_id .merge-request-select.dropdown = f.hidden_field :target_branch - = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch" } - .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown + = dropdown_toggle f.object.target_branch, { toggle: "dropdown", field_name: "#{f.object_name}[target_branch]" }, { toggle_class: "js-compare-dropdown js-target-branch git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.dropdown-target-branch.js-target-branch-dropdown.git-revision-dropdown = dropdown_title("Select target branch") = dropdown_filter("Search branches") = dropdown_content do diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index e7fcac4c477..e3ecbee5490 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -3,9 +3,9 @@ %p.slead - source_title, target_title = format_mr_branch_names(@merge_request) From - %strong.label-branch= source_title + %strong.ref-name= source_title %span into - %strong.label-branch= target_title + %strong.ref-name= target_title %span.pull-right = link_to 'Change branches', mr_change_branches_path(@merge_request) @@ -46,12 +46,13 @@ -# This tab is always loaded via AJAX - if @pipelines.any? #pipelines.pipelines.tab-pane - = render 'projects/merge_requests/show/pipelines', endpoint: url_for(params.merge(format: :json)) + = render 'projects/merge_requests/show/pipelines', endpoint: url_for(params.merge(format: :json)), disable_initialization: true .mr-loading-status = spinner :javascript var merge_request = new MergeRequest({ - action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}" + action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}", + setUrl: false, }); diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 881ee9fd596..75120409bb3 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -1,91 +1,69 @@ - @content_class = "limit-container-width" unless fluid_layout -- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" -- page_description @merge_request.description +- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" +- page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('diff_notes') -.merge-request{ 'data-url' => merge_request_path(@merge_request), 'data-project-path' => project_path(@merge_request.project) } +.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) } = render "projects/merge_requests/show/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/show/mr_box" - .append-bottom-default.mr-source-target.prepend-top-default - - if @merge_request.open? - .pull-right - - if @merge_request.source_branch_exists? - - if koding_enabled? && @repository.koding_yml - = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank', rel: 'noopener noreferrer' do - Run in IDE (Koding) - = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do - Check out branch - - %span.dropdown.inline.prepend-left-5 - %a.btn.btn-sm.dropdown-toggle{ data: {toggle: :dropdown} } - Download as - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch) - %li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff) - .normal - %span <b>Request to merge</b> - %span.label-branch= source_branch_with_namespace(@merge_request) - %span <b>into</b> - %span.label-branch - = link_to_if @merge_request.target_branch_exists?, @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch) - - if @merge_request.open? && @merge_request.diverged_from_target_branch? - %span (#{pluralize(@merge_request.diverged_commits_count, 'commit')} behind) - if @merge_request.source_branch_exists? = render "projects/merge_requests/show/how_to_merge" - = render "projects/merge_requests/widget/show.html.haml" + :javascript + window.gl.mrWidgetData = #{serialize_issuable(@merge_request)} + + #js-vue-mr-widget.mr-widget - - if @merge_request.source_branch_exists? && @merge_request.mergeable? && @merge_request.can_be_merged_by?(current_user) - .merge-manually.light.prepend-top-default - You can also accept this merge request manually using the - = succeed '.' do - = link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal" + - content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'vue_merge_request_widget' .content-block.content-block-small.emoji-list-container = render 'award_emoji/awards_block', awardable: @merge_request, inline: true .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } - .merge-request-tabs-container.scrolling-tabs-container.inner-page-scroll-tabs - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - %ul.merge-request-tabs.nav-links.scrolling-tabs - %li.notes-tab - = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do - Discussion - %span.badge= @merge_request.related_notes.user.count - - if @merge_request.source_project - %li.commits-tab - = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do - Commits - %span.badge= @commits_count - - if @pipelines.any? - %li.pipelines-tab - = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do - Pipelines - %span.badge= @pipelines.size - %li.diffs-tab - = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do - Changes - %span.badge= @merge_request.diff_size - %li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true } - %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } - %div - .line-resolve-all{ "v-show" => "discussionCount > 0", - ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } - %span.line-resolve-btn.is-disabled{ type: "button", - ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } - = render "shared/icons/icon_status_success.svg" - %span.line-resolve-text - {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved - = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request - = render "discussions/jump_to_next" + .merge-request-tabs-container + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + .nav-links.scrolling-tabs + %ul.merge-request-tabs + %li.notes-tab + = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do + Discussion + %span.badge= @merge_request.related_notes.user.count + - if @merge_request.source_project + %li.commits-tab + = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do + Commits + %span.badge= @commits_count + - if @pipelines.any? + %li.pipelines-tab + = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do + Pipelines + %span.badge= @pipelines.size + %li.diffs-tab + = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do + Changes + %span.badge= @merge_request.diff_size + #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } + %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } + %div + .line-resolve-all{ "v-show" => "discussionCount > 0", + ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } + %span.line-resolve-btn.is-disabled{ type: "button", + ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } + = render "shared/icons/icon_status_success.svg" + %span.line-resolve-text + {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved + = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request + = render "discussions/jump_to_next" .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes @@ -113,9 +91,7 @@ :javascript $(function () { - new MergeRequest({ + window.mergeRequest = new MergeRequest({ action: "#{controller.action_name}" }); }); - - var mrRefreshWidgetUrl = "#{mr_widget_refresh_url(@merge_request)}"; diff --git a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml b/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml deleted file mode 100644 index eab5be488b5..00000000000 --- a/app/views/projects/merge_requests/cancel_merge_when_pipeline_succeeds.js.haml +++ /dev/null @@ -1,2 +0,0 @@ -:plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/accept'))}"); diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 8a96c8dacf6..502220232a1 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -2,21 +2,28 @@ - @bulk_edit = can?(current_user, :admin_merge_request, @project) - page_title "Merge Requests" +- unless @project.default_issues_tracker? + = content_for :sub_nav do + = render "projects/merge_requests/head" = render 'projects/last_push' - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('filtered_search') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'filtered_search' -%div{ class: container_class } - .top-area - = render 'shared/issuable/nav', type: :merge_requests - .nav-controls - - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) - - if merge_project - = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New Merge Request" do - New Merge Request +- if @project.merge_requests.exists? + %div{ class: container_class } + .top-area + = render 'shared/issuable/nav', type: :merge_requests + .nav-controls + - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) + - if merge_project + = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do + New merge request - = render 'shared/issuable/search_bar', type: :merge_requests + = render 'shared/issuable/search_bar', type: :merge_requests - .merge-requests-holder - = render 'merge_requests' + .merge-requests-holder + = render 'merge_requests' +- else + = render 'shared/empty_states/merge_requests', button_path: new_namespace_project_merge_request_path(@project.namespace, @project) diff --git a/app/views/projects/merge_requests/merge.js.haml b/app/views/projects/merge_requests/merge.js.haml deleted file mode 100644 index e632fc681cf..00000000000 --- a/app/views/projects/merge_requests/merge.js.haml +++ /dev/null @@ -1,14 +0,0 @@ -- case @status -- when :success - - remove_source_branch = params[:should_remove_source_branch] == '1' || @merge_request.remove_source_branch? - :plain - merge_request_widget.mergeInProgress(#{remove_source_branch}); -- when :merge_when_pipeline_succeeds - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/merge_when_pipeline_succeeds'))}"); -- when :sha_mismatch - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/sha_mismatch'))}"); -- else - :plain - $('.mr-widget-body').html("#{escape_javascript(render('projects/merge_requests/widget/open/reload'))}"); diff --git a/app/views/projects/merge_requests/show/_how_to_merge.html.haml b/app/views/projects/merge_requests/show/_how_to_merge.html.haml index cde0ce08e14..766cb272bec 100644 --- a/app/views/projects/merge_requests/show/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/show/_how_to_merge.html.haml @@ -8,7 +8,7 @@ %p %strong Step 1. Fetch and check out the branch for this merge request - = clipboard_button(clipboard_target: "pre#merge-info-1", title: "Copy commands to clipboard") + = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard") %pre.dark#merge-info-1 - if @merge_request.for_fork? :preserve @@ -25,7 +25,7 @@ %p %strong Step 3. Merge the branch and fix any conflicts that come up - = clipboard_button(clipboard_target: "pre#merge-info-3", title: "Copy commands to clipboard") + = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard") %pre.dark#merge-info-3 - if @merge_request.for_fork? :preserve @@ -38,7 +38,7 @@ %p %strong Step 4. Push the result of the merge to GitLab - = clipboard_button(clipboard_target: "pre#merge-info-4", title: "Copy commands to clipboard") + = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard") %pre.dark#merge-info-4 :preserve git push origin #{h @merge_request.target_branch} @@ -49,7 +49,7 @@ %strong Tip: = succeed '.' do You can also checkout merge requests locally by - = link_to 'following these guidelines', help_page_path('user/project/merge_requests.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer' + = link_to 'following these guidelines', help_page_path('user/project/merge_requests/index.md', anchor: "checkout-merge-requests-locally"), target: '_blank', rel: 'noopener noreferrer' :javascript $(function(){ diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/show/_mr_box.html.haml index 683cb8a5a27..8a390cf8700 100644 --- a/app/views/projects/merge_requests/show/_mr_box.html.haml +++ b/app/views/projects/merge_requests/show/_mr_box.html.haml @@ -6,8 +6,7 @@ - if @merge_request.description.present? .description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' } .wiki - = preserve do - = markdown_field(@merge_request, :description) + = markdown_field(@merge_request, :description) %textarea.hidden.js-task-list-field = @merge_request.description diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml index de4aa255bbd..2f1dbe87619 100644 --- a/app/views/projects/merge_requests/show/_pipelines.html.haml +++ b/app/views/projects/merge_requests/show/_pipelines.html.haml @@ -1,3 +1,4 @@ - endpoint_path = local_assigns[:endpoint] || pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, format: :json) +- disable_initialization = local_assigns.fetch(:disable_initialization, false) -= render 'projects/commit/pipelines_list', endpoint: endpoint_path += render 'projects/commit/pipelines_list', endpoint: endpoint_path, disable_initialization: disable_initialization diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml index 74a7b1dc498..37117bc64a3 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -20,25 +20,27 @@ - @merge_request_diffs.each do |merge_request_diff| %li = link_to merge_request_version_path(@project, @merge_request, merge_request_diff, @start_sha), class: ('is-active' if merge_request_diff == @merge_request_diff) do - %strong - - if merge_request_diff.latest? - latest version - - else - version #{version_index(merge_request_diff)} - .monospace= short_sha(merge_request_diff.head_commit_sha) - %small - #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)}, - = time_ago_with_tooltip(merge_request_diff.created_at) + %div + %strong + - if merge_request_diff.latest? + latest version + - else + version #{version_index(merge_request_diff)} + %div + %small.commit-sha= short_sha(merge_request_diff.head_commit_sha) + %div + %small + #{number_with_delimiter(merge_request_diff.commits_count)} #{'commit'.pluralize(merge_request_diff.commits_count)}, + = time_ago_with_tooltip(merge_request_diff.created_at) - if @merge_request_diff.base_commit_sha and %span.dropdown.inline.mr-version-compare-dropdown %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} } - %span - - if @start_sha - version #{version_index(@start_version)} - - else - #{@merge_request.target_branch} + - if @start_version + version #{version_index(@start_version)} + - else + %span.ref-name= @merge_request.target_branch = icon('caret-down') .dropdown-menu.dropdown-select.dropdown-menu-selectable .dropdown-title @@ -50,19 +52,25 @@ - @comparable_diffs.each do |merge_request_diff| %li = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff, merge_request_diff.head_commit_sha), class: ('is-active' if merge_request_diff == @start_version) do - %strong - - if merge_request_diff.latest? - latest version - - else - version #{version_index(merge_request_diff)} - .monospace= short_sha(merge_request_diff.head_commit_sha) - %small - = time_ago_with_tooltip(merge_request_diff.created_at) + %div + %strong + - if merge_request_diff.latest? + latest version + - else + version #{version_index(merge_request_diff)} + %div + %small.commit-sha= short_sha(merge_request_diff.head_commit_sha) + %div + %small + = time_ago_with_tooltip(merge_request_diff.created_at) %li - = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_sha) do - %strong - #{@merge_request.target_branch} (base) - .monospace= short_sha(@merge_request_diff.base_commit_sha) + = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do + %div + %strong + %span.ref-name= @merge_request.target_branch + (base) + %div + %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha) - if different_base?(@start_version, @merge_request_diff) .content-block @@ -72,13 +80,18 @@ = link_to namespace_project_compare_path(@project.namespace, @project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do new commits from - %code= @merge_request.target_branch + = succeed '.' do + %code= @merge_request.target_branch - - unless @merge_request_diff.latest? && !@start_sha + - if @start_version || !@merge_request_diff.latest? .comments-disabled-notif.content-block = icon('info-circle') - - if @start_sha - Comments are disabled because you're comparing two versions of this merge request. + Not all comments are displayed because you're + - if @start_version + comparing two versions - else - Comments are disabled because you're viewing an old version of this merge request. - = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' + viewing an old version + of this merge request. + + .pull-right + = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' diff --git a/app/views/projects/merge_requests/widget/_closed.html.haml b/app/views/projects/merge_requests/widget/_closed.html.haml deleted file mode 100644 index 15f47ecf210..00000000000 --- a/app/views/projects/merge_requests/widget/_closed.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - Closed - - if @merge_request.closed_event - by #{link_to_member(@project, @merge_request.closed_event.author, avatar: true)} - #{time_ago_with_tooltip(@merge_request.closed_event.created_at)} - %p - = succeed '.' do - The changes were not merged into - %span.label-branch= @merge_request.target_branch diff --git a/app/views/projects/merge_requests/widget/_commit_change_content.html.haml b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml new file mode 100644 index 00000000000..ad0ce7bf501 --- /dev/null +++ b/app/views/projects/merge_requests/widget/_commit_change_content.html.haml @@ -0,0 +1,4 @@ +- if @merge_request.can_be_reverted?(current_user) + = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title +- if @merge_request.can_be_cherry_picked? + = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml deleted file mode 100644 index 1298376ac25..00000000000 --- a/app/views/projects/merge_requests/widget/_heading.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- if @pipeline - .mr-widget-heading - - %w[success success_with_warnings skipped manual canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) } - %div{ class: "ci-status-icon ci-status-icon-#{status}" } - = link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do - = ci_icon_for_status(status) - %span - Pipeline - = link_to "##{@pipeline.id}", namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'pipeline' - = ci_label_for_status(status) - - if @pipeline.stages.any? - .mr-widget-pipeline-graph - = render 'shared/mini_pipeline_graph', pipeline: @pipeline, klass: 'js-pipeline-inline-mr-widget-graph' - %span - for - = succeed "." do - = link_to @pipeline.short_sha, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, @pipeline.sha), class: "monospace js-commit-link" - %span.ci-coverage - -- elsif @merge_request.has_ci? - -# Compatibility with old CI integrations (ex jenkins) when you request status from CI server via AJAX - -# TODO, remove in later versions when services like Jenkins will set CI status via Commit status API - .mr-widget-heading - - %w[success skipped canceled failed running pending].each do |status| - .ci_widget{ class: "ci-#{status} ci-status-icon-#{status}", style: "display:none" } - = ci_icon_for_status(status) - %span - CI job - = ci_label_for_status(status) - for - - commit = @merge_request.diff_head_commit - = succeed "." do - = link_to commit.short_id, namespace_project_commit_path(@merge_request.source_project.namespace, @merge_request.source_project, commit), class: "monospace" - %span.ci-coverage - - .ci_widget - = icon("spinner spin") - Checking CI status for #{@merge_request.diff_head_commit.short_id}… - - .ci_widget.ci-not_found{ style: "display:none" } - = icon("times-circle") - Could not find CI status for #{@merge_request.diff_head_commit.short_id}. - - .ci_widget.ci-error{ style: "display:none" } - = icon("times-circle") - Could not connect to the CI server. Please check your settings and try again. - -.js-success-icon.hidden - = ci_icon_for_status('success') diff --git a/app/views/projects/merge_requests/widget/_locked.html.haml b/app/views/projects/merge_requests/widget/_locked.html.haml deleted file mode 100644 index 78d0783cba0..00000000000 --- a/app/views/projects/merge_requests/widget/_locked.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - = icon("spinner spin") - Merge in progress… - %p - This merge request is in the process of being merged, during which time it is locked and cannot be closed. - diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml deleted file mode 100644 index adc3bbc37f3..00000000000 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ /dev/null @@ -1,52 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - %h4 - Merged - - if @merge_request.merge_event - by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} - #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - - if !@merge_request.source_branch_exists? || params[:deleted_source_branch] - .remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - %li - %span - The source branch has been removed. - = render 'projects/merge_requests/widget/merged_buttons' - - elsif @merge_request.can_remove_source_branch?(current_user) - .remove_source_branch_widget.remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - %li - %span - You can remove the source branch now. - = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true - .remove_source_branch_widget.failed.remove-message-pipes.hide - %ul - %li - %span - Failed to remove source branch '#{@merge_request.source_branch}'. - .remove_source_branch_in_progress.remove-message-pipes.hide - %ul - %li - %span - = icon('spinner spin') - Removing source branch '#{@merge_request.source_branch}'. - %li - %span - Please wait, this page will be automatically reloaded. - - else - .remove-message-pipes - %ul - %li - %span - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - = render 'projects/merge_requests/widget/merged_buttons' diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml deleted file mode 100644 index caf3bf54eef..00000000000 --- a/app/views/projects/merge_requests/widget/_merged_buttons.haml +++ /dev/null @@ -1,14 +0,0 @@ -- can_remove_source_branch = local_assigns.fetch(:source_branch_exists, false) && @merge_request.can_remove_source_branch?(current_user) -- mr_can_be_reverted = @merge_request.can_be_reverted?(current_user) -- mr_can_be_cherry_picked = @merge_request.can_be_cherry_picked? - -- if can_remove_source_branch || mr_can_be_reverted || mr_can_be_cherry_picked - .clearfix.merged-buttons - - if can_remove_source_branch - = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default remove_source_branch" do - = icon('trash-o') - Remove Source Branch - - if mr_can_be_reverted - = revert_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "close") - - if mr_can_be_cherry_picked - = cherry_pick_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "default") diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml deleted file mode 100644 index bc426f1dc0c..00000000000 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ /dev/null @@ -1,47 +0,0 @@ -.mr-state-widget - = render 'projects/merge_requests/widget/heading' - .mr-widget-body - -# After conflicts are resolved, the user is redirected back to the MR page. - -# There is a short window before background workers run and GitLab processes - -# the new push and commits, during which it will think the conflicts still exist. - -# We send this param to get the widget to treat the MR as having no more conflicts. - - resolved_conflicts = params[:resolved_conflicts] - - - if @project.archived? - = render 'projects/merge_requests/widget/open/archived' - - elsif @merge_request.branch_missing? - = render 'projects/merge_requests/widget/open/missing_branch' - - elsif @merge_request.has_no_commits? - = render 'projects/merge_requests/widget/open/nothing' - - elsif @merge_request.unchecked? - = render 'projects/merge_requests/widget/open/check' - - elsif @merge_request.cannot_be_merged? && !resolved_conflicts - = render 'projects/merge_requests/widget/open/conflicts' - - elsif @merge_request.work_in_progress? - = render 'projects/merge_requests/widget/open/wip' - - elsif @merge_request.merge_when_pipeline_succeeds? - = render 'projects/merge_requests/widget/open/merge_when_pipeline_succeeds' - - elsif !@merge_request.can_be_merged_by?(current_user) - = render 'projects/merge_requests/widget/open/not_allowed' - - elsif !@merge_request.mergeable_ci_state? && (@pipeline.failed? || @pipeline.canceled?) - = render 'projects/merge_requests/widget/open/build_failed' - - elsif !@merge_request.mergeable_discussions_state? - = render 'projects/merge_requests/widget/open/unresolved_discussions' - - elsif @pipeline&.blocked? - = render 'projects/merge_requests/widget/open/manual' - - elsif @merge_request.can_be_merged? || resolved_conflicts - = render 'projects/merge_requests/widget/open/accept' - - - if mr_closes_issues.present? || mr_issues_mentioned_but_not_closing.present? - .mr-widget-footer - %span - = icon('check') - - if mr_closes_issues.present? - Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)} - = succeed '.' do - != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author - = mr_assign_issues_link - - if mr_issues_mentioned_but_not_closing.present? - #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} - != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author - #{mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is'} mentioned but will not be closed. diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml deleted file mode 100644 index 0b0fb7854c2..00000000000 --- a/app/views/projects/merge_requests/widget/_show.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -- if @merge_request.open? - = render 'projects/merge_requests/widget/open' -- elsif @merge_request.merged? - = render 'projects/merge_requests/widget/merged' -- elsif @merge_request.closed? - = render 'projects/merge_requests/widget/closed' -- elsif @merge_request.locked? - = render 'projects/merge_requests/widget/locked' - -:javascript - var opts = { - merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - check_enable: #{@merge_request.unchecked? ? "true" : "false"}, - ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - ci_environments_status_url: "#{ci_environments_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", - gitlab_icon: "#{asset_path 'gitlab_logo.png'}", - ci_status: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.status : ''}", - ci_message: { - normal: "Pipeline {{status}} for \"{{title}}\"", - preparing: "{{status}} pipeline for \"{{title}}\"" - }, - ci_enable: #{@project.ci_service ? "true" : "false"}, - ci_title: { - preparing: "{{status}} pipeline", - normal: "Pipeline {{status}}" - }, - ci_sha: "#{@merge_request.head_pipeline ? @merge_request.head_pipeline.short_sha : ''}", - ci_pipeline: #{@merge_request.head_pipeline.try(:id).to_json}, - commits_path: "#{project_commits_path(@project)}", - pipeline_path: "#{project_pipelines_path(@project)}", - pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" - }; - - if (typeof merge_request_widget !== 'undefined') { - merge_request_widget.cancelPolling(); - merge_request_widget.clearEventListeners(); - } - - merge_request_widget = new window.gl.MergeRequestWidget(opts); diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml deleted file mode 100644 index e5ec151a61d..00000000000 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ /dev/null @@ -1,50 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -= form_for [:merge, @project.namespace.becomes(Namespace), @project, @merge_request], remote: true, method: :post, html: { class: 'accept-mr-form js-quick-submit js-requires-input' } do |f| - = hidden_field_tag :authenticity_token, form_authenticity_token - = hidden_field_tag :sha, @merge_request.diff_head_sha - .accept-merge-holder.clearfix.js-toggle-container - .clearfix - .accept-action - - if @pipeline && @pipeline.active? - %span.btn-group - = button_tag class: "btn btn-info js-merge-when-pipeline-succeeds-button merge-when-pipeline-succeeds" do - Merge When Pipeline Succeeds - - unless @project.only_allow_merge_if_pipeline_succeeds? - = button_tag class: "btn btn-info dropdown-toggle", 'data-toggle' => 'dropdown' do - = icon('caret-down') - %span.sr-only - Select Merge Moment - %ul.js-merge-dropdown.dropdown-menu.dropdown-menu-right{ role: 'menu' } - %li - = link_to "#", class: "merge_when_pipeline_succeeds" do - = icon('check fw') - Merge When Pipeline Succeeds - %li - = link_to "#", class: "accept-merge-request" do - = icon('warning fw') - Merge Immediately - - else - = f.button class: "btn btn-grouped js-merge-button accept-merge-request" do - Accept Merge Request - - if @merge_request.force_remove_source_branch? - .accept-control - The source branch will be removed. - - elsif @merge_request.can_remove_source_branch?(current_user) - .accept-control.checkbox - = label_tag :should_remove_source_branch, class: "merge-param-checkbox" do - = check_box_tag :should_remove_source_branch - Remove source branch - .accept-control - %button.modify-merge-commit-link.js-toggle-button{ type: "button" } - = icon('edit') - Modify commit message - .js-toggle-content.hide.prepend-top-default - = render 'shared/commit_message_container', params: params, - message_with_description: @merge_request.merge_commit_message(include_description: true), - message_without_description: @merge_request.merge_commit_message, - text: @merge_request.merge_commit_message, - rows: 14, hint: true - - = hidden_field_tag :merge_when_pipeline_succeeds, "", autocomplete: "off" diff --git a/app/views/projects/merge_requests/widget/open/_archived.html.haml b/app/views/projects/merge_requests/widget/open/_archived.html.haml deleted file mode 100644 index 0d61e56d8fb..00000000000 --- a/app/views/projects/merge_requests/widget/open/_archived.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%h4 - Project is archived -%p - This merge request cannot be merged because archived projects cannot be written to. diff --git a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml b/app/views/projects/merge_requests/widget/open/_build_failed.html.haml deleted file mode 100644 index 3979d5fa8ed..00000000000 --- a/app/views/projects/merge_requests/widget/open/_build_failed.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon('exclamation-triangle') - The pipeline for this merge request failed - -%p - Please retry the job or push a new commit to fix the failure. diff --git a/app/views/projects/merge_requests/widget/open/_check.html.haml b/app/views/projects/merge_requests/widget/open/_check.html.haml deleted file mode 100644 index 909dc52fc06..00000000000 --- a/app/views/projects/merge_requests/widget/open/_check.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -%strong - = icon("spinner spin") - Checking ability to merge automatically… diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml deleted file mode 100644 index 621ee313026..00000000000 --- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -- can_resolve = @merge_request.conflicts_can_be_resolved_by?(current_user) -- can_resolve_in_ui = @merge_request.conflicts_can_be_resolved_in_ui? -- can_merge = @merge_request.can_be_merged_via_command_line_by?(current_user) - -%h4.has-conflicts - %p - = icon("exclamation-triangle") - This merge request contains merge conflicts - -.remove-message-pipes - %ul - %li - %span - To merge this request, resolve these conflicts - - if can_resolve && !can_resolve_in_ui - locally - or - - unless can_merge - ask someone with write access to this repository to - merge it locally. - -- if (can_resolve && can_resolve_in_ui) || can_merge - .merged-buttons.clearfix - - if can_resolve && can_resolve_in_ui - = link_to "Resolve conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "btn" - - if can_merge - = link_to "Merge locally", "#modal_merge_info", class: "btn how_to_merge_link vlink", "data-toggle" => "modal" diff --git a/app/views/projects/merge_requests/widget/open/_error.html.haml b/app/views/projects/merge_requests/widget/open/_error.html.haml new file mode 100644 index 00000000000..bbdc053609f --- /dev/null +++ b/app/views/projects/merge_requests/widget/open/_error.html.haml @@ -0,0 +1,6 @@ +%h4 + = icon('exclamation-triangle') + This merge request failed to be merged automatically + +%p + = @merge_request.merge_error diff --git a/app/views/projects/merge_requests/widget/open/_manual.html.haml b/app/views/projects/merge_requests/widget/open/_manual.html.haml deleted file mode 100644 index 9078b7e21dd..00000000000 --- a/app/views/projects/merge_requests/widget/open/_manual.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%h4 - Pipeline blocked -%p - The pipeline for this merge request requires a manual action to proceed. diff --git a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml b/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml deleted file mode 100644 index 5f347acce4d..00000000000 --- a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml +++ /dev/null @@ -1,33 +0,0 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('merge_request_widget') - -%h4 - Set by #{link_to_member(@project, @merge_request.merge_user, avatar: true)} - to be merged automatically when the pipeline succeeds. -.remove-message-pipes - %ul - %li - %span - = succeed '.' do - The changes will be merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"} - - if @merge_request.remove_source_branch? - %li - %span - The source branch will be removed. - - else - %li - %span - The source branch will not be removed. - - - remove_source_branch_button = !@merge_request.remove_source_branch? && @merge_request.can_remove_source_branch?(current_user) && @merge_request.merge_user == current_user - - user_can_cancel_automatic_merge = @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user) - - if remove_source_branch_button || user_can_cancel_automatic_merge - .clearfix.prepend-top-10 - - if remove_source_branch_button - = link_to merge_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request, merge_params(@merge_request)), remote: true, method: :post, class: "btn btn-grouped btn-primary btn-sm remove_source_branch" do - = icon('times') - Remove Source Branch When Merged - - - if user_can_cancel_automatic_merge - = link_to cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request), remote: true, method: :post, class: "btn btn-grouped btn-sm" do - Cancel Automatic Merge diff --git a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml b/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml deleted file mode 100644 index c9f07629493..00000000000 --- a/app/views/projects/merge_requests/widget/open/_missing_branch.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -- unless @merge_request.source_branch_exists? - %h4 - = icon("exclamation-triangle") - Source branch - %span.label-branch= source_branch_with_namespace(@merge_request) - does not exist - %p - Please restore the source branch or close this merge request and open a new merge request with a different source branch. -- else - %h4 - = icon("exclamation-triangle") - Target branch - %span.label-branch= @merge_request.target_branch - does not exist - %p - Please restore the target branch or use a different target branch. diff --git a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml b/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml deleted file mode 100644 index 57ce1959021..00000000000 --- a/app/views/projects/merge_requests/widget/open/_not_allowed.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - Ready to be merged automatically -%p - Ask someone with write access to this repository to merge this request. - - if @merge_request.force_remove_source_branch? - The source branch will be removed. diff --git a/app/views/projects/merge_requests/widget/open/_nothing.html.haml b/app/views/projects/merge_requests/widget/open/_nothing.html.haml deleted file mode 100644 index 7af8c01c134..00000000000 --- a/app/views/projects/merge_requests/widget/open/_nothing.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -%h4 - = icon("exclamation-triangle") - Nothing to merge from - %span.label-branch= source_branch_with_namespace(@merge_request) - into - %span.label-branch= @merge_request.target_branch -%p - Please push new commits to the source branch or use a different target branch. diff --git a/app/views/projects/merge_requests/widget/open/_reload.html.haml b/app/views/projects/merge_requests/widget/open/_reload.html.haml deleted file mode 100644 index acfc31725eb..00000000000 --- a/app/views/projects/merge_requests/widget/open/_reload.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon("exclamation-triangle") - This merge request failed to be merged automatically - -%p - Please reload the page to find out the reason. diff --git a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml b/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml deleted file mode 100644 index 499624f8dd8..00000000000 --- a/app/views/projects/merge_requests/widget/open/_sha_mismatch.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%h4 - = icon("exclamation-triangle") - This merge request has received new commits since the page was loaded. - -%p - Please reload the page to review the new commits before merging. diff --git a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml b/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml deleted file mode 100644 index ec9346ce89b..00000000000 --- a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%h4 - = icon('exclamation-triangle') - This merge request has unresolved discussions - -%p - Please resolve these discussions - - if @project.issues_enabled? && can?(current_user, :create_issue, @project) - or - = link_to "open an issue to resolve them later", new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: @merge_request.iid) - to allow this merge request to be merged. diff --git a/app/views/projects/merge_requests/widget/open/_wip.html.haml b/app/views/projects/merge_requests/widget/open/_wip.html.haml deleted file mode 100644 index c296422a9cf..00000000000 --- a/app/views/projects/merge_requests/widget/open/_wip.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -%h4 - This merge request is currently a Work In Progress - -- if can?(current_user, :update_merge_request, @merge_request) - %p - When this merge request is ready, - = link_to remove_wip_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), method: :post do - remove the - %code WIP: - prefix from the title - to allow it to be merged. diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 0f4a8508751..9a95b2a82ff 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -9,9 +9,9 @@ .form-group.milestone-description = f.label :description, "Description", class: "control-label" .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...' - = render 'projects/notes/hints' + = render 'shared/notes/hints' .clearfix .error-alert = render "shared/milestones/form_dates", f: f diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index 55b0b837c6d..1e66c6079e3 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -1,11 +1,11 @@ - @no_container = true - page_title "Edit", @milestone.title, "Milestones" -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } %h3.page-title - Edit Milestone #{@milestone.to_reference} + Edit Milestone %hr diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index b6340a00b29..e1096bd1d67 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title 'Milestones' -= render 'projects/issues/head' += render "shared/mr_head" %div{ class: container_class } .top-area @@ -9,8 +9,8 @@ .nav-controls = render 'shared/milestones_sort_dropdown' - if can?(current_user, :admin_milestone, @project) - = link_to new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New Milestone' do - New Milestone + = link_to new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New milestone' do + New milestone .milestones %ul.content-list diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml index cda093ade81..586eb909afa 100644 --- a/app/views/projects/milestones/new.html.haml +++ b/app/views/projects/milestones/new.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "New Milestone" -= render "projects/issues/head" += render "shared/mr_head" %div{ class: container_class } %h3.page-title diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index f612b5c7d6b..4b692aba11c 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -1,10 +1,7 @@ - @no_container = true - page_title @milestone.title, "Milestones" - page_description @milestone.description -= render "projects/issues/head" - -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? += render "shared/mr_head" %div{ class: container_class } .detail-page-header.milestone-page-header @@ -20,15 +17,15 @@ .header-text-content %span.identifier %strong - Milestone #{@milestone.to_reference} + Milestone - if @milestone.due_date || @milestone.start_date = milestone_date_range(@milestone) .milestone-buttons - if can?(current_user, :admin_milestone, @project) - if @milestone.active? - = link_to 'Close Milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" + = link_to 'Close milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" - else - = link_to 'Reopen Milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped" + = link_to 'Reopen milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped" = link_to edit_namespace_project_milestone_path(@project.namespace, @project, @milestone), class: "btn btn-grouped btn-nr" do Edit @@ -39,15 +36,14 @@ %a.btn.btn-default.btn-grouped.pull-right.visible-xs-block.js-sidebar-toggle{ href: "#" } = icon('angle-double-left') - .detail-page-description.milestone-detail{ class: ('hide-bottom-border' unless @milestone.description.present? ) } + .detail-page-description.milestone-detail %h2.title = markdown_field(@milestone, :title) %div - if @milestone.description.present? .description .wiki - = preserve do - = markdown_field(@milestone, :description) + = markdown_field(@milestone, :description) - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? .alert.alert-success.prepend-top-default diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 09ac1fd6794..e180cb8bad1 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -30,7 +30,7 @@ #{root_url}#{current_user.username}/ = f.hidden_field :namespace_id, value: current_user.namespace_id .form-group.col-xs-12.col-sm-6.project-path - = f.label :namespace_id, class: 'label-light' do + = f.label :path, class: 'label-light' do %span Project name = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true @@ -78,7 +78,7 @@ - if git_import_enabled? %button.btn.js-toggle-button.import_git{ type: "button" } = icon('git', text: 'Repo by URL') - .import_gitlab_project + .import_gitlab_project.has-tooltip{ data: { container: 'body' } } - if gitlab_project_import_enabled? = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do = icon('gitlab', text: 'GitLab export') @@ -109,6 +109,9 @@ %p Please wait a moment, this page will automatically refresh when ready. :javascript + var importBtnTooltip = "Please enter a valid project name."; + var $importBtnWrapper = $('.import_gitlab_project'); + $('.how_to_import_link').bind('click', function (e) { e.preventDefault(); var import_modal = $(this).next(".modal").show(); @@ -123,15 +126,8 @@ $(".btn_import_gitlab_project").attr("href", _href + '?namespace_id=' + $("#project_namespace_id").val() + '&path=' + $("#project_path").val()); }); - $('.btn_import_gitlab_project').attr('disabled',true) - $('.import_gitlab_project').attr('title', 'Project path and name required.'); - - $('.import_gitlab_project').click(function( event ) { - if($('.btn_import_gitlab_project').attr('disabled')) { - event.preventDefault(); - new Flash("Please enter path and name for the project to be imported to."); - } - }); + $('.btn_import_gitlab_project').attr('disabled', $('#project_path').val().trim().length === 0); + $importBtnWrapper.attr('title', importBtnTooltip); $('#new_project').submit(function(){ var $path = $('#project_path'); @@ -139,17 +135,18 @@ }); $('#project_path').keyup(function(){ - if($(this).val().length !=0) { + if($(this).val().trim().length !== 0) { $('.btn_import_gitlab_project').attr('disabled', false); - $('.import_gitlab_project').attr('title',''); - $(".flash-container").html("") + $importBtnWrapper.attr('title',''); + $importBtnWrapper.removeClass('has-tooltip'); } else { $('.btn_import_gitlab_project').attr('disabled',true); - $('.import_gitlab_project').attr('title', 'Project path and name required.'); + $importBtnWrapper.addClass('has-tooltip'); } }); + $('#project_import_url').disable(); $('.import_git').click(function( event ) { - $projectImportUrl = $('#project_import_url') - $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled')) + $projectImportUrl = $('#project_import_url'); + $projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled')); }); diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml new file mode 100644 index 00000000000..3e79dbec70c --- /dev/null +++ b/app/views/projects/notes/_actions.html.haml @@ -0,0 +1,44 @@ +- access = note_max_access_for_user(note) +- if access + %span.note-role= access + +- if note.resolvable? + - can_resolve = can?(current_user, :resolve_note, note) + %resolve-btn{ "project-path" => project_path(note.project), + "discussion-id" => note.discussion_id(@noteable), + ":note-id" => note.id, + ":resolved" => note.resolved?, + ":can-resolve" => can_resolve, + ":author-name" => "'#{j(note.author.name)}'", + "author-avatar" => note.author.avatar_url, + ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'", + ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'", + "v-show" => "#{can_resolve || note.resolved?}", + "inline-template" => true, + "ref" => "note_#{note.id}" } + + %button.note-action-button.line-resolve-btn{ type: "button", + class: ("is-disabled" unless can_resolve), + ":class" => "{ 'is-active': isResolved }", + ":aria-label" => "buttonText", + "@click" => "resolve", + ":title" => "buttonText", + ":ref" => "'button'" } + + = icon('spin spinner', 'v-show' => 'loading', class: 'loading', 'aria-hidden' => 'true', 'aria-label' => 'Loading') + %div{ 'v-show' => '!loading' }= render 'shared/icons/icon_status_success.svg' + +- if current_user + - if note.emoji_awardable? + - user_authored = note.user_authored?(current_user) + = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do + = icon('spinner spin') + %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') + %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') + %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') + + - if note_editable + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do + = icon('pencil', class: 'link-highlight') + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do + = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/projects/notes/_hints.html.haml deleted file mode 100644 index 81d97eabe65..00000000000 --- a/app/views/projects/notes/_hints.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) -.comment-toolbar.clearfix - .toolbar-text - = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1 - - if supports_slash_commands - and - = link_to 'slash commands', help_page_path('user/project/slash_commands'), target: '_blank', tabindex: -1 - are - - else - is - supported - %button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' } - = icon('file-image-o', class: 'toolbar-button-icon') - Attach a file diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml deleted file mode 100644 index 6c0e6d48d6c..00000000000 --- a/app/views/projects/notes/_note.html.haml +++ /dev/null @@ -1,95 +0,0 @@ -- return unless note.author -- return if note.cross_reference_not_visible_for?(current_user) - -- note_editable = note_editable?(note) -%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} } - .timeline-entry-inner - .timeline-icon - %a{ href: user_path(note.author) } - = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40' - .timeline-content - .note-header - %a.visible-xs{ href: user_path(note.author) } - = note.author.to_reference - = link_to_member(note.project, note.author, avatar: false, extra_class: 'hidden-xs') - .note-headline-light - %span.hidden-xs - = note.author.to_reference - - unless note.system - commented - - if note.system - %span.system-note-message - = note.redacted_note_html - %a{ href: "##{dom_id(note)}" } - = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') - - unless note.system? - .note-actions - - access = note_max_access_for_user(note) - - if access - %span.note-role= access - - - if note.resolvable? - - can_resolve = can?(current_user, :resolve_note, note) - %resolve-btn{ "project-path" => project_path(note.project), - "discussion-id" => note.discussion_id, - ":note-id" => note.id, - ":resolved" => note.resolved?, - ":can-resolve" => can_resolve, - ":author-name" => "'#{j(note.author.name)}'", - "author-avatar" => note.author.avatar_url, - ":note-truncated" => "'#{j(truncate(note.note, length: 17))}'", - ":resolved-by" => "'#{j(note.resolved_by.try(:name))}'", - "v-show" => "#{can_resolve || note.resolved?}", - "inline-template" => true, - "ref" => "note_#{note.id}" } - - %button.note-action-button.line-resolve-btn{ type: "button", - class: ("is-disabled" unless can_resolve), - ":class" => "{ 'is-active': isResolved }", - ":aria-label" => "buttonText", - "@click" => "resolve", - ":title" => "buttonText", - "v-show" => "!loading", - ":ref" => "'button'" } - = icon("spin spinner", "v-show" => "loading") - - = render "shared/icons/icon_status_success.svg" - - - if current_user - - if note.emoji_awardable? - = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do - = icon('spinner spin') - = icon('smile-o', class: 'link-highlight') - - - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do - = icon('pencil', class: 'link-highlight') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do - = icon('trash-o', class: 'danger-highlight') - .note-body{ class: note_editable ? 'js-task-list-container' : '' } - .note-text.md - = preserve do - = note.redacted_note_html - = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) - - if note_editable - .original-note-content.hidden{ data: { post_url: namespace_project_note_path(@project.namespace, @project, note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } - #{note.note} - %textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: namespace_project_note_path(@project.namespace, @project, note) } }= note.note - .note-awards - = render 'award_emoji/awards_block', awardable: note, inline: false - - if note.system - .system-note-commit-list-toggler - Toggle commit list - %i.fa.fa-angle-down - - if note.attachment.url - .note-attachment - - if note.attachment.image? - = link_to note.attachment.url, target: '_blank' do - = image_tag note.attachment.url, class: 'note-image-attach' - .attachment - = link_to note.attachment.url, target: '_blank' do - = icon('paperclip') - = note.attachment_identifier - = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note), - title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do - = icon('trash-o', class: 'cred') diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/projects/notes/_notes.html.haml deleted file mode 100644 index 022578bd6db..00000000000 --- a/app/views/projects/notes/_notes.html.haml +++ /dev/null @@ -1,8 +0,0 @@ -- if @discussions.present? - - @discussions.each do |discussion| - - if discussion.for_target?(@noteable) - = render partial: "projects/notes/note", object: discussion.first_note, as: :note - - else - = render 'discussions/discussion', discussion: discussion -- else - = render partial: "projects/notes/note", collection: @notes, as: :note diff --git a/app/views/projects/pages/_disabled.html.haml b/app/views/projects/pages/_disabled.html.haml deleted file mode 100644 index ad51fbc6cab..00000000000 --- a/app/views/projects/pages/_disabled.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.panel.panel-default - .nothing-here-block - GitLab Pages are disabled. - Ask your system's administrator to enable it. diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 259d5bd63d6..b22a54d75c8 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -16,13 +16,10 @@ %hr.clearfix -- if Gitlab.config.pages.enabled - = render 'access' - = render 'use' - - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https - = render 'list' - - else - = render 'no_domains' - = render 'destroy' += render 'access' += render 'use' +- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render 'list' - else - = render 'disabled' + = render 'no_domains' += render 'destroy' diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml new file mode 100644 index 00000000000..bbed10039af --- /dev/null +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -0,0 +1,33 @@ +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'schedule_form' + += form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "form-horizontal js-pipeline-schedule-form" } do |f| + = form_errors(@schedule) + .form-group + .col-md-9 + = f.label :description, 'Description', class: 'label-light' + = f.text_field :description, class: 'form-control', required: true, autofocus: true, placeholder: 'Provide a short description for this pipeline' + .form-group + .col-md-9 + = f.label :cron, 'Interval Pattern', class: 'label-light' + #interval-pattern-input{ data: { initial_interval: @schedule.cron } } + .form-group + .col-md-9 + = f.label :cron_timezone, 'Cron Timezone', class: 'label-light' + = dropdown_tag("Select a timezone", options: { toggle_class: 'btn js-timezone-dropdown', title: "Select a timezone", filter: true, placeholder: "Filter", data: { data: timezone_data } } ) + = f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true + .form-group + .col-md-9 + = f.label :ref, 'Target Branch', class: 'label-light' + = dropdown_tag("Select target branch", options: { toggle_class: 'btn js-target-branch-dropdown git-revision-dropdown-toggle', dropdown_class: 'git-revision-dropdown', title: "Select target branch", filter: true, placeholder: "Filter", data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } ) + = f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true + .form-group + .col-md-9 + = f.label :active, 'Activated', class: 'label-light' + %div + = f.check_box :active, required: false, value: @schedule.active? + Active + .footer-block.row-content-block + = f.submit 'Save pipeline schedule', class: 'btn btn-create', tabindex: 3 + = link_to 'Cancel', pipeline_schedules_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml new file mode 100644 index 00000000000..2cd82e1b661 --- /dev/null +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -0,0 +1,36 @@ +- if pipeline_schedule + %tr.pipeline-schedule-table-row + %td + = pipeline_schedule.description + %td.branch-name-cell + = icon('code-fork') + = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name" + %td + - if pipeline_schedule.last_pipeline + .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } + = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline_schedule.last_pipeline.id) do + = ci_icon_for_status(pipeline_schedule.last_pipeline.status) + %span ##{pipeline_schedule.last_pipeline.id} + - else + None + %td.next-run-cell + - if pipeline_schedule.active? + = time_ago_with_tooltip(pipeline_schedule.next_run_at) + - else + Inactive + %td + - if pipeline_schedule.owner + = image_tag avatar_icon(pipeline_schedule.owner, 20), class: "avatar s20" + = link_to user_path(pipeline_schedule.owner) do + = pipeline_schedule.owner&.name + %td + .pull-right.btn-group + - if can?(current_user, :update_pipeline_schedule, @project) && !pipeline_schedule.owned_by?(current_user) + = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: 'Take Ownership', class: 'btn' do + Take ownership + - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) + = link_to edit_pipeline_schedule_path(pipeline_schedule), title: 'Edit', class: 'btn' do + = icon('pencil') + - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) + = link_to pipeline_schedule_path(pipeline_schedule), title: 'Delete', method: :delete, class: 'btn btn-remove', data: { confirm: "Are you sure you want to cancel this pipeline?" } do + = icon('trash') diff --git a/app/views/projects/pipeline_schedules/_table.html.haml b/app/views/projects/pipeline_schedules/_table.html.haml new file mode 100644 index 00000000000..25c7604eb24 --- /dev/null +++ b/app/views/projects/pipeline_schedules/_table.html.haml @@ -0,0 +1,12 @@ +.table-holder + %table.table.ci-table + %thead + %tr + %th Description + %th Target + %th Last Pipeline + %th Next Run + %th Owner + %th + + = render partial: "pipeline_schedule", collection: @schedules diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml new file mode 100644 index 00000000000..2a1fb16876a --- /dev/null +++ b/app/views/projects/pipeline_schedules/_tabs.html.haml @@ -0,0 +1,18 @@ +%ul.nav-links + %li{ class: active_when(scope.nil?) }> + = link_to schedule_path_proc.call(nil) do + All + %span.badge.js-totalbuilds-count + = number_with_delimiter(all_schedules.count(:id)) + + %li{ class: active_when(scope == 'active') }> + = link_to schedule_path_proc.call('active') do + Active + %span.badge + = number_with_delimiter(all_schedules.active.count(:id)) + + %li{ class: active_when(scope == 'inactive') }> + = link_to schedule_path_proc.call('inactive') do + Inactive + %span.badge + = number_with_delimiter(all_schedules.inactive.count(:id)) diff --git a/app/views/projects/pipeline_schedules/edit.html.haml b/app/views/projects/pipeline_schedules/edit.html.haml new file mode 100644 index 00000000000..e16fe0b7a98 --- /dev/null +++ b/app/views/projects/pipeline_schedules/edit.html.haml @@ -0,0 +1,7 @@ +- page_title "Edit", @schedule.description, "Pipeline Schedule" + +%h3.page-title + Edit Pipeline Schedule #{@schedule.id} +%hr + += render "form" diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml new file mode 100644 index 00000000000..6751efaaf2f --- /dev/null +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -0,0 +1,24 @@ +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'schedules_index' + +- @no_container = true +- page_title "Pipeline Schedules" += render "projects/pipelines/head" + +%div{ class: container_class } + #pipeline-schedules-callout{ data: { docs_url: help_page_path('user/project/pipelines/schedules') } } + .top-area + - schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) } + = render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope + + .nav-controls + = link_to new_namespace_project_pipeline_schedule_path(@project.namespace, @project), class: 'btn btn-create' do + %span New schedule + + - if @schedules.present? + %ul.content-list + = render partial: "table" + - else + .light-well + .nothing-here-block No schedules diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml new file mode 100644 index 00000000000..b89e170ad3c --- /dev/null +++ b/app/views/projects/pipeline_schedules/new.html.haml @@ -0,0 +1,7 @@ +- page_title "New Pipeline Schedule" + +%h3.page-title + Schedule a new pipeline +%hr + += render "form" diff --git a/app/views/projects/pipelines/_graph.html.haml b/app/views/projects/pipelines/_graph.html.haml deleted file mode 100644 index 0202833c0bf..00000000000 --- a/app/views/projects/pipelines/_graph.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- pipeline = local_assigns.fetch(:pipeline) -.pipeline-visualization.pipeline-graph - %ul.stage-column-list - = render partial: "projects/stage/graph", collection: pipeline.stages, as: :stage diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index bc57f7f1c46..db9d77dba16 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -4,17 +4,23 @@ .nav-links.sub-nav.scrolling-tabs %ul{ class: (container_class) } - if project_nav_tab? :pipelines - = nav_link(path: 'pipelines#index', controller: :pipelines) do + = nav_link(path: ['pipelines#index', 'pipelines#show']) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines - if project_nav_tab? :builds - = nav_link(controller: :builds) do + = nav_link(controller: [:builds, :artifacts]) do = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do %span Jobs + - if project_nav_tab? :pipelines + = nav_link(controller: :pipeline_schedules) do + = link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do + %span + Schedules + - if project_nav_tab? :environments = nav_link(controller: :environments) do = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 4be9a1371ec..8607da8fcdd 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,6 +1,6 @@ .page-content-header .header-main-content - = render 'ci/status/badge', status: @pipeline.detailed_status(current_user) + = render 'ci/status/badge', status: @pipeline.detailed_status(current_user), title: @pipeline.status_title %strong Pipeline ##{@pipeline.id} triggered #{time_ago_with_tooltip(@pipeline.created_at)} - if @pipeline.user @@ -30,7 +30,7 @@ = pluralize @pipeline.statuses.count(:id), "job" - if @pipeline.ref from - = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" + = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - if @pipeline.duration in = time_interval_in_words(@pipeline.duration) @@ -40,10 +40,10 @@ .well-segment.branch-info .icon-container.commit-icon = custom_icon("icon_commit") - = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace js-details-short" + = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "commit-sha js-details-short" = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do %span.text-expander \... %span.js-details-content.hide - = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full" - = clipboard_button(clipboard_text: @pipeline.sha, title: "Copy commit SHA to clipboard") + = link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "commit-sha commit-hash-full" + = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 53067cdcba4..075ddc0025c 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,3 +1,9 @@ +- failed_builds = @pipeline.statuses.latest.failed + +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('pipelines_graph') + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom %li.js-pipeline-tab-link @@ -7,13 +13,15 @@ = link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do Jobs %span.badge.js-builds-counter= pipeline.statuses.count - - + - if failed_builds.present? + %li.js-failures-tab-link + = link_to failures_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do + Failed Jobs + %span.badge.js-failures-counter= failed_builds.count .tab-content #js-tab-pipeline.tab-pane - .build-content.middle-block.js-pipeline-graph - = render "projects/pipelines/graph", pipeline: pipeline + #js-pipeline-graph-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } } #js-tab-builds.tab-pane - if pipeline.yaml_errors.present? @@ -36,7 +44,16 @@ %th Job ID %th Name %th - - if pipeline.project.build_coverage_enabled? - %th Coverage + %th Coverage %th = render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage + - if failed_builds.present? + #js-tab-failures.build-failures.tab-pane + - failed_builds.each_with_index do |build, index| + .build-state + %span.ci-status-icon-failed= custom_icon('icon_status_failed') + %span.stage + = build.stage.titleize + %span.build-name + = link_to build.name, pipeline_build_url(pipeline, build) + %pre.build-log= build_summary(build, skip: index >= 10) diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 3d73284699f..38237d2d97d 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -17,4 +17,4 @@ "ci-lint-path" => ci_lint_path } } = page_specific_javascript_bundle_tag('common_vue') -= page_specific_javascript_bundle_tag('vue_pipelines') += page_specific_javascript_bundle_tag('pipelines') diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 14a270a3039..71a8e490c3e 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -11,8 +11,8 @@ .col-sm-10 = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search branches", + options: { toggle_class: 'js-branch-select wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search branches", data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) .help-block Existing branch name, tag .form-actions diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index 132f6372e40..1b1910b5c0f 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -1,14 +1,14 @@ .row.prepend-top-default .col-lg-3.profile-settings-sidebar %h4.prepend-top-0 - CI/CD Pipelines + Pipelines .col-lg-9 = form_for @project, url: namespace_project_pipelines_settings_path(@project.namespace.becomes(Namespace), @project) do |f| %fieldset.builds-feature - unless @repository.gitlab_ci_yml .form-group %p Pipelines need to be configured before you can begin using Continuous Integration. - = link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' + = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info' %hr .form-group.append-bottom-default = f.label :runners_token, "Runner token", class: 'label-light' @@ -21,7 +21,7 @@ Git strategy for pipelines %p Choose between <code>clone</code> or <code>fetch</code> to get the recent application code - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy') + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy'), target: '_blank' .radio = f.label :build_allow_git_fetch_false do = f.radio_button :build_allow_git_fetch, 'false' @@ -43,7 +43,7 @@ = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' %p.help-block Per job in minutes. If a job passes this threshold, it will be marked as failed. - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout') + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr .form-group @@ -53,7 +53,16 @@ %strong Public pipelines .help-block Allow everyone to access pipelines for public and internal projects - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines') + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank' + %hr + .form-group + .checkbox + = f.label :auto_cancel_pending_pipelines do + = f.check_box :auto_cancel_pending_pipelines, {}, 'enabled', 'disabled' + %strong Auto-cancel redundant, pending pipelines + .help-block + New pipelines will cancel older, pending pipelines on the same branch + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank' %hr .form-group @@ -65,7 +74,7 @@ %p.help-block A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing') + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank' .bs-callout.bs-callout-info %p Below are examples of regex for existing tools: %ul diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml index ab0771b5751..d080b6c83d4 100644 --- a/app/views/projects/project_members/_index.html.haml +++ b/app/views/projects/project_members/_index.html.haml @@ -6,13 +6,19 @@ %p Add a new member to %strong= @project.name + - else + %p + Members can be added by project + %i Masters + or + %i Owners .col-lg-9 .light.prepend-top-default - if can?(current_user, :admin_project_member, @project) = render "projects/project_members/new_project_member" = render 'shared/members/requests', membership_source: @project, requesters: @requesters - .append-bottom-default.clearfix + .clearfix %h5.member.existing-title Existing members and groups - if @group_links.any? diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 81d57c77edf..7b1a26043e1 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -1,9 +1,11 @@ .panel.panel-default - .panel-heading - Members with access to - %strong= @project.name + .panel-heading.flex-project-members-panel + %span.flex-project-title + Members of + %strong + #{@project.name} %span.badge= @project_members.total_count - = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do + = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml index b8e885b4d9a..99bc2516366 100644 --- a/app/views/projects/protected_branches/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml @@ -25,7 +25,7 @@ .merge_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-merge wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }}) .form-group %label.col-md-2.text-right{ for: 'push_access_levels_attributes' } @@ -34,7 +34,7 @@ .push_access_levels-container = dropdown_tag('Select', options: { toggle_class: 'js-allowed-to-push wide', - dropdown_class: 'dropdown-menu-selectable', + dropdown_class: 'dropdown-menu-selectable capitalize-header', data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }}) .panel-footer diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml index 5af0cc7a2f3..6e9c473494e 100644 --- a/app/views/projects/protected_branches/_dropdown.html.haml +++ b/app/views/projects/protected_branches/_dropdown.html.haml @@ -1,8 +1,8 @@ = f.hidden_field(:name) = dropdown_tag('Select branch or create wildcard', - options: { toggle_class: 'js-protected-branch-select js-filter-submit wide', - filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches", + options: { toggle_class: 'js-protected-branch-select js-filter-submit wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: "Search protected branches", footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_branch_name], diff --git a/app/views/projects/protected_branches/_matching_branch.html.haml b/app/views/projects/protected_branches/_matching_branch.html.haml index 8a5332ca5bb..27896272733 100644 --- a/app/views/projects/protected_branches/_matching_branch.html.haml +++ b/app/views/projects/protected_branches/_matching_branch.html.haml @@ -1,9 +1,10 @@ %tr %td - = link_to matching_branch.name, namespace_project_tree_path(@project.namespace, @project, matching_branch.name) + = link_to matching_branch.name, project_ref_path(@project, matching_branch.name), class: 'ref-name' + - if @project.root_ref?(matching_branch.name) %span.label.label-info.prepend-left-5 default %td - commit = @project.commit(matching_branch.name) - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml index b2a6b8469a3..0f80de94392 100644 --- a/app/views/projects/protected_branches/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_protected_branch.html.haml @@ -1,6 +1,7 @@ %tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } } %td - = protected_branch.name + %span.ref-name= protected_branch.name + - if @project.root_ref?(protected_branch.name) %span.label.label-info.prepend-left-5 default %td @@ -9,7 +10,7 @@ = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) - else - if commit = protected_branch.commit - = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') = time_ago_with_tooltip(commit.committed_date) - else (branch was removed from repository) diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml index d6044aacaec..c61b2951e1e 100644 --- a/app/views/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -1,10 +1,10 @@ %td = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container', + options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) %td = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container', + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml index 4d8169815b3..a806a0756ec 100644 --- a/app/views/projects/protected_branches/show.html.haml +++ b/app/views/projects/protected_branches/show.html.haml @@ -1,13 +1,13 @@ -- page_title @protected_branch.name, "Protected Branches" +- page_title @protected_ref.name, "Protected Branches" .row.prepend-top-default.append-bottom-default .col-lg-3 - %h4.prepend-top-0 - = @protected_branch.name + %h4.prepend-top-0.ref-name + = @protected_ref.name .col-lg-9 %h5 Matching Branches - - if @matching_branches.present? + - if @matching_refs.present? .table-responsive %table.table.protected-branches-list %colgroup @@ -18,7 +18,7 @@ %th Branch %th Last commit %tbody - - @matching_branches.each do |matching_branch| + - @matching_refs.each do |matching_branch| = render partial: "matching_branch", object: matching_branch - else %p.settings-message.text-center diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml new file mode 100644 index 00000000000..af9a080f0a2 --- /dev/null +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -0,0 +1,32 @@ += form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f| + .panel.panel-default + .panel-heading + %h3.panel-title + Protect a tag + .panel-body + .form-horizontal + = form_errors(@protected_tag) + .form-group + = f.label :name, class: 'col-md-2 text-right' do + Tag: + .col-md-10.protected-tags-dropdown + = render partial: "projects/protected_tags/dropdown", locals: { f: f } + .help-block + = link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags') + such as + %code v* + or + %code *-release + are supported + .form-group + %label.col-md-2.text-right{ for: 'create_access_levels_attributes' } + Allowed to create: + .col-md-10 + .create_access_levels-container + = dropdown_tag('Select', + options: { toggle_class: 'js-allowed-to-create wide', + dropdown_class: 'dropdown-menu-selectable', + data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }}) + + .panel-footer + = f.submit 'Protect', class: 'btn-create btn', disabled: true diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml new file mode 100644 index 00000000000..c8531f96f97 --- /dev/null +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -0,0 +1,15 @@ += f.hidden_field(:name) + += dropdown_tag('Select tag or create wildcard', + options: { toggle_class: 'js-protected-tag-select js-filter-submit wide git-revision-dropdown-toggle', + filter: true, dropdown_class: "dropdown-menu-selectable capitalize-header git-revision-dropdown", placeholder: "Search protected tag", + footer_content: true, + data: { show_no: true, show_any: true, show_upcoming: true, + selected: params[:protected_tag_name], + project_id: @project.try(:id) } }) do + + %ul.dropdown-footer-list + %li + = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do + Create wildcard + %code diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml new file mode 100644 index 00000000000..0bfb1ad191d --- /dev/null +++ b/app/views/projects/protected_tags/_index.html.haml @@ -0,0 +1,18 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('protected_tags') + +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Protected tags + %p.prepend-top-20 + By default, Protected tags are designed to: + %ul + %li Prevent tag creation by everybody except Masters + %li Prevent <strong>anyone</strong> from updating the tag + %li Prevent <strong>anyone</strong> from deleting the tag + .col-lg-9 + - if can? current_user, :admin_project, @project + = render 'projects/protected_tags/create_protected_tag' + + = render "projects/protected_tags/tags_list" diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml new file mode 100644 index 00000000000..f17353df122 --- /dev/null +++ b/app/views/projects/protected_tags/_matching_tag.html.haml @@ -0,0 +1,10 @@ +%tr + %td + = link_to matching_tag.name, project_ref_path(@project, matching_tag.name), class: 'ref-name' + + - if @project.root_ref?(matching_tag.name) + %span.label.label-info.prepend-left-5 default + %td + - commit = @project.commit(matching_tag.name) + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') + = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml new file mode 100644 index 00000000000..54249ec0db1 --- /dev/null +++ b/app/views/projects/protected_tags/_protected_tag.html.haml @@ -0,0 +1,22 @@ +%tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } } + %td + %span.ref-name= protected_tag.name + + - if @project.root_ref?(protected_tag.name) + %span.label.label-info.prepend-left-5 default + %td + - if protected_tag.wildcard? + - matching_tags = protected_tag.matching(repository.tags) + = link_to pluralize(matching_tags.count, "matching tag"), namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) + - else + - if commit = protected_tag.commit + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha') + = time_ago_with_tooltip(commit.committed_date) + - else + (tag was removed from repository) + + = render partial: 'projects/protected_tags/update_protected_tag', locals: { protected_tag: protected_tag } + + - if can_admin_project + %td + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml new file mode 100644 index 00000000000..728afd75b50 --- /dev/null +++ b/app/views/projects/protected_tags/_tags_list.html.haml @@ -0,0 +1,28 @@ +.panel.panel-default.protected-tags-list.js-protected-tags-list + - if @protected_tags.empty? + .panel-heading + %h3.panel-title + Protected tag (#{@protected_tags.size}) + %p.settings-message.text-center + There are currently no protected tags, protect a tag with the form above. + - else + - can_admin_project = can?(current_user, :admin_project, @project) + + %table.table.table-bordered + %colgroup + %col{ width: "25%" } + %col{ width: "25%" } + %col{ width: "50%" } + %thead + %tr + %th Protected tag (#{@protected_tags.size}) + %th Last commit + %th Allowed to create + - if can_admin_project + %th + %tbody + %tr + %td.flash-container{ colspan: 4 } + = render partial: 'projects/protected_tags/protected_tag', collection: @protected_tags, locals: { can_admin_project: can_admin_project} + + = paginate @protected_tags, theme: 'gitlab' diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml new file mode 100644 index 00000000000..cc80bd04dd0 --- /dev/null +++ b/app/views/projects/protected_tags/_update_protected_tag.haml @@ -0,0 +1,5 @@ +%td + = hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level + = dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container', + data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }}) diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml new file mode 100644 index 00000000000..94c3612a449 --- /dev/null +++ b/app/views/projects/protected_tags/show.html.haml @@ -0,0 +1,25 @@ +- page_title @protected_ref.name, "Protected Tags" + +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0.ref-name + = @protected_ref.name + + .col-lg-9 + %h5 Matching Tags + - if @matching_refs.present? + .table-responsive + %table.table.protected-tags-list + %colgroup + %col{ width: "30%" } + %col{ width: "30%" } + %thead + %tr + %th Tag + %th Last commit + %tbody + - @matching_refs.each do |matching_tag| + = render partial: "matching_tag", object: matching_tag + - else + %p.settings-message.text-center + Couldn't find any matching tags. diff --git a/app/views/projects/registry/repositories/_image.html.haml b/app/views/projects/registry/repositories/_image.html.haml new file mode 100644 index 00000000000..8bc78f8d018 --- /dev/null +++ b/app/views/projects/registry/repositories/_image.html.haml @@ -0,0 +1,32 @@ +.container-image.js-toggle-container + .container-image-head + = link_to "#", class: "js-toggle-button" do + = icon('chevron-down', 'aria-hidden': 'true') + = escape_once(image.path) + + = clipboard_button(clipboard_text: "docker pull #{image.location}") + + .controls.hidden-xs.pull-right + = link_to namespace_project_container_registry_path(@project.namespace, @project, image), + class: 'btn btn-remove has-tooltip', + title: 'Remove repository', + data: { confirm: 'Are you sure?' }, + method: :delete do + = icon('trash cred', 'aria-hidden': 'true') + + .container-image-tags.js-toggle-content.hide + - if image.has_tags? + .table-holder + %table.table.tags + %thead + %tr + %th Tag + %th Tag ID + %th Size + %th Created + - if can?(current_user, :update_container_image, @project) + %th + = render partial: 'tag', collection: image.tags + - else + .nothing-here-block No tags in Container Registry for this container image. + diff --git a/app/views/projects/container_registry/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml index 10822b6184c..378a23f07e6 100644 --- a/app/views/projects/container_registry/_tag.html.haml +++ b/app/views/projects/registry/repositories/_tag.html.haml @@ -1,7 +1,7 @@ %tr.tag %td = escape_once(tag.name) - = clipboard_button(clipboard_text: "docker pull #{tag.path}") + = clipboard_button(text: "docker pull #{tag.location}") %td - if tag.revision %span.has-tooltip{ title: "#{tag.revision}" } @@ -25,5 +25,9 @@ - if can?(current_user, :update_container_image, @project) %td.content .controls.hidden-xs.pull-right - = link_to namespace_project_container_registry_path(@project.namespace, @project, tag.name), class: 'btn btn-remove has-tooltip', title: "Remove", data: { confirm: "Are you sure?" }, method: :delete do - = icon("trash cred") + = link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name), + method: :delete, + class: 'btn btn-remove has-tooltip', + title: 'Remove tag', + data: { confirm: 'Are you sure you want to delete this tag?' } do + = icon('trash cred') diff --git a/app/views/projects/container_registry/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 993da27310f..be128e92fa7 100644 --- a/app/views/projects/container_registry/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -15,25 +15,12 @@ %br Then you are free to create and upload a container image with build and push commands: %pre - docker build -t #{escape_once(@project.container_registry_repository_url)} . + docker build -t #{escape_once(@project.container_registry_url)}/image . %br - docker push #{escape_once(@project.container_registry_repository_url)} + docker push #{escape_once(@project.container_registry_url)}/image - - if @tags.blank? - %li - .nothing-here-block No images in Container Registry for this project. + - if @images.blank? + .nothing-here-block No container image repositories in Container Registry for this project. - else - .table-holder - %table.table.tags - %thead - %tr - %th Name - %th Image ID - %th Size - %th Created - - if can?(current_user, :update_container_image, @project) - %th - - - @tags.each do |tag| - = render 'tag', tag: tag + = render partial: 'image', collection: @images diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml index 79d8d721aa9..93ee9382a6e 100644 --- a/app/views/projects/releases/edit.html.haml +++ b/app/views/projects/releases/edit.html.haml @@ -11,9 +11,9 @@ = form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f| - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." - = render 'projects/notes/hints' + = render 'shared/notes/hints' .error-alert .prepend-top-default = f.submit 'Save changes', class: 'btn btn-save' diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index deeadb609f6..674f87e8220 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -1,15 +1,18 @@ %li.runner{ id: dom_id(runner) } %h4 = runner_status_icon(runner) - %span.monospace - - if @project_runners.include?(runner) - = link_to runner.short_sha, runner_path(runner) - - if runner.locked? - = icon('lock', class: 'has-tooltip', title: 'Locked to current projects') - %small - = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do - %i.fa.fa-edit.btn - - else + + - if @project_runners.include?(runner) + = link_to runner.short_sha, runner_path(runner), class: 'commit-sha' + + - if runner.locked? + = icon('lock', class: 'has-tooltip', title: 'Locked to current projects') + + %small + = link_to edit_namespace_project_runner_path(@project.namespace, @project, runner) do + %i.fa.fa-edit.btn + - else + %span.commit-sha = runner.short_sha .pull-right diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml index 6b8e6bd4fee..f8835454140 100644 --- a/app/views/projects/runners/_specific_runners.html.haml +++ b/app/views/projects/runners/_specific_runners.html.haml @@ -9,7 +9,7 @@ (checkout the #{link_to 'GitLab Runner section', 'https://about.gitlab.com/gitlab-ci/#gitlab-runner', target: '_blank'} for information on how to install it). %li Specify the following URL during the Runner setup: - %code= ci_root_url(only_path: false) + %code= root_url(only_path: false) %li Use the following registration token during setup: %code= @project.runners_token diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index 50ed78286d2..0f1a76a104a 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,2 +1,3 @@ - page_title @service.title, "Services" += render "projects/settings/head" = render 'form' diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 2fb88297fb3..ef3599460f1 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -22,14 +22,14 @@ .col-sm-10.col-xs-12.input-group = text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#display_name') + = clipboard_button(target: '#display_name') .form-group = label_tag :description, 'Description', class: 'col-sm-2 col-xs-12 control-label' .col-sm-10.col-xs-12.input-group = text_field_tag :description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#description') + = clipboard_button(target: '#description') .form-group = label_tag nil, 'Command trigger word', class: 'col-sm-2 col-xs-12 control-label' @@ -46,7 +46,7 @@ .col-sm-10.col-xs-12.input-group = text_field_tag :request_url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#request_url') + = clipboard_button(target: '#request_url') .form-group = label_tag nil, 'Request method', class: 'col-sm-2 col-xs-12 control-label' @@ -57,14 +57,14 @@ .col-sm-10.col-xs-12.input-group = text_field_tag :response_username, 'GitLab', class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#response_username') + = clipboard_button(target: '#response_username') .form-group = label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-xs-12 control-label' .col-sm-10.col-xs-12.input-group = text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#response_icon') + = clipboard_button(target: '#response_icon') .form-group = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label' @@ -75,14 +75,14 @@ .col-sm-10.col-xs-12.input-group = text_field_tag :autocomplete_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#autocomplete_hint') + = clipboard_button(target: '#autocomplete_hint') .form-group = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-xs-12 control-label' .col-sm-10.col-xs-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#autocomplete_description') + = clipboard_button(target: '#autocomplete_description') %hr diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 078b7be6865..73b99453a4b 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -40,7 +40,7 @@ .col-sm-10.col-xs-12.input-group = text_field_tag :url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#url') + = clipboard_button(target: '#url') .form-group = label_tag nil, 'Method', class: 'col-sm-2 col-xs-12 control-label' @@ -51,7 +51,7 @@ .col-sm-10.col-xs-12.input-group = text_field_tag :customize_name, 'GitLab', class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#customize_name') + = clipboard_button(target: '#customize_name') .form-group = label_tag nil, 'Customize icon', class: 'col-sm-2 col-xs-12 control-label' @@ -68,21 +68,21 @@ .col-sm-10.col-xs-12.input-group = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#autocomplete_description') + = clipboard_button(target: '#autocomplete_description') .form-group = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-xs-12 control-label' .col-sm-10.col-xs-12.input-group = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#autocomplete_usage_hint') + = clipboard_button(target: '#autocomplete_usage_hint') .form-group = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-xs-12 control-label' .col-sm-10.col-xs-12.input-group = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control input-sm', readonly: 'readonly' .input-group-btn - = clipboard_button(clipboard_target: '#descriptive_label') + = clipboard_button(target: '#descriptive_label') %hr diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml index 88bcb541dac..faed65d6588 100644 --- a/app/views/projects/settings/_head.html.haml +++ b/app/views/projects/settings/_head.html.haml @@ -14,7 +14,7 @@ %span Members - if can_edit - = nav_link(controller: :integrations) do + = nav_link(controller: [:integrations, :services, :hooks]) do = link_to project_settings_integrations_path(@project), title: 'Integrations' do %span Integrations @@ -24,10 +24,11 @@ Repository - if @project.feature_available?(:builds, current_user) = nav_link(controller: :ci_cd) do - = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do + = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'Pipelines' do %span - CI/CD Pipelines - = nav_link(controller: :pages) do - = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do - %span - Pages + Pipelines + - if Gitlab.config.pages.enabled + = nav_link(controller: :pages) do + = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do + %span + Pages diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index e2603096014..e8d2e91bd76 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -1,4 +1,4 @@ -- page_title "CI/CD Pipelines" +- page_title "Pipelines" = render "projects/settings/head" = render 'projects/runners/index' diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml index ceabe2eab3d..a6640592dba 100644 --- a/app/views/projects/settings/integrations/_project_hook.html.haml +++ b/app/views/projects/settings/integrations/_project_hook.html.haml @@ -3,12 +3,13 @@ .col-md-8.col-lg-7 %strong.light-header= hook.url %div - - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events build_events pipeline_events wiki_page_events).each do |trigger| + - %w(push_events tag_push_events issues_events confidential_issues_events note_events merge_requests_events job_events pipeline_events wiki_page_events).each do |trigger| - if hook.send(trigger) %span.label.label-gray.deploy-project-label= trigger.titleize .col-md-4.col-lg-5.text-right-lg.prepend-top-5 %span.append-right-10.inline SSL Verification: #{hook.enable_ssl_verification ? "enabled" : "disabled"} + = link_to "Edit", edit_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm" = link_to "Test", test_namespace_project_hook_path(@project.namespace, @project, hook), class: "btn btn-sm" = link_to namespace_project_hook_path(@project.namespace, @project, hook), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-transparent" do %span.sr-only Remove diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 4c02302e161..4e59033c4a3 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -1,5 +1,10 @@ - page_title "Repository" = render "projects/settings/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('deploy_keys') + = render @deploy_keys = render "projects/protected_branches/index" += render "projects/protected_tags/index" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index de1229d58aa..1ca464696ed 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -12,7 +12,7 @@ = render "projects/last_push" = render "home_panel" -- if current_user && can?(current_user, :download_code, @project) +- if can?(current_user, :download_code, @project) %nav.project-stats{ class: container_class } %ul.nav %li @@ -70,14 +70,9 @@ = link_to 'Set up Koding', add_koding_stack_path(@project) - if @repository.gitlab_ci_yml.blank? && @project.deployment_service.present? %li.missing - = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', target_branch: 'auto-deploy', context: 'autodeploy') do + = link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', branch_name: 'auto-deploy', context: 'autodeploy') do Set up auto deploy - - if @repository.commit - %div{ class: container_class } - .project-last-commit - = render 'projects/last_commit', commit: @repository.commit, ref: current_ref, project: @project - %div{ class: container_class } - if @project.archived? .text-warning.center.prepend-top-20 diff --git a/app/views/projects/snippets/edit.html.haml b/app/views/projects/snippets/edit.html.haml index fb39028529d..24b92094b7d 100644 --- a/app/views/projects/snippets/edit.html.haml +++ b/app/views/projects/snippets/edit.html.haml @@ -1,4 +1,4 @@ -- page_title "Edit", @snippet.title, "Snippets" +- page_title "Edit", "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" %h3.page-title Edit Snippet diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index e35385f4cab..aab1c043e66 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -1,12 +1,12 @@ -- page_title @snippet.title, "Snippets" +- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" = render 'shared/snippets/header' .project-snippets %article.file-holder.snippet-file-content - = render 'shared/snippets/blob', raw_path: raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) + = render 'shared/snippets/blob' .row-content-block.top-block.content-component-block = render 'award_emoji/awards_block', awardable: @snippet, inline: true - #notes= render "projects/notes/notes_with_form" + #notes= render "shared/notes/notes_with_form" diff --git a/app/views/projects/stage/_graph.html.haml b/app/views/projects/stage/_graph.html.haml deleted file mode 100644 index 4ee30b023ac..00000000000 --- a/app/views/projects/stage/_graph.html.haml +++ /dev/null @@ -1,19 +0,0 @@ -- stage = local_assigns.fetch(:stage) -- statuses = stage.statuses.latest -- status_groups = statuses.sort_by(&:sortable_name).group_by(&:group_name) -%li.stage-column - .stage-name - %a{ name: stage.name } - = stage.name.titleize - .builds-container - %ul - - status_groups.each do |group_name, grouped_statuses| - - if grouped_statuses.one? - - status = grouped_statuses.first - %li.build{ 'id' => "ci-badge-#{group_name}" } - .curve - = render 'ci/status/graph_badge', subject: status - - else - %li.build{ 'id' => "ci-badge-#{group_name}" } - .curve - = render 'projects/stage/in_stage_group', name: group_name, subject: grouped_statuses diff --git a/app/views/projects/stage/_in_stage_group.html.haml b/app/views/projects/stage/_in_stage_group.html.haml deleted file mode 100644 index 671a3ef481c..00000000000 --- a/app/views/projects/stage/_in_stage_group.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- group_status = CommitStatus.where(id: subject).status -%button.dropdown-menu-toggle.build-content.has-tooltip{ type: 'button', data: { toggle: 'dropdown', title: "#{name} - #{group_status}", container: 'body' } } - %span{ class: "ci-status-icon ci-status-icon-#{group_status}" } - = ci_icon_for_status(group_status) - %span.ci-status-text - = name - %span.dropdown-counter-badge= subject.size - -%ul.dropdown-menu.big-pipeline-graph-dropdown-menu.js-grouped-pipeline-dropdown - .arrow - .scrollable-menu - - subject.each do |status| - %li - = render 'ci/status/dropdown_graph_badge', subject: status diff --git a/app/views/projects/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml index 28e1c060875..f93994bebe3 100644 --- a/app/views/projects/stage/_stage.html.haml +++ b/app/views/projects/stage/_stage.html.haml @@ -6,8 +6,8 @@ = ci_icon_for_status(stage.status) = stage.name.titleize -= render stage.statuses.latest_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, allow_retry: true -= render stage.statuses.retried_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, retried: true += render stage.statuses.latest_ordered, stage: false, ref: false, pipeline_link: false, allow_retry: true += render stage.statuses.retried_ordered, stage: false, ref: false, pipeline_link: false, retried: true %tr %td{ colspan: 10 } diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index dffe908e85a..44cb734d7b9 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -2,10 +2,14 @@ - release = @releases.find { |release| release.tag == tag.name } %li.flex-row .row-main-content.str-truncated - = link_to namespace_project_tag_path(@project.namespace, @project, tag.name) do - %span.item-title - = icon('tag') - = tag.name + = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'item-title ref-name' do + = icon('tag') + = tag.name + + - if protected_tag?(@project, tag) + %span.label.label-success + protected + - if tag.message.present? = strip_gpg_signature(tag.message) @@ -19,8 +23,7 @@ - if release && release.description.present? .description.prepend-top-default .wiki - = preserve do - = markdown_field(release, :description) + = markdown_field(release, :description) .row-fixed-content.controls = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] @@ -30,5 +33,5 @@ = icon("pencil") - if can?(current_user, :admin_project, @project) - = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do + = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do = icon("trash-o") diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 7f9a44e565f..56656ea3d86 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -1,4 +1,5 @@ - @no_container = true +- @sort ||= sort_value_recently_updated - page_title "Tags" = render "projects/commits/head" @@ -14,16 +15,14 @@ .dropdown %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light - = projects_sort_options_hash[@sort] + = tags_sort_options_hash[@sort] = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right - %li - = link_to filter_tags_path(sort: sort_value_name) do - = sort_title_name - = link_to filter_tags_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to filter_tags_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - tags_sort_options_hash.each do |value, title| + %li + = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value) - if can?(current_user, :push_code, @project) = link_to new_namespace_project_tag_path(@project.namespace, @project), class: 'btn btn-create new-tag-btn' do New tag diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 160d4c7a223..52af295bddd 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -1,4 +1,5 @@ - page_title "New Tag" +- default_ref = params[:ref] || @project.default_branch - if @error .alert.alert-danger @@ -16,30 +17,30 @@ = text_field_tag :tag_name, params[:tag_name], required: true, tabindex: 1, autofocus: true, class: 'form-control' .form-group = label_tag :ref, 'Create from', class: 'control-label' - .col-sm-10 - = text_field_tag :ref, params[:ref] || @project.default_branch, required: true, tabindex: 2, class: 'form-control' - .help-block Branch name or commit SHA + .col-sm-10.create-from + .dropdown + = hidden_field_tag :ref, default_ref + = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do + .text-left.dropdown-toggle-text= default_ref + = render 'shared/ref_dropdown', dropdown_class: 'wide' + .help-block Existing branch name, tag, or commit SHA .form-group = label_tag :message, nil, class: 'control-label' .col-sm-10 - = text_area_tag :message, nil, required: false, tabindex: 3, class: 'form-control', rows: 5 + = text_area_tag :message, @message, required: false, tabindex: 3, class: 'form-control', rows: 5 .help-block Optionally, add a message to the tag. %hr .form-group = label_tag :release_description, 'Release notes', class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do - = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..." - = render 'projects/notes/hints' + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do + = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here...", current_text: @release_description + = render 'shared/notes/hints' .help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page. .form-actions = button_tag 'Create tag', class: 'btn btn-create', tabindex: 3 = link_to 'Cancel', namespace_project_tags_path(@project.namespace, @project), class: 'btn btn-cancel' :javascript - var availableRefs = #{@project.repository.ref_names.to_json}; - - $("#ref").autocomplete({ - source: availableRefs, - minLength: 1 - }); + window.gl = window.gl || { }; + window.gl.availableRefs = #{@project.repository.ref_names.to_json}; diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index fad3c5c2173..2b81ce4b9fa 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -6,7 +6,12 @@ .top-area.multi-line .nav-text .title - %span.item-title= @tag.name + %span.item-title.ref-name + = icon('tag') + = @tag.name + - if protected_tag?(@project, @tag) + %span.label.label-success + protected - if @commit = render 'projects/branches/commit', commit: @commit, project: @project - else @@ -24,7 +29,7 @@ = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_project, @project) .btn-container.controls-item-full - = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do + = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do %i.fa.fa-trash-o - if @tag.message.present? @@ -35,7 +40,6 @@ - if @release.description.present? .description .wiki - = preserve do - = markdown_field(@release, :description) + = markdown_field(@release, :description) - else This tag has no release notes. diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index bdcc160a067..de57cd4ba00 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,8 +1,9 @@ -%article.file-holder.readme-holder - .js-file-title.file-title - = blob_icon readme.mode, readme.name - = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, @path, readme.name)) do - %strong - = readme.name - .file-content.wiki - = render_readme(readme) +- if readme.rich_viewer + %article.file-holder.readme-holder + .js-file-title.file-title + = blob_icon readme.mode, readme.name + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path)) do + %strong + = readme.name + + = render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, readme.path), viewer: :rich, format: :json) diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 6855c463c6d..2e34803b143 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -6,16 +6,6 @@ %th Name %th.hidden-xs .pull-left Last commit - .last-commit.hidden-sm.pull-left - %i.fa.fa-angle-right - %small.light - = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace" - = clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard") - = time_ago_with_tooltip(@commit.committed_date) - \- - = @commit.full_title - %small.commit-history-link-spacer | - = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'commit-history-link' %th.text-right Last Update - if @path.present? %tr.tree-item diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 259207a6dfd..e4d9e24f56e 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,3 +1,10 @@ +.tree-controls + = render 'projects/find_file_link' + + = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn btn-grouped' + + = render 'projects/buttons/download', project: @project, ref: @ref + .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path @@ -5,12 +12,9 @@ %li = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do = @project.path - - tree_breadcrumbs(tree, 6) do |title, path| + - path_breadcrumbs do |title, path| %li - - if path - = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path) - - else - = link_to title, '#' + = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) - if current_user %li diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index a2a26039220..b51955010ce 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -7,12 +7,4 @@ = render 'projects/last_push' %div{ class: container_class } - .tree-controls - = render 'projects/find_file_link' - = render 'projects/buttons/download', project: @project, ref: @ref - - #tree-holder.tree-holder.clearfix - .nav-block - = render 'projects/tree/tree_header', tree: @tree - - = render 'projects/tree/tree_content', tree: @tree + = render 'projects/files', commit: @last_commit, project: @project, ref: @ref diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index ed68e0ed56d..9b5f63ae81a 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -2,7 +2,7 @@ %td - if can?(current_user, :admin_trigger, trigger) %span= trigger.token - = clipboard_button(clipboard_text: trigger.token, title: "Copy trigger token to clipboard") + = clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard") - else %span= trigger.short_token diff --git a/app/views/projects/variables/_table.html.haml b/app/views/projects/variables/_table.html.haml index c7cebf45160..0ce597dcf21 100644 --- a/app/views/projects/variables/_table.html.haml +++ b/app/views/projects/variables/_table.html.haml @@ -14,7 +14,7 @@ %tr %td.variable-key= variable.key %td.variable-value{ "data-value" => variable.value }****** - %td + %td.variable-menu = link_to namespace_project_variable_path(@project.namespace, @project, variable), class: "btn btn-transparent btn-variable-edit" do %span.sr-only Update diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index c52527332bc..6cb7c1e9c4d 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -1,3 +1,5 @@ +- commit_message = @page.persisted? ? "Update #{@page.title}" : "Create #{@page.title}" + = form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, html: { class: 'form-horizontal wiki-form common-note-form prepend-top-default js-quick-submit' } do |f| = form_errors(@page) @@ -10,9 +12,9 @@ .form-group = f.label :content, class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do + = render layout: 'projects/md_preview', locals: { url: namespace_project_wiki_preview_markdown_path(@project.namespace, @project, @page.slug) } do = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: 'Write your content or drag files here...' - = render 'projects/notes/hints' + = render 'shared/notes/hints' .clearfix .error-alert @@ -28,7 +30,7 @@ .form-group = f.label :commit_message, class: 'control-label' - .col-sm-10= f.text_field :message, class: 'form-control', rows: 18 + .col-sm-10= f.text_field :message, class: 'form-control', rows: 18, value: commit_message .form-actions - if @page && @page.persisted? diff --git a/app/views/projects/wikis/_main_links.html.haml b/app/views/projects/wikis/_main_links.html.haml index 5211ade1a5f..6a578dbf640 100644 --- a/app/views/projects/wikis/_main_links.html.haml +++ b/app/views/projects/wikis/_main_links.html.haml @@ -1,9 +1,9 @@ - if (@page && @page.persisted?) - if can?(current_user, :create_wiki, @project) = link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do - New Page + New page = link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do - Page History + Page history - if can?(current_user, :create_wiki, @project) && @page.latest? - = link_to namespace_project_wiki_edit_path(@project.namespace, @project, @page), class: "btn" do + = link_to namespace_project_wiki_edit_path(@project.namespace, @project, @page), class: "btn js-wiki-edit" do Edit diff --git a/app/views/projects/wikis/_new.html.haml b/app/views/projects/wikis/_new.html.haml index 3d33679f07d..ba47574563d 100644 --- a/app/views/projects/wikis/_new.html.haml +++ b/app/views/projects/wikis/_new.html.haml @@ -18,4 +18,4 @@ Tip: You can specify the full path for the new file. We will automatically create any missing directories. .form-actions - = button_tag 'Create Page', class: 'build-new-wiki btn btn-create' + = button_tag 'Create page', class: 'build-new-wiki btn btn-create' diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index 713b758727e..c2f9e65015d 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -1,4 +1,4 @@ -%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" } } +%aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } } .block.wiki-sidebar-header.append-bottom-default %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-wiki-toggle{ href: "#" } = icon('angle-double-right') diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index 8cf018da1b7..b995d08cd02 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -22,10 +22,10 @@ .nav-controls - if can?(current_user, :create_wiki, @project) = link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do - New Page + New page - if @page.persisted? = link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do - Page History + Page history - if can?(current_user, :admin_wiki, @project) = link_to namespace_project_wiki_path(@project.namespace, @project, @page), data: { confirm: "Are you sure you want to delete this page?"}, method: :delete, class: "btn btn-danger" do Delete diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml index fb0efd85dcd..68862206248 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/projects/wikis/git_access.html.haml @@ -28,7 +28,7 @@ %h3 Clone your wiki %pre.dark :preserve - git clone #{ content_tag(:span, default_url_to_repo(@project_wiki), class: 'clone')} + git clone #{ content_tag(:span, h(default_url_to_repo(@project_wiki)), class: 'clone')} cd #{h @project_wiki.path} %h3 Start Gollum and edit locally diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 3609461b721..c00967546aa 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -27,7 +27,6 @@ .wiki-holder.prepend-top-default.append-bottom-default .wiki - = preserve do - = render_wiki_content(@page) + = render_wiki_content(@page) = render 'sidebar' diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml index 938be20c7cf..e43796e9654 100644 --- a/app/views/search/_filter.html.haml +++ b/app/views/search/_filter.html.haml @@ -3,7 +3,7 @@ - if params[:project_id].present? = hidden_field_tag :project_id, params[:project_id] .dropdown - %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:" } } + %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:", group_id: params[:group_id] } } %span.dropdown-toggle-text Group: - if @group.present? diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index e010f21de5a..b4bc8982c05 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -3,13 +3,11 @@ = confidential_icon(issue) = link_to [issue.project.namespace.becomes(Namespace), issue.project, issue] do %span.term.str-truncated= issue.title + - if issue.closed? + %span.label.label-danger.prepend-left-5 Closed .pull-right ##{issue.iid} - if issue.description.present? .description.term - = preserve do - = search_md_sanitize(issue, :description) + = search_md_sanitize(issue, :description) %span.light #{issue.project.name_with_namespace} - - if issue.closed? - .pull-right - %span.label.label-danger Closed diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml index 2e6adf3027c..1a5499e4d58 100644 --- a/app/views/search/results/_merge_request.html.haml +++ b/app/views/search/results/_merge_request.html.haml @@ -2,15 +2,13 @@ %h4 = link_to [merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request] do %span.term.str-truncated= merge_request.title + - if merge_request.merged? + %span.label.label-primary.prepend-left-5 Merged + - elsif merge_request.closed? + %span.label.label-danger.prepend-left-5 Closed .pull-right= merge_request.to_reference - if merge_request.description.present? .description.term - = preserve do - = search_md_sanitize(merge_request, :description) + = search_md_sanitize(merge_request, :description) %span.light #{merge_request.project.name_with_namespace} - .pull-right - - if merge_request.merged? - %span.label.label-primary Merged - - elsif merge_request.closed? - %span.label.label-danger Closed diff --git a/app/views/search/results/_milestone.html.haml b/app/views/search/results/_milestone.html.haml index 9664f65a36e..2daa96e34d1 100644 --- a/app/views/search/results/_milestone.html.haml +++ b/app/views/search/results/_milestone.html.haml @@ -5,5 +5,4 @@ - if milestone.description.present? .description.term - = preserve do - = search_md_sanitize(milestone, :description) + = search_md_sanitize(milestone, :description) diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml index f3701b89bb4..a7e178dfa71 100644 --- a/app/views/search/results/_note.html.haml +++ b/app/views/search/results/_note.html.haml @@ -22,5 +22,4 @@ .note-search-result .term - = preserve do - = search_md_sanitize(note, :note) + = search_md_sanitize(note, :note) diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index f84be600df8..c4a5131c1a7 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -21,7 +21,7 @@ .file-content.wiki - snippet_chunks.each do |chunk| - unless chunk[:data].empty? - = render_markup(snippet.file_name, chunk[:data]) + = markup(snippet.file_name, chunk[:data]) - else .file-content.code .nothing-here-block Empty file @@ -39,7 +39,7 @@ .blob-content - snippet_chunks.each do |chunk| - unless chunk[:data].empty? - = highlight(snippet.file_name, chunk[:data], repository: nil, plain: snippet.no_highlighting?) + = highlight(snippet.file_name, chunk[:data], repository: nil, plain: snippet.blob.no_highlighting?) - else .file-content.code .nothing-here-block Empty file diff --git a/app/views/shared/_branch_switcher.html.haml b/app/views/shared/_branch_switcher.html.haml index 7799aff6b5b..69e3f3042a9 100644 --- a/app/views/shared/_branch_switcher.html.haml +++ b/app/views/shared/_branch_switcher.html.haml @@ -1,8 +1,8 @@ -- dropdown_toggle_text = @target_branch || tree_edit_branch -= hidden_field_tag 'target_branch', dropdown_toggle_text +- dropdown_toggle_text = @branch_name || tree_edit_branch += hidden_field_tag 'branch_name', dropdown_toggle_text .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: 'dropdown', selected: dropdown_toggle_text, field_name: 'target_branch', form_id: '.js-edit-blob-form', refs_url: namespace_project_branches_path(@project.namespace, @project) }, { toggle_class: 'js-project-branches-dropdown js-target-branch' } + = dropdown_toggle dropdown_toggle_text, { toggle: 'dropdown', selected: dropdown_toggle_text, field_name: 'branch_name', form_id: '.js-edit-blob-form', refs_url: namespace_project_branches_path(@project.namespace, @project) }, { toggle_class: 'js-project-branches-dropdown js-target-branch' } .dropdown-menu.dropdown-menu-selectable.dropdown-menu-paging.dropdown-menu-branches = render partial: 'shared/projects/blob/branch_page_default' = render partial: 'shared/projects/blob/branch_page_create' diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 03684389742..0992a65f7cd 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -17,9 +17,9 @@ %li = http_clone_button(project) - = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true + = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' } .input-group-btn - = clipboard_button(clipboard_target: '#project_clone', title: "Copy URL to clipboard") + = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard") :javascript $('ul.clone-options-dropdown a').on('click',function(e){ diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml index 8d6e16f74c3..d74b0043949 100644 --- a/app/views/shared/_field.html.haml +++ b/app/views/shared/_field.html.haml @@ -9,7 +9,7 @@ .form-group - if type == "password" && value.present? - = form.label name, "Change #{title}", class: "control-label" + = form.label name, "Enter new #{title.downcase}", class: "control-label" - else = form.label name, title, class: "control-label" .col-sm-10 @@ -22,6 +22,6 @@ - elsif type == 'select' = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control" } - elsif type == 'password' - = form.password_field name, autocomplete: "new-password", class: 'form-control' + = form.password_field name, autocomplete: "new-password", class: "form-control" - if help %span.help-block= help diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 8869d510aef..90ae3f06a98 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -1,12 +1,8 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('group') - parent = GroupFinder.new(current_user).execute(id: params[:parent_id] || @group.parent_id) - group_path = root_url - group_path << parent.full_path + '/' if parent -- if @group.persisted? - .form-group - = f.label :name, class: 'control-label' do - Group name - .col-sm-10 - = f.text_field :name, placeholder: 'open-source', class: 'form-control' .form-group = f.label :path, class: 'control-label' do @@ -20,7 +16,7 @@ = f.text_field :path, placeholder: 'open-source', class: 'form-control', autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS, - title: 'Please choose a group name with no special characters.', + title: 'Please choose a group path with no special characters.', "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - if parent = f.hidden_field :parent_id, value: parent.id @@ -33,6 +29,14 @@ %li It will change web url for access group and group projects. %li It will change the git path to repositories under this group. +.form-group.group-name-holder + = f.label :name, class: 'control-label' do + Group name + .col-sm-10 + = f.text_field :name, class: 'form-control', + required: true, + title: 'You can choose a descriptive name different from the path.' + .form-group.group-description-holder = f.label :description, class: 'control-label' .col-sm-10 diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 54b5ae2402e..1c7c73be933 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -2,7 +2,7 @@ = f.label :import_url, class: 'control-label' do %span Git repository URL .col-sm-10 - = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', disabled: true + = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git' .well.prepend-top-20 %ul diff --git a/app/views/shared/_merge_requests.html.haml b/app/views/shared/_merge_requests.html.haml index b7982b7fe9b..eecbb32e90e 100644 --- a/app/views/shared/_merge_requests.html.haml +++ b/app/views/shared/_merge_requests.html.haml @@ -6,4 +6,4 @@ = paginate @merge_requests, theme: "gitlab" - else - .nothing-here-block No merge requests to show + = render 'shared/empty_states/merge_requests' diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml index b0778653d4e..07970ad9cba 100644 --- a/app/views/shared/_mini_pipeline_graph.html.haml +++ b/app/views/shared/_mini_pipeline_graph.html.haml @@ -11,8 +11,8 @@ = icon('caret-down') %ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container - .arrow-up - .js-builds-dropdown-list.scrollable-menu + %li.js-builds-dropdown-list.scrollable-menu - .js-builds-dropdown-loading.builds-dropdown-loading.hidden - %span.fa.fa-spinner.fa-spin + %li.js-builds-dropdown-loading.hidden + .text-center + %i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' } diff --git a/app/views/shared/_mr_head.html.haml b/app/views/shared/_mr_head.html.haml new file mode 100644 index 00000000000..4211ec6351d --- /dev/null +++ b/app/views/shared/_mr_head.html.haml @@ -0,0 +1,4 @@ +- if @project.default_issues_tracker? + = render "projects/issues/head" +- else + = render "projects/merge_requests/head" diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml index 3ac5e15d1c4..0b37fe3013b 100644 --- a/app/views/shared/_new_commit_form.html.haml +++ b/app/views/shared/_new_commit_form.html.haml @@ -1,11 +1,11 @@ = render 'shared/commit_message_container', placeholder: placeholder - if @project.empty_repo? - = hidden_field_tag 'target_branch', @ref + = hidden_field_tag 'branch_name', @ref - else - if can?(current_user, :push_code, @project) .form-group.branch - = label_tag 'target_branch', 'Target branch', class: 'control-label' + = label_tag 'branch_name', 'Target branch', class: 'control-label' .col-sm-10 = render 'shared/branch_switcher' @@ -16,7 +16,7 @@ = check_box_tag 'create_merge_request', 1, true, class: 'js-create-merge-request', id: "create_merge_request-#{nonce}" Start a <strong>new merge request</strong> with these changes - else - = hidden_field_tag 'target_branch', @target_branch || tree_edit_branch + = hidden_field_tag 'branch_name', @branch_name || tree_edit_branch = hidden_field_tag 'create_merge_request', 1 = hidden_field_tag 'original_branch', @ref, class: 'js-original-branch' diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index af4cc90f4a7..b20055a564e 100644 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -1,4 +1,4 @@ -- type = impersonation ? "Impersonation" : "Personal Access" +- type = impersonation ? "impersonation" : "personal access" %h5.prepend-top-0 Add a #{type} Token @@ -22,7 +22,7 @@ = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes .prepend-top-default - = f.submit "Create #{type} Token", class: "btn btn-create" + = f.submit "Create #{type} token", class: "btn btn-create" :javascript var $dateField = $('.datepicker'); @@ -30,9 +30,10 @@ new Pikaday({ field: $dateField.get(0), - theme: 'gitlab-theme', + theme: 'gitlab-theme animate-picker', format: 'yyyy-mm-dd', minDate: new Date(), + container: $dateField.parent().get(0), onSelect: function(dateText) { $dateField.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); } diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml index 67a49815478..ab7a2db002e 100644 --- a/app/views/shared/_personal_access_tokens_table.html.haml +++ b/app/views/shared/_personal_access_tokens_table.html.haml @@ -33,7 +33,7 @@ - if impersonation %td.token-token-container = text_field_tag 'impersonation-token-token', token.token, readonly: true, class: "form-control" - = clipboard_button(clipboard_text: token.token) + = clipboard_button(text: token.token) - path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token) %td= link_to "Revoke", path, method: :put, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." } - else diff --git a/app/views/shared/_ref_dropdown.html.haml b/app/views/shared/_ref_dropdown.html.haml new file mode 100644 index 00000000000..8b2a3bee407 --- /dev/null +++ b/app/views/shared/_ref_dropdown.html.haml @@ -0,0 +1,7 @@ +- dropdown_class = local_assigns.fetch(:dropdown_class, '') + +.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: dropdown_class } + = dropdown_title "Select Git revision" + = dropdown_filter "Filter by Git revision" + = dropdown_content + = dropdown_loading diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index 9a8252ab087..2029eb5824a 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -6,8 +6,8 @@ - @options && @options.each do |key, value| = hidden_field_tag key, value, id: nil .dropdown - = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown" } - .dropdown-menu.dropdown-menu-selectable{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } + = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown git-revision-dropdown-toggle" } + .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } = dropdown_title "Switch branch/tag" = dropdown_filter "Search branches and tags" = dropdown_content diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 9c5053dace5..b200e5fc528 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -4,8 +4,7 @@ = render "projects/services/#{@service.to_param}/help", subject: subject - elsif @service.help.present? .well - = preserve do - = markdown @service.help + = markdown @service.help .service-settings .form-group diff --git a/app/views/shared/_user_callout.html.haml b/app/views/shared/_user_callout.html.haml index 8f1293adcb1..8308baa7829 100644 --- a/app/views/shared/_user_callout.html.haml +++ b/app/views/shared/_user_callout.html.haml @@ -3,12 +3,11 @@ %button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss customize experience box' } = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') - .row - .col-sm-3.col-xs-12.svg-container - = custom_icon('icon_customization') - .col-sm-8.col-xs-12.inner-content - %h4 - Customize your experience - %p - Change syntax themes, default project pages, and more in preferences. - = link_to 'Check it out', profile_preferences_path, class: 'btn btn-default js-close-callout' + .svg-container + = custom_icon('icon_customization') + .user-callout-copy + %h4 + Customize your experience + %p + Change syntax themes, default project pages, and more in preferences. + = link_to 'Check it out', profile_preferences_path, class: 'btn btn-primary js-close-callout' diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index 7a7e3d46796..046b127f73c 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -3,10 +3,10 @@ - has_button = button_path || project_select_button .row.empty-state - .pull-right.col-xs-12{ class: "#{'col-sm-6' if has_button}" } + .col-xs-12 .svg-content = render 'shared/empty_states/icons/issues.svg' - .col-xs-12{ class: "#{'col-sm-6' if has_button}" } + .col-xs-12.text-center .text-content - if has_button && current_user %h4 @@ -16,6 +16,7 @@ Also, issues are searchable and filterable. - if project_select_button = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue' + = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' - else - %h4 There are no issues to show. - = link_to 'New issue', button_path, class: 'btn btn-new', title: 'New issue', id: 'new_issue_link' + .text-center + %h4 There are no issues to show. diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml index 00fb77bdb3b..5e2f4cf109d 100644 --- a/app/views/shared/empty_states/_labels.html.haml +++ b/app/views/shared/empty_states/_labels.html.haml @@ -1,8 +1,8 @@ .row.empty-state.labels - .pull-right.col-xs-12.col-sm-6 + .col-xs-12 .svg-content = render 'shared/empty_states/icons/labels.svg' - .col-xs-12.col-sm-6 + .col-xs-12.text-center .text-content %h4 Labels can be applied to issues and merge requests to categorize them. %p You can also star a label to make it a priority label. diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml new file mode 100644 index 00000000000..3e64f403b8b --- /dev/null +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -0,0 +1,22 @@ +- button_path = local_assigns.fetch(:button_path, false) +- project_select_button = local_assigns.fetch(:project_select_button, false) +- has_button = button_path || project_select_button + +.row.empty-state.merge-requests + .col-xs-12 + .svg-content + = render 'shared/empty_states/icons/merge_requests.svg' + .col-xs-12.text-center + .text-content + - if has_button + %h4 + Merge requests are a place to propose changes you've made to a project and discuss those changes with others. + %p + Interested parties can even contribute by pushing commits if they want to. + - if project_select_button + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: 'New merge request' + - else + = link_to 'New merge request', button_path, class: 'btn btn-new', title: 'New merge request', id: 'new_merge_request_link' + - else + %h4.text-center + There are no merge requests to show. diff --git a/app/views/shared/empty_states/icons/_merge_requests.svg b/app/views/shared/empty_states/icons/_merge_requests.svg new file mode 100644 index 00000000000..e77f6319a95 --- /dev/null +++ b/app/views/shared/empty_states/icons/_merge_requests.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="755 221 385 225" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="278" height="179" rx="10"/><mask id="d" width="278" height="179" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M13.6 49H57c5.5 0 10-4.5 10-10V10c0-5.5-4.5-10-10-10H10C4.5 0 0 4.5 0 10v42c0 5.5 3.2 7 7.2 3l6.4-6z"/><mask id="e" width="67" height="57.2" x="0" y="0" fill="#fff"><use xlink:href="#b"/></mask><path id="c" d="M13.6 49H57c5.5 0 10-4.5 10-10V10c0-5.5-4.5-10-10-10H10C4.5 0 0 4.5 0 10v42c0 5.5 3.2 7 7.2 3l6.4-6z"/><mask id="f" width="67" height="57.2" x="0" y="0" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#F9F9F9" transform="translate(752 227)"><rect width="120" height="22" x="30" rx="11"/><rect width="132" height="22" y="44" rx="11"/><rect width="190" height="22" x="208" y="66" rx="11"/><rect width="158" height="22" x="129" y="197" rx="11"/><rect width="158" height="22" x="66" y="154" rx="11"/><rect width="350" height="22" x="31" y="110" rx="11"/><path d="M153 22H21h21.5c6 0 11 5 11 11s-5 11-11 11H21h132-36.5c-6 0-11-5-11-11s5-11 11-11H153zm252 66H288h36.5c6 0 11 5 11 11s-5 11-11 11H288h117-36.5c-6 0-11-5-11-11s5-11 11-11H405zm-244 44H44h36.5c6 0 11 5 11 11s-5 11-11 11H44h117-36.5c-6 0-11-5-11-11s5-11 11-11H161zm75 44H119h21.5c6 0 11 5 11 11s-5 11-11 11H119h117-51.5c-6 0-11-5-11-11s5-11 11-11H236z"/></g><g transform="translate(812 240)"><use fill="#FFF" stroke="#EEE" stroke-width="8" mask="url(#d)" xlink:href="#a"/><path fill="#EEE" d="M4 29h271v4H4z"/><g transform="translate(34 60)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(34 93)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#FC6D26" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#FC6D26" opacity=".5" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#EEE" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" opacity=".5" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(34 126)"><rect width="6" height="2" y="1" fill="#B5A7DD" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#EEE" rx="2"/><rect width="20" height="4" x="48" fill="#FC6D26" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#FC6D26" opacity=".5" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#B5A7DD" rx="1"/><rect width="6" height="2" y="23" fill="#B5A7DD" rx="1"/></g><g transform="translate(157 59)"><rect width="6" height="2" y="1" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" fill="#EEE" rx="2"/><rect width="15" height="4" x="72" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="22" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="15" height="4" x="53" y="11" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="48" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="22" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="29" y="11" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" fill="#6B4FBB" rx="2"/><rect width="10" height="4" x="15" y="11" fill="#EEE" rx="2"/><rect width="6" height="2" y="12" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="23" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="34" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" y="33" fill="#EEE" rx="2"/><rect width="15" height="4" x="58" y="22" fill="#EEE" rx="2"/><rect width="15" height="4" x="39" y="55" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="15" height="4" x="29" y="44" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="48" y="33" fill="#6B4FBB" rx="2"/><rect width="20" height="4" x="15" y="55" fill="#EEE" rx="2"/><rect width="10" height="4" x="34" y="33" fill="#EEE" rx="2"/><rect width="10" height="4" x="15" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="48" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="62" y="44" fill="#EEE" rx="2"/><rect width="10" height="4" x="77" y="22" fill="#EEE" rx="2"/><rect width="6" height="2" y="45" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="56" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="67" fill="#FDE5D8" rx="1"/><rect width="15" height="4" x="15" y="66" fill="#6B4FBB" rx="2"/><rect width="15" height="4" x="39" y="88" fill="#EEE" rx="2"/><rect width="15" height="4" x="53" y="77" fill="#6B4FBB" opacity=".5" rx="2"/><rect width="20" height="4" x="15" y="88" fill="#EEE" rx="2"/><rect width="20" height="4" x="29" y="77" fill="#6B4FBB" rx="2"/><rect width="10" height="4" x="34" y="66" fill="#EEE" rx="2"/><rect width="10" height="4" x="72" y="77" fill="#EEE" rx="2"/><rect width="10" height="4" x="15" y="77" fill="#EEE" rx="2"/><rect width="6" height="2" y="78" fill="#FDE5D8" rx="1"/><rect width="6" height="2" y="89" fill="#FDE5D8" rx="1"/></g></g><g transform="translate(1057 221)"><use fill="#FFF" stroke="#FDE5D8" stroke-width="8" mask="url(#e)" xlink:href="#b"/><rect width="29" height="3" x="14" y="14" fill="#FDB692" rx="1.5"/><rect width="39" height="3" x="14" y="23" fill="#FDB692" rx="1.5"/><rect width="29" height="3" x="14" y="32" fill="#FDB692" rx="1.5"/></g><g transform="translate(1046 285)"><circle cx="16" cy="15" r="15" fill="#FFF7F4" stroke="#FC6D26" stroke-width="3"/><path stroke="#FC6D26" stroke-width="2" d="M0 14h1c5 0 9.2-2.7 11.4-6.7M14 1V0"/><path stroke="#FC6D26" stroke-width="2" d="M7.8 3c3 4.3 7.8 7 13.2 7 3.3 0 6.3-1 9-2.7"/><circle cx="10.5" cy="17.5" r="1.5" fill="#FC6D26"/><circle cx="21.5" cy="17.5" r="1.5" fill="#FC6D26"/></g><g transform="translate(825 370)"><circle cx="15" cy="16" r="15" fill="#F4F1FA" stroke="#6B4FBB" stroke-width="3"/><path fill="#6B4FBB" d="M25 7h2.7C25 2.8 20.4 0 15 0 9.6 0 5 2.8 2.3 7H5l2.5-3L10 7l2.5-3L15 7l2.5-3L20 7l2.5-3L25 7z"/><circle cx="9.5" cy="17.5" r="1.5" fill="#6B4FBB"/><circle cx="20.5" cy="17.5" r="1.5" fill="#6B4FBB"/></g><g transform="matrix(-1 0 0 1 840 306)"><use fill="#FFF" stroke="#E2DCF2" stroke-width="8" mask="url(#f)" xlink:href="#c"/><rect width="29" height="3" x="24" y="14" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="19" height="3" x="34" y="23" fill="#6B4FBB" opacity=".5" rx="1.5"/><rect width="19" height="3" x="34" y="32" fill="#6B4FBB" opacity=".5" rx="1.5"/></g></g></svg> diff --git a/app/views/shared/empty_states/icons/_pipelines_empty.svg b/app/views/shared/empty_states/icons/_pipelines_empty.svg index 8119d5bebe0..7c672538097 100644 --- a/app/views/shared/empty_states/icons/_pipelines_empty.svg +++ b/app/views/shared/empty_states/icons/_pipelines_empty.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd" transform="translate(0-3)"><g transform="translate(0 105)"><g fill="#e5e5e5"><rect width="78" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g transform="translate(0 4)"><path fill="#98d7b2" fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path fill="#31af64" d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(69 3)"><path fill="#e5e5e5" fill-rule="nonzero" d="m4 11.99v60.02c0 4.413 3.583 7.99 8 7.99h89.991c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99m-4 0c0-6.622 5.378-11.99 12-11.99h89.991c6.629 0 12 5.367 12 11.99v60.02c0 6.622-5.378 11.99-12 11.99h-89.991c-6.629 0-12-5.367-12-11.99v-60.02m52.874 80.3l-13.253-15.292h34.76l-13.253 15.292c-2.237 2.582-6.01 2.585-8.253 0m3.02-2.62c.644.743 1.564.743 2.207 0l7.516-8.673h-17.24l7.516 8.673"/><rect width="18" height="6" x="15" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="39" y="39" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="33" y="55" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="39" y="23" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="57" y="55" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="15" y="55" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="81" y="23" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="15" y="39" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="57" y="23" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="69" y="23" rx="3"/><rect width="6" height="6" x="75" y="39" rx="3"/></g><rect width="6" height="6" x="63" y="39" fill="#e52c5a" rx="3"/></g><g transform="matrix(.70711-.70711.70711.70711 84.34 52.5)"><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26"/><path fill="#fff" fill-opacity=".3" stroke="#6b4fbb" stroke-width="8" d="m31 71c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30" transform="matrix(.86603.5-.5.86603 26.663-17.507)"/></g></g></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 150"><g fill="none" fill-rule="evenodd"><g transform="translate(0 102)"><g fill="#e5e5e5"><rect width="74" height="4" x="34" y="21" opacity=".5" rx="2"/><path d="m152 23c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2m14 0c0-1.105.887-2 1.998-2h4c1.104 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4c-1.104 0-1.998-.888-1.998-2"/></g><g fill="#31af64" transform="translate(0 4)"><path fill-rule="nonzero" d="m19 38c-10.493 0-19-8.507-19-19 0-10.493 8.507-19 19-19 10.493 0 19 8.507 19 19 0 10.493-8.507 19-19 19m0-4c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15"/><path d="m17.07 21.02l-2.829-2.829c-.786-.786-2.047-.781-2.828 0-.786.786-.781 2.047 0 2.828l4.243 4.243c.392.392.902.587 1.412.588.512.002 1.021-.193 1.41-.582l7.79-7.79c.777-.777.775-2.042-.006-2.823-.786-.786-2.045-.784-2.823-.006l-6.37 6.37"/></g><g fill="#e52c5a" transform="translate(102)"><path fill-rule="nonzero" d="m24 47.5c-12.979 0-23.5-10.521-23.5-23.5 0-12.979 10.521-23.5 23.5-23.5 12.979 0 23.5 10.521 23.5 23.5 0 12.979-10.521 23.5-23.5 23.5m0-5c10.217 0 18.5-8.283 18.5-18.5 0-10.217-8.283-18.5-18.5-18.5-10.217 0-18.5 8.283-18.5 18.5 0 10.217 8.283 18.5 18.5 18.5"/><path d="m28.24 24l2.833-2.833c1.167-1.167 1.167-3.067-.004-4.239-1.169-1.169-3.069-1.173-4.239-.004l-2.833 2.833-2.833-2.833c-1.167-1.167-3.067-1.167-4.239.004-1.169 1.169-1.173 3.069-.004 4.239l2.833 2.833-2.833 2.833c-1.167 1.167-1.167 3.067.004 4.239 1.169 1.169 3.069 1.173 4.239.004l2.833-2.833 2.833 2.833c1.167 1.167 3.067 1.167 4.239-.004 1.169-1.169 1.173-3.069.004-4.239l-2.833-2.833"/></g><path fill="#e5e5e5" fill-rule="nonzero" d="m236 37c-7.732 0-14-6.268-14-14 0-7.732 6.268-14 14-14 7.732 0 14 6.268 14 14 0 7.732-6.268 14-14 14m0-4c5.523 0 10-4.477 10-10 0-5.523-4.477-10-10-10-5.523 0-10 4.477-10 10 0 5.523 4.477 10 10 10"/></g><g transform="translate(73 4)"><path stroke="#e5e5e5" stroke-width="4" d="m64.82 76h33.18c4.419 0 8-3.579 8-7.99v-60.02c0-4.413-3.583-7.99-8-7.99h-89.991c-4.419 0-8 3.579-8 7.99v60.02c0 4.413 3.583 7.99 8 7.99h31.935l9.263 9.855c1.725 1.835 4.631 1.833 6.354 0l9.263-9.855"/><rect width="18" height="6" x="11" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="35" y="35" fill="#e52c5a" rx="3"/><rect width="18" height="6" x="29" y="51" fill="#e5e5e5" rx="3"/><rect width="12" height="6" x="35" y="19" fill="#fde5d8" rx="3"/><rect width="12" height="6" x="53" y="51" fill="#e52c5a" rx="3"/><rect width="12" height="6" x="11" y="51" fill="#b5a7dd" rx="3"/><rect width="18" height="6" x="77" y="19" fill="#fc8a51" rx="3"/><rect width="18" height="6" x="11" y="35" fill="#fde5d8" rx="3"/><rect width="6" height="6" x="53" y="19" fill="#e52c5a" rx="3"/><g fill="#fde5d8"><rect width="6" height="6" x="65" y="19" rx="3"/><rect width="6" height="6" x="71" y="35" rx="3"/></g><rect width="6" height="6" x="59" y="35" fill="#e52c5a" rx="3"/></g><path fill="#6b4fbb" fill-rule="nonzero" d="m28.02 67.48c-15.927-2.825-28.02-16.738-28.02-33.476 0-18.778 15.222-34 34-34 18.778 0 34 15.222 34 34 0 16.738-12.1 30.652-28.02 33.476.015.173.023.347.023.524v21.999c0 3.314-2.693 6-6 6-3.314 0-6-2.682-6-6v-21.999c0-.177.008-.351.023-.524m5.977-7.476c14.359 0 26-11.641 26-26 0-14.359-11.641-26-26-26-14.359 0-26 11.641-26 26 0 14.359 11.641 26 26 26" transform="matrix(.70711-.70711.70711.70711 84.34 49.5)"/></g></svg> diff --git a/app/views/shared/empty_states/monitoring/_getting_started.svg b/app/views/shared/empty_states/monitoring/_getting_started.svg new file mode 100644 index 00000000000..db7a1c2e708 --- /dev/null +++ b/app/views/shared/empty_states/monitoring/_getting_started.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="159.8" height="127.81" x=".196" y="5" rx="10"/><rect id="2" width="160" height="128" x=".666" y=".41" rx="10"/><rect id="4" width="160.19" height="128.19" x=".339" y=".59" rx="10"/><mask id="1" width="159.8" height="127.81" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="3" width="160" height="128" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="5" width="160.19" height="128.19" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(12 3)"><rect width="160" height="128" x="122.08" y="146.08" fill="#f9f9f9" transform="matrix(.99619.08716-.08716.99619 19.08-16.813)" rx="10"/><g transform="matrix(.96593.25882-.25882.96593 227.1 57.47)"><rect width="159.8" height="127.81" x="1.64" y="10.06" fill="#f9f9f9" rx="8"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><g transform="translate(24.368 36.951)"><path fill="#d2caea" fill-rule="nonzero" d="m71.785 44.2c.761.296 1.625.099 2.184-.496l35.956-38.34c.756-.806.715-2.071-.091-2.827-.806-.756-2.071-.715-2.827.091l-35.03 37.36-41.888-16.285c-.749-.291-1.6-.106-2.16.471l-26.368 27.16c-.769.793-.751 2.059.042 2.828.793.769 2.059.751 2.828-.042l25.444-26.21 41.911 16.294"/><g fill="#fff"><circle cx="5.716" cy="5.104" r="5" stroke="#6b4fbb" stroke-width="4" transform="translate(65.917 34.945)"/><g stroke="#fb722e"><ellipse cx="4.632" cy="50.05" stroke-width="3.2" rx="4" ry="3.999"/><g stroke-width="4"><ellipse cx="29.632" cy="27.05" rx="4" ry="3.999"/><ellipse cx="107.63" cy="4.048" rx="4" ry="3.999"/></g></g></g></g></g><rect width="160.19" height="128.19" x="36.28" y="86.74" fill="#f9f9f9" transform="matrix(.99619-.08716.08716.99619-12.703 10.717)" rx="10"/><g transform="matrix(.99619.08716-.08716.99619 126.61 137.8)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#3)" xlink:href="#2"/><path fill="#6b4fbb" stroke="#6b4fbb" stroke-width="3.2" d="m84.67 28.41c18.225 0 33 15.07 33 33.651h-33v-33.651" stroke-linecap="round" stroke-linejoin="round"/><path fill="#d2caea" fill-rule="nonzero" d="m78.67 66.41h30c1.105 0 2 .895 2 2 0 18.778-15.222 34-34 34-18.778 0-34-15.222-34-34 0-18.778 15.222-34 34-34 1.105 0 2 .895 2 2v30m-32 2c0 16.569 13.431 30 30 30 15.896 0 28.905-12.364 29.934-28h-29.934c-1.105 0-2-.895-2-2v-29.934c-15.636 1.029-28 14.04-28 29.934"/></g><g transform="matrix(.99619-.08716.08716.99619 30 88.03)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><g transform="translate(42 34)"><path fill="#fef0ea" d="m0 13.391c0-.768.628-1.391 1.4-1.391h9.2c.773 0 1.4.626 1.4 1.391v49.609h-12v-49.609"/><path fill="#fb722e" d="m66 21.406c0-.777.628-1.406 1.4-1.406h9.2c.773 0 1.4.624 1.4 1.406v41.594h-12v-41.594"/><path fill="#6b4fbb" d="m22 1.404c0-.776.628-1.404 1.4-1.404h9.2c.773 0 1.4.624 1.4 1.404v61.6h-12v-61.6"/><path fill="#d2caea" d="m44 39.4c0-.772.628-1.398 1.4-1.398h9.2c.773 0 1.4.618 1.4 1.398v23.602h-12v-23.602"/></g></g><g fill="#fee8dc"><path d="m6.226 94.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" transform="matrix(.70711.70711-.70711.70711 66.33 22.317)"/><path d="m312.78 53.43l-3.634.807c-1.296.288-2.115-.52-1.825-1.825l.807-3.634-.807-3.634c-.288-1.296.52-2.115 1.825-1.825l3.634.807 3.634-.807c1.296-.288 2.115.52 1.825 1.825l-.807 3.634.807 3.634c.288 1.296-.52 2.115-1.825 1.825l-3.634-.807" transform="matrix(.70711.70711-.70711.70711 126.1-206.88)"/></g><path fill="#e1dcf1" d="m124.78 12.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" transform="matrix(.70711-.70711.70711.70711 31.05 90.51)"/><path fill="#d2caea" d="m374.78 244.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" transform="matrix(.70711-.70711.70711.70711-59.779 335.24)"/></g></svg>
\ No newline at end of file diff --git a/app/views/shared/empty_states/monitoring/_loading.svg b/app/views/shared/empty_states/monitoring/_loading.svg new file mode 100644 index 00000000000..6bbd7a6c5b9 --- /dev/null +++ b/app/views/shared/empty_states/monitoring/_loading.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="C" width="161" height="100" x="92" y="181" rx="10"/><rect id="E" width="151" height="32" x="20" rx="10"/><rect id="G" width="191" height="62" y="10" rx="10"/><circle id="I" cx="23" cy="41" r="9"/><circle id="4" cx="36.5" cy="36.5" r="36.5"/><circle id="8" cx="262.5" cy="169.5" r="15.5"/><circle id="A" cx="79.5" cy="169.5" r="15.5"/><circle id="K" cx="45" cy="41" r="9"/><circle id="0" cx="30.5" cy="30.5" r="30.5"/><circle id="2" cx="18" cy="34" r="3"/><ellipse id="6" cx="43.5" cy="43.5" rx="43.5" ry="43.5"/><mask id="H" width="191" height="62" x="0" y="0" fill="#fff"><use xlink:href="#G"/></mask><mask id="J" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#I"/></mask><mask id="D" width="161" height="100" x="0" y="0" fill="#fff"><use xlink:href="#C"/></mask><mask id="F" width="151" height="32" x="0" y="0" fill="#fff"><use xlink:href="#E"/></mask><mask id="9" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#8"/></mask><mask id="1" width="61" height="61" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="B" width="31" height="31" x="0" y="0" fill="#fff"><use xlink:href="#A"/></mask><mask id="3" width="6" height="6" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="7" width="87" height="87" x="0" y="0" fill="#fff"><use xlink:href="#6"/></mask><mask id="L" width="18" height="18" x="0" y="0" fill="#fff"><use xlink:href="#K"/></mask><mask id="5" width="73" height="73" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(28 2)"><g transform="translate(133 87)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><path stroke="#d2caea" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="m19 32l2-9 5 17 4-12 4 5 6-10 3 5"/><g fill="#fff" stroke="#fb722e"><use stroke-width="4" mask="url(#3)" xlink:href="#2"/><circle cx="44" cy="30" r="2" stroke-width="2"/></g></g><g transform="translate(188 29)"><circle cx="36.5" cy="41.5" r="36.5" fill="#f9f9f9"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><rect width="27" height="4" x="23" y="27" fill="#d2caea" rx="2"/><rect width="10.5" height="4" x="23" y="27" fill="#6b4fbb" rx="2"/><rect width="27" height="4" x="23" y="36" fill="#d2caea" rx="2"/><rect width="19" height="4" x="23" y="36" fill="#6b4fbb" rx="2"/><rect width="27" height="4" x="23" y="45" fill="#d2caea" rx="2"/><rect width="7" height="4" x="23" y="45" fill="#6b4fbb" rx="2"/></g><path fill="#eee" fill-rule="nonzero" d="m247 292v1c0 5.519-4.469 9.993-10.01 9.993h-125.99c-5.177 0-9.436-3.927-9.954-8.96 1.348.998 2.957 1.666 4.705 1.883 1.027 1.835 2.992 3.077 5.248 3.077h125.99c2.485 0 4.611-1.497 5.526-3.637 1.796-.675 3.347-1.852 4.48-3.359m1.947-8.962c-.518 5.03-4.774 8.958-9.95 8.958h-131.99c-4.929 0-9.03-3.563-9.851-8.25 1.382.767 2.964 1.216 4.649 1.248 1.037 1.794 2.978 3 5.202 3h131.99c2.255 0 4.219-1.241 5.245-3.076 1.748-.216 3.356-.883 4.705-1.882"/><g transform="translate(79)"><ellipse cx="43.5" cy="47.5" fill="#f9f9f9" rx="43.5" ry="43.5"/><g fill="#fff"><g stroke="#eee"><use stroke-width="8" mask="url(#7)" xlink:href="#6"/><path stroke-width="4" d="m18.595 49c2.515 11.44 12.71 20 24.905 20 14.08 0 25.5-11.417 25.5-25.5 0-12.195-8.56-22.391-20-24.905v15.959c3 1.848 5 5.164 5 8.946 0 5.799-4.701 10.5-10.5 10.5-3.782 0-7.098-2-8.946-5h-15.959" stroke-linejoin="round"/></g><path stroke="#d2caea" stroke-width="4" d="m18 44c-.003-.166-.005-.333-.005-.5 0-14.08 11.417-25.5 25.5-25.5.167 0 .334.002.5.005v15.01c-.166-.008-.332-.012-.5-.012-5.799 0-10.5 4.701-10.5 10.5 0 .168.004.334.012.5h-15.01" stroke-linejoin="round"/></g></g><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#9)" xlink:href="#8"/><use mask="url(#B)" xlink:href="#A"/><use mask="url(#D)" xlink:href="#C"/></g><g fill="#eee"><rect width="15" height="2" x="226" y="247" rx="1"/><rect width="15" height="2" x="226" y="242" rx="1"/><rect width="15" height="2" x="226" y="252" rx="1"/></g><rect width="10" height="52" x="118" y="196" fill="#d2caea" rx="2"/><rect width="10" height="47" x="154" y="196" fill="#6b4fbb" rx="2"/><rect width="10" height="37" x="190" y="196" fill="#d2caea" rx="2"/><g fill="#fee8dc"><rect width="10" height="52" x="132" y="185" rx="2"/><rect width="10" height="38" x="168" y="185" rx="2"/></g><rect width="10" height="58" x="204" y="185" fill="#fb722e" rx="2"/><g transform="translate(76 128)"><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#F)" xlink:href="#E"/><use mask="url(#H)" xlink:href="#G"/></g><g fill="#d2caea"><rect width="16" height="4" x="156" y="35" rx="2"/><rect width="16" height="4" x="156" y="43" rx="2"/></g><g fill="#fff" stroke-width="8"><use stroke="#fee8dc" mask="url(#J)" xlink:href="#I"/><use stroke="#fb722e" mask="url(#L)" xlink:href="#K"/></g></g><g fill="#fb722e"><path d="m6.226 220.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" opacity=".2" transform="matrix(.70711.70711-.70711.70711 155.43 59.22)"/><path d="m256.23 9.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" opacity=".2" transform="matrix(.70711.70711-.70711.70711 79.45-179.36)"/></g><path fill="#fee8dc" d="m312.78 150.43l-3.634.807c-1.296.288-2.115-.52-1.825-1.825l.807-3.634-.807-3.634c-.288-1.296.52-2.115 1.825-1.825l3.634.807 3.634-.807c1.296-.288 2.115.52 1.825 1.825l-.807 3.634.807 3.634c.288 1.296-.52 2.115-1.825 1.825l-3.634-.807" transform="matrix(.70711.70711-.70711.70711 194.69-178.47)"/><path fill="#6b4fbb" d="m43.778 80.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" opacity=".2" transform="matrix(.70711-.70711.70711.70711-40.761 53.15)"/></g></svg>
\ No newline at end of file diff --git a/app/views/shared/empty_states/monitoring/_unable_to_connect.svg b/app/views/shared/empty_states/monitoring/_unable_to_connect.svg new file mode 100644 index 00000000000..62537d87d5d --- /dev/null +++ b/app/views/shared/empty_states/monitoring/_unable_to_connect.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><use id="0" xlink:href="#E"/><use id="2" xlink:href="#E"/><use id="4" xlink:href="#E"/><path id="6" d="m74 93h26v47h-26z"/><path id="8" d="m74 93h26v47h-26z"/><rect id="A" width="65" height="14" x="55" y="135" rx="4"/><rect id="C" width="175" height="118" rx="10"/><rect id="E" width="159" rx="10" height="56"/><rect id="F" width="160" y="2" rx="10" height="56" fill="#f9f9f9"/><mask id="B" width="65" height="14" x="0" y="0" fill="#fff"><use xlink:href="#A"/></mask><mask id="9" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#8"/></mask><mask id="D" width="175" height="118" x="0" y="0" fill="#fff"><use xlink:href="#C"/></mask><mask id="7" width="26" height="47" x="0" y="0" fill="#fff"><use xlink:href="#6"/></mask><mask id="3" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="1" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="5" width="159" height="56" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(1 65)"><g transform="translate(244)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><g fill-rule="nonzero"><path fill="#fb722e" d="m134 31c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m117 31c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6m-17-4c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><g fill="#d2caea"><rect width="50" height="4" x="19" y="20" rx="2"/><rect width="50" height="4" x="19" y="34" rx="2"/></g><g transform="translate(0 59)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#3)" xlink:href="#2"/><g fill-rule="nonzero"><path fill="#fee8dc" d="m134 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fb722e" d="m117 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m100 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><rect width="50" height="4" x="19" y="19" fill="#d2caea" rx="2" id="G"/><rect width="50" height="4" x="19" y="33" fill="#d2caea" rx="2" id="H"/></g><g transform="translate(0 118)"><use xlink:href="#F"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><g fill-rule="nonzero"><path fill="#fb722e" d="m134 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/><path fill="#fee8dc" d="m117 30c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6m-17-4c1.105 0 2-.895 2-2 0-1.105-.895-2-2-2-1.105 0-2 .895-2 2 0 1.105.895 2 2 2m0 4c-3.314 0-6-2.686-6-6 0-3.314 2.686-6 6-6 3.314 0 6 2.686 6 6 0 3.314-2.686 6-6 6"/></g><use xlink:href="#G"/><use xlink:href="#H"/></g></g><g transform="translate(163 55)"><g fill="#eee"><rect width="29" height="4" y="29" rx="2"/><rect width="28" height="4" x="55" y="29" rx="2"/></g><g transform="translate(16)"><circle cx="30" cy="30" r="24" fill="#fef0ea"/><g fill="#fb722e"><circle cx="30.5" cy="30.5" r="30.5" opacity=".1"/><circle cx="30.5" cy="30.5" r="19.5" opacity=".1"/></g><circle cx="30.5" cy="30.5" r="13.5" fill="#fff"/><path fill="#fb722e" d="m32.621 30.5l2.481-2.481c.586-.586.58-1.529-.006-2.115-.59-.59-1.533-.589-2.115-.006l-2.481 2.481-2.481-2.481c-.586-.586-1.529-.58-2.115.006-.59.59-.589 1.533-.006 2.115l2.481 2.481-2.481 2.481c-.586.586-.58 1.529.006 2.115.59.59 1.533.589 2.115.006l2.481-2.481 2.481 2.481c.586.586 1.529.58 2.115-.006.59-.59.589-1.533.006-2.115l-2.481-2.481"/></g></g><g transform="translate(0 13)"><rect width="65" height="14" x="55" y="137" fill="#f9f9f9" rx="4"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#7)" xlink:href="#6"/><rect width="175" height="118" y="3" fill="#f9f9f9" rx="10"/><g fill="#fff" stroke="#eee" stroke-width="8"><use mask="url(#9)" xlink:href="#8"/><use mask="url(#B)" xlink:href="#A"/><use mask="url(#D)" xlink:href="#C"/></g><g fill-rule="nonzero"><path fill="#eee" d="m163 105v-93h-152v93h152m-156-93.01c0-2.204 1.797-3.99 3.995-3.99h152.01c2.206 0 3.995 1.796 3.995 3.99v93.02c0 2.204-1.797 3.99-3.995 3.99h-152.01c-2.206 0-3.995-1.796-3.995-3.99v-93.02"/><path fill="#d2caea" d="m86 92c-11.598 0-21-9.402-21-21 0-11.598 9.402-21 21-21 11.598 0 21 9.402 21 21 0 11.598-9.402 21-21 21m0-4c9.389 0 17-7.611 17-17 0-9.389-7.611-17-17-17-9.389 0-17 7.611-17 17 0 9.389 7.611 17 17 17"/></g><path fill="#6b4fbb" d="m83 63c0-1.659 1.347-3 3-3 1.657 0 3 1.342 3 3v7.993c0 1.659-1.347 3-3 3-1.657 0-3-1.342-3-3v-7.993m3 18.997c-1.657 0-3-1.343-3-3 0-1.657 1.343-3 3-3 1.657 0 3 1.343 3 3 0 1.657-1.343 3-3 3"/><g fill="#eee"><rect width="134" height="4" x="20" y="30" rx="2"/><rect width="14" height="4" x="20" y="20" rx="2"/><circle cx="87" cy="21" r="5"/></g></g></g></svg>
\ No newline at end of file diff --git a/app/views/shared/errors/_graphic_422.svg b/app/views/shared/errors/_graphic_422.svg new file mode 100644 index 00000000000..87128ecd69d --- /dev/null +++ b/app/views/shared/errors/_graphic_422.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 246" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="178" height="136" rx="10"/><mask id="1" width="178" height="136" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><g fill="#e5e5e5" fill-rule="nonzero"><path d="m109.88 37.634c5.587-3.567 12.225-5.634 19.345-5.634 7.445 0 14.363 2.26 20.1 6.132l21.435-37.13c.554-.959 1.771-1.292 2.734-.736.957.552 1.284 1.777.73 2.736l-21.496 37.23c-.065.112-.138.215-.219.309 3.686 3.13 6.733 6.988 8.919 11.353l-3.393.002c-5.775-10.322-16.705-16.901-28.814-16.901-12.12 0-23.06 6.594-28.833 16.935l-3.393.002c2.32-4.646 5.616-8.72 9.618-11.954l-21.349-36.977c-.554-.959-.227-2.184.73-2.736.963-.556 2.181-.223 2.734.736l21.15 36.629"/><path d="m3 70v134c0 9.389 7.611 17 16.997 17h220.01c9.389 0 16.997-7.611 16.997-17v-134c0-9.389-7.611-17-16.997-17h-220.01c-9.389 0-16.997 7.611-16.997 17m-3 0c0-11.05 8.95-20 19.997-20h220.01c11.04 0 19.997 8.958 19.997 20v134c0 11.05-8.95 20-19.997 20h-220.01c-11.04 0-19.997-8.958-19.997-20v-134"/></g><ellipse cx="129" cy="241.5" fill="#f9f9f9" rx="89" ry="4.5"/><g fill-rule="nonzero" transform="translate(210 70)"><path fill="#eaeaea" d="m16 29c7.18 0 13-5.82 13-13 0-7.18-5.82-13-13-13-7.18 0-13 5.82-13 13 0 7.18 5.82 13 13 13m0 3c-8.837 0-16-7.163-16-16 0-8.837 7.163-16 16-16 8.837 0 16 7.163 16 16 0 8.837-7.163 16-16 16" id="2"/><path fill="#6b4fbb" d="m16 21c2.761 0 5-2.239 5-5 0-2.761-2.239-5-5-5-2.761 0-5 2.239-5 5 0 2.761 2.239 5 5 5m0 3c-4.418 0-8-3.582-8-8 0-4.418 3.582-8 8-8 4.418 0 8 3.582 8 8 0 4.418-3.582 8-8 8" id="3"/></g><g fill-rule="nonzero" transform="translate(210 109)"><use xlink:href="#2"/><use xlink:href="#3"/></g><g transform="translate(210 147)"><path fill="#e5e5e5" fill-rule="nonzero" d="m3 5.992v45.02c0 1.647 1.346 2.992 3 2.992h20c1.657 0 3-1.341 3-2.992v-45.02c0-1.647-1.346-2.992-3-2.992h-20c-1.657 0-3 1.341-3 2.992m-3 0c0-3.309 2.687-5.992 6-5.992h20c3.314 0 6 2.692 6 5.992v45.02c0 3.309-2.687 5.992-6 5.992h-20c-3.314 0-6-2.692-6-5.992v-45.02"/><rect width="16" height="4" x="8" y="27" fill="#fdb692" rx="2"/><rect width="16" height="4" x="8" y="19" fill="#fc9867" rx="2"/><rect width="16" height="4" x="8" y="11" fill="#fc6d26" rx="2"/><rect width="16" height="4" x="8" y="35" fill="#fed3bd" rx="2"/><rect width="16" height="4" x="8" y="43" fill="#fef0e9" rx="2"/></g><g transform="translate(16 69)"><use fill="#6b4fbb" fill-opacity=".1" stroke="#e5e5e5" stroke-width="6" mask="url(#1)" xlink:href="#0"/><g class="tv-screen" fill="#fff"><path opacity=".4" mix-blend-mode="overlay" d="m3 17h172v16h-172z"/><path opacity=".6" mix-blend-mode="overlay" d="m3 70h172v24h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 107h172v16h-172z"/><path opacity=".4" mix-blend-mode="overlay" d="m3 40h172v8h-172z"/><path opacity=".3" mix-blend-mode="overlay" d="m3 55h172v8h-172z"/></g></g><path class="text-422" d="m.693 19h5.808c.277 0 .498-.224.498-.5 0-.268-.223-.5-.498-.5h-5.808v-2.094l3.777-5.906h3.916l-4.124 6.454h6.259v-6.454h.978c.273 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-.978v-2h4.698v6h-2.721c-.277 0-.498.224-.498.5 0 .268.223.5.498.5h2.721v2.454h2.723v4.2h-2.723v5.346h-4.698v-5.346h-9.828v-1.654m4.417-10l1.279-2h3.914l-1.278 2h-3.916m1.919-3l1.279-2h4.192c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.552l1.142-1.786h5.13v4.786h-8.191m31.09 19v1h-15.738v-2h5.118c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-5.118v-1.184l2.656-2.822c.682-.725 1.306-1.39 1.872-1.994h5.428c-.389.394-.808.815-1.256 1.264-1.428 1.428-2.562 2.568-3.403 3.42h10.442v2.316h-4.614c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.614m-6.674-13c.493-.631.87-1.208 1.129-1.73.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.589c.27 0 .5-.224.5-.5 0-.268-.224-.5-.5-.5h-3.589v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-2.782c-.27 0-.5.224-.5.5 0 .268.224.5.5.5h3.602c.654 1.01.981 2.209.981 3.605 0 .974-.163 1.887-.49 2.739-.326.852-.888 1.798-1.685 2.839-.397.509-1.261 1.448-2.594 2.816h-5.474c1.34-1.436 2.261-2.436 2.763-3h4.396c.271 0 .499-.224.499-.5 0-.268-.223-.5-.499-.5h-3.557m28.14 12v2h-15.738v-4.184l2.651-2.816h5.313c-1.087 1.089-1.976 1.983-2.668 2.684h10.442v1.316h-4.083c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h4.083m-2.069-11c-.045.061-.092.122-.139.184-.567.727-2.089 2.333-4.568 4.816h-5.372c2.601-2.77 4.204-4.503 4.81-5.198.83-.952 1.428-1.796 1.793-2.532.365-.736.548-1.464.548-2.183 0-1.107-.335-1.962-1-2.565-.67-.603-1.619-.905-2.847-.905-.874 0-1.857.174-2.947.523-1.09.349-2.227.855-3.412 1.519v-2.659h3.117c.271 0 .503-.224.503-.5 0-.268-.225-.5-.503-.5h-3.117v-.906c1.184-.432 2.344-.761 3.478-.988 1.134-.227 2.222-.34 3.262-.34 2.623 0 4.684.611 6.184 1.834.157.128.307.262.448.4h-1.248c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.069c.654 1.01.981 2.209.981 3.605 0 .844-.123 1.642-.368 2.395h-2.683c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h2.272c-.159.321-.347.655-.566 1h-3.706c-.271 0-.503.224-.503.5 0 .268.225.5.503.5h3.01" transform="translate(75 124)" fill="#5c5c5c"/></g></svg> diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 09f946f1d88..b361ec86ced 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -27,7 +27,8 @@ = visibility_level_icon(group.visibility_level, fw: false) .avatar-container.s40 - = image_tag group_icon(group), class: "avatar s40 hidden-xs" + = link_to group do + = image_tag group_icon(group), class: "avatar s40 hidden-xs" .title = link_to group_name, group, class: 'group-name' diff --git a/app/views/shared/icons/_activity.svg b/app/views/shared/icons/_activity.svg deleted file mode 100644 index d465504b154..00000000000 --- a/app/views/shared/icons/_activity.svg +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch --> - <title>path-1</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <g id="_activity" fill="#7E7D7D"> - <g id="Page-1"> - <g id="path-1"> - <path d="M5,0 C4.448,0 4,0.448 4,1 L4,3 L1,3 C0.448,3 0,3.448 0,4 L0,9 C0,9.552 0.448,10 1,10 L5,10 L5,8 L11,8 L11,10 L15,10 C15.552,10 16,9.552 16,9 L16,4 C16,3.448 15.552,3 15,3 L12,3 L12,1 C12,0.448 11.552,0 11,0 L5,0 L5,0 L5,0 L5,0 Z M6,2.5 C6,2.224 6.224,2 6.5,2 L9.5,2 C9.776,2 10,2.224 10,2.5 C10,2.776 9.776,3 9.5,3 L6.5,3 C6.224,3 6,2.776 6,2.5 L6,2.5 L6,2.5 L6,2.5 Z M6,11 L10.001,11 L10.001,9 L6,9 L6,11 L6,11 L6,11 L6,11 Z M11,11 L11,12 L5,12 L5,11 L1,11 C0.448,11 0,11.448 0,12 L0,15 C0,15.552 0.448,16 1,16 L15,16 C15.552,16 16,15.552 16,15 L16,12 C16,11.448 15.552,11 15,11 L11,11 L11,11 L11,11 L11,11 Z"></path> - </g> - </g> - </g> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_commits.svg b/app/views/shared/icons/_commits.svg deleted file mode 100644 index ba9bb89935e..00000000000 --- a/app/views/shared/icons/_commits.svg +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch --> - <title>Pasted Image 240</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <path d="M3,8 C3,5.951 4.236,4.194 6,3.422 L6,0 L1,0 C0.448,0 0,0.448 0,1 L0,15 C0,15.552 0.448,16 1,16 L6,16 L6,12.578 C4.236,11.806 3,10.049 3,8 M7,12.899 L7,16 L9,16 L9,12.899 C8.677,12.965 8.343,13 8,13 C7.657,13 7.323,12.965 7,12.899 M15,0 L10,0 L10,3.422 C11.764,4.194 13,5.951 13,8 C13,10.049 11.764,11.806 10,12.578 L10,16 L15,16 C15.552,16 16,15.552 16,15 L16,1 C16,0.448 15.552,0 15,0 M10,8 C10,9.105 9.105,10 8,10 C6.895,10 6,9.105 6,8 C6,6.895 6.895,6 8,6 C9.105,6 10,6.895 10,8 M4,8 C4,10.209 5.791,12 8,12 C10.209,12 12,10.209 12,8 C12,5.791 10.209,4 8,4 C5.791,4 4,5.791 4,8 M9,3.101 L9,0 L7,0 L7,3.101 C7.323,3.035 7.657,3 8,3 C8.343,3 8.677,3.035 9,3.101" id="Pasted-Image-240" fill="#7E7D7D"></path> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_contributionanalytics.svg b/app/views/shared/icons/_contributionanalytics.svg deleted file mode 100644 index adf09a14964..00000000000 --- a/app/views/shared/icons/_contributionanalytics.svg +++ /dev/null @@ -1,17 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.7.2 (28276) - http://www.bohemiancoding.com/sketch --> - <title>Group</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <g id="Group"> - <path d="M8,0 C3.581,0 0,3.581 0,8 C0,12.419 3.581,16 8,16 C12.419,16 16,12.419 16,8 C16,3.581 12.419,0 8,0 M8,2 C11.308,2 14,4.692 14,8 C14,11.308 11.308,14 8,14 C4.692,14 2,11.308 2,8 C2,4.692 4.692,2 8,2" id="Fill-1" fill="#7E7C7C"></path> - <polygon id="Stroke-6" fill="#7E7C7C" points="2.0197351 9.86809696 6.4567351 6.52409696 5.79233671 6.46815759 9.53233671 10.4271576 9.87070552 10.78534 10.2338016 10.4522494 15.0258016 6.05624938 14.3497984 5.31935062 9.55779844 9.71535062 10.2592633 9.74044241 6.51926329 5.78144241 6.21208651 5.45627854 5.8548649 5.72550304 1.4178649 9.06950304"></polygon> - <path d="M7.0313,6.3928 C7.0313,6.9448 6.5833,7.3928 6.0313,7.3928 C5.4793,7.3928 5.0313,6.9448 5.0313,6.3928 C5.0313,5.8408 5.4793,5.3928 6.0313,5.3928 C6.5833,5.3928 7.0313,5.8408 7.0313,6.3928" id="Fill-8" fill="#FEFEFE"></path> - <path d="M6.5313,6.3928 C6.5313,6.66865763 6.30715763,6.8928 6.0313,6.8928 C5.75544237,6.8928 5.5313,6.66865763 5.5313,6.3928 C5.5313,6.11694237 5.75544237,5.8928 6.0313,5.8928 C6.30715763,5.8928 6.5313,6.11694237 6.5313,6.3928 L6.5313,6.3928 Z M7.5313,6.3928 C7.5313,5.56465763 6.85944237,4.8928 6.0313,4.8928 C5.20315763,4.8928 4.5313,5.56465763 4.5313,6.3928 C4.5313,7.22094237 5.20315763,7.8928 6.0313,7.8928 C6.85944237,7.8928 7.5313,7.22094237 7.5313,6.3928 L7.5313,6.3928 Z" id="Stroke-10" fill="#7E7C7C"></path> - <path d="M10.8854,9.8715 C10.8854,10.4235 10.4374,10.8715 9.8854,10.8715 C9.3334,10.8715 8.8854,10.4235 8.8854,9.8715 C8.8854,9.3195 9.3334,8.8715 9.8854,8.8715 C10.4374,8.8715 10.8854,9.3195 10.8854,9.8715" id="Fill-12" fill="#FEFEFE"></path> - <path d="M10.3854,9.8715 C10.3854,10.1473576 10.1612576,10.3715 9.8854,10.3715 C9.60954237,10.3715 9.3854,10.1473576 9.3854,9.8715 C9.3854,9.59564237 9.60954237,9.3715 9.8854,9.3715 C10.1612576,9.3715 10.3854,9.59564237 10.3854,9.8715 L10.3854,9.8715 Z M11.3854,9.8715 C11.3854,9.04335763 10.7135424,8.3715 9.8854,8.3715 C9.05725763,8.3715 8.3854,9.04335763 8.3854,9.8715 C8.3854,10.6996424 9.05725763,11.3715 9.8854,11.3715 C10.7135424,11.3715 11.3854,10.6996424 11.3854,9.8715 L11.3854,9.8715 Z" id="Stroke-14" fill="#7E7C7C"></path> - </g> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_delta.svg b/app/views/shared/icons/_delta.svg deleted file mode 100644 index 7c0c0d3999c..00000000000 --- a/app/views/shared/icons/_delta.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg width="14px" height="10px" viewBox="322 21 14 10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <path d="M330.078605,22.8166945 L335.259532,29.6235062 C335.615145,30.0907182 335.412062,30.4694683 334.822641,30.4694683 L331.657805,30.4694683 L324.04678,30.4694683 C323.449879,30.4694683 323.260751,30.0822112 323.609889,29.6235062 L328.790816,22.8166945 C329.146429,22.3494825 329.729467,22.3579895 330.078605,22.8166945 Z" id="delta" stroke="#5C5C5C" stroke-width="1" fill="none"></path> -</svg> diff --git a/app/views/shared/icons/_emoji_slightly_smiling_face.svg b/app/views/shared/icons/_emoji_slightly_smiling_face.svg new file mode 100644 index 00000000000..56dbad91554 --- /dev/null +++ b/app/views/shared/icons/_emoji_slightly_smiling_face.svg @@ -0,0 +1 @@ +<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445c.195.625.556 1.131 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_emoji_smile.svg b/app/views/shared/icons/_emoji_smile.svg new file mode 100644 index 00000000000..ce645fee46f --- /dev/null +++ b/app/views/shared/icons/_emoji_smile.svg @@ -0,0 +1 @@ +<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg> diff --git a/app/views/shared/icons/_emoji_smiley.svg b/app/views/shared/icons/_emoji_smiley.svg new file mode 100644 index 00000000000..ddfae50e566 --- /dev/null +++ b/app/views/shared/icons/_emoji_smiley.svg @@ -0,0 +1 @@ +<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568h.001zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6c.92.397 1.91.6 2.912.598a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39c.397-.92.6-1.91.598-2.912zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="nonzero"/></svg> diff --git a/app/views/shared/icons/_files.svg b/app/views/shared/icons/_files.svg deleted file mode 100644 index fc378d81e40..00000000000 --- a/app/views/shared/icons/_files.svg +++ /dev/null @@ -1,17 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch --> - <title>Pasted Image 237</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <g id="Pasted-Image-237"> - <path d="M15.1111,16 C15.6021,16 16.0001,15.602 16.0001,15.111 L16.0001,4.444 C15.5341,3.983 12.0671,0.378 11.5551,0 L0.8891,0 C0.3981,0 0.0001,0.398 0.0001,0.889 L0.0001,15.111 C0.0001,15.602 0.3981,16 0.8891,16 L15.1111,16 M14.0001,14.111 L1.8891,14.111 L1.8891,2 L10.8131,2 C11.4451,2.42 13.5811,4.555 14.0001,5.187 L14.0001,14.111" id="Fill-1" fill="#7E7D7D"></path> - <path d="M0.889,0 C0.398,0 0,0.398 0,0.889 L0,15.111 C0,15.602 0.398,16 0.889,16 L15.111,16 C15.602,16 16,15.602 16,15.111 L16,4.445 C15.534,3.983 12.068,0.377 11.555,0 L0.889,0 L0.889,0 Z M1.889,2 L10.813,2 C11.446,2.42 13.581,4.554 14,5.187 L14,14.111 L1.889,14.111 L1.889,2 L1.889,2 Z" id="Clip-4"></path> - <polygon id="Fill-6" fill="#7E7D7D" points="9 7 11 7 11 2 9 2"></polygon> - <polygon id="Clip-9" points="9 7 11 7 11 2.001 9 2.001"></polygon> - <polygon id="Fill-11" fill="#7E7D7D" points="10 7 15.444 7 15.444 5 10 5"></polygon> - <polygon id="Clip-14" points="10 7 15.444 7 15.444 5 10 5"></polygon> - </g> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_icon_arrow_circle_o_right.svg b/app/views/shared/icons/_icon_arrow_circle_o_right.svg new file mode 100644 index 00000000000..5e45c6c15ce --- /dev/null +++ b/app/views/shared/icons/_icon_arrow_circle_o_right.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14"><g fill-rule="evenodd"><path fill-rule="nonzero" d="m0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7m1 0c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6"/><path d="m7 6h-2.702c-.154 0-.298.132-.298.295v1.41c0 .164.133.295.298.295h2.702v1.694c0 .18.095.209.213.09l2.539-2.568c.115-.116.118-.312 0-.432l-2.539-2.568c-.115-.116-.213-.079-.213.09v1.694"/></g></svg> diff --git a/app/views/shared/icons/_icon_check_square_o.svg b/app/views/shared/icons/_icon_check_square_o.svg new file mode 100644 index 00000000000..3dfbfc8c0e9 --- /dev/null +++ b/app/views/shared/icons/_icon_check_square_o.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1472 930v318q0 119-84.5 203.5t-203.5 84.5h-832q-119 0-203.5-84.5t-84.5-203.5v-832q0-119 84.5-203.5t203.5-84.5h832q63 0 117 25 15 7 18 23 3 17-9 29l-49 49q-10 10-23 10-3 0-9-2-23-6-45-6h-832q-66 0-113 47t-47 113v832q0 66 47 113t113 47h832q66 0 113-47t47-113v-254q0-13 9-22l64-64q10-10 23-10 6 0 12 3 20 8 20 29zm231-489l-814 814q-24 24-57 24t-57-24l-430-430q-24-24-24-57t24-57l110-110q24-24 57-24t57 24l263 263 647-647q24-24 57-24t57 24l110 110q24 24 24 57t-24 57z"/></svg> diff --git a/app/views/shared/icons/_icon_clock_o.svg b/app/views/shared/icons/_icon_clock_o.svg new file mode 100644 index 00000000000..8ddce62614c --- /dev/null +++ b/app/views/shared/icons/_icon_clock_o.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1024 544v448q0 14-9 23t-23 9h-320q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224v-352q0-14 9-23t23-9h64q14 0 23 9t9 23zm416 352q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/></svg> diff --git a/app/views/shared/icons/_icon_close.svg b/app/views/shared/icons/_icon_close.svg index 9d62012518b..59a6cb32d18 100644 --- a/app/views/shared/icons/_icon_close.svg +++ b/app/views/shared/icons/_icon_close.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><path d="M9,7.5l5.83-5.91a.48.48,0,0,0,0-.69L14.11.15a.46.46,0,0,0-.68,0l-5.93,6L1.57.15a.46.46,0,0,0-.68,0L.15.9a.48.48,0,0,0,0,.69L6,7.5.15,13.41a.48.48,0,0,0,0,.69l.74.75a.46.46,0,0,0,.68,0l5.93-6,5.93,6a.46.46,0,0,0,.68,0l.74-.75a.48.48,0,0,0,0-.69Z"/></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><path d="M9,7.5l5.83-5.91a.48.48,0,0,0,0-.69L14.11.15a.46.46,0,0,0-.68,0l-5.93,6L1.57.15a.46.46,0,0,0-.68,0L.15.9a.48.48,0,0,0,0,.69L6,7.5.15,13.41a.48.48,0,0,0,0,.69l.74.75a.46.46,0,0,0,.68,0l5.93-6,5.93,6a.46.46,0,0,0,.68,0l.74-.75a.48.48,0,0,0,0-.69Z"/></svg> diff --git a/app/views/shared/icons/_icon_code_fork.svg b/app/views/shared/icons/_icon_code_fork.svg new file mode 100644 index 00000000000..5a0df2eee19 --- /dev/null +++ b/app/views/shared/icons/_icon_code_fork.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M672 1472q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm0-1152q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm640 128q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm96 0q0 52-26 96.5t-70 69.5q-2 287-226 414-68 38-203 81-128 40-169.5 71t-41.5 100v26q44 25 70 69.5t26 96.5q0 80-56 136t-136 56-136-56-56-136q0-52 26-96.5t70-69.5v-820q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136q0 52-26 96.5t-70 69.5v497q54-26 154-57 55-17 87.5-29.5t70.5-31 59-39.5 40.5-51 28-69.5 8.5-91.5q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136z"/></svg> diff --git a/app/views/shared/icons/_icon_comment_o.svg b/app/views/shared/icons/_icon_comment_o.svg new file mode 100644 index 00000000000..b99bd5f42c8 --- /dev/null +++ b/app/views/shared/icons/_icon_comment_o.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M896 384q-204 0-381.5 69.5t-282 187.5-104.5 255q0 112 71.5 213.5t201.5 175.5l87 50-27 96q-24 91-70 172 152-63 275-171l43-38 57 6q69 8 130 8 204 0 381.5-69.5t282-187.5 104.5-255-104.5-255-282-187.5-381.5-69.5zm896 512q0 174-120 321.5t-326 233-450 85.5q-70 0-145-8-198 175-460 242-49 14-114 22h-5q-15 0-27-10.5t-16-27.5v-1q-3-4-.5-12t2-10 4.5-9.5l6-9 7-8.5 8-9q7-8 31-34.5t34.5-38 31-39.5 32.5-51 27-59 26-76q-157-89-247.5-220t-90.5-281q0-174 120-321.5t326-233 450-85.5 450 85.5 326 233 120 321.5z"/></svg> diff --git a/app/views/shared/icons/_icon_commit.svg b/app/views/shared/icons/_icon_commit.svg index 0e96035b7b7..7e9c0ded04e 100644 --- a/app/views/shared/icons/_icon_commit.svg +++ b/app/views/shared/icons/_icon_commit.svg @@ -1,3 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"> - <path fill="#8F8F8F" fill-rule="evenodd" d="M28.7769836,18 C27.8675252,13.9920226 24.2831748,11 20,11 C15.7168252,11 12.1324748,13.9920226 11.2230164,18 L4.0085302,18 C2.90195036,18 2,18.8954305 2,20 C2,21.1122704 2.8992496,22 4.0085302,22 L11.2230164,22 C12.1324748,26.0079774 15.7168252,29 20,29 C24.2831748,29 27.8675252,26.0079774 28.7769836,22 L35.9914698,22 C37.0980496,22 38,21.1045695 38,20 C38,18.8877296 37.1007504,18 35.9914698,18 L28.7769836,18 L28.7769836,18 Z M20,25 C22.7614237,25 25,22.7614237 25,20 C25,17.2385763 22.7614237,15 20,15 C17.2385763,15 15,17.2385763 15,20 C15,22.7614237 17.2385763,25 20,25 L20,25 Z"/> -</svg> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 18" enable-background="new 0 0 36 18"><path d="m34 7h-7.2c-.9-4-4.5-7-8.8-7s-7.9 3-8.8 7h-7.2c-1.1 0-2 .9-2 2 0 1.1.9 2 2 2h7.2c.9 4 4.5 7 8.8 7s7.9-3 8.8-7h7.2c1.1 0 2-.9 2-2 0-1.1-.9-2-2-2m-16 7c-2.8 0-5-2.2-5-5s2.2-5 5-5 5 2.2 5 5-2.2 5-5 5"/></svg> diff --git a/app/views/shared/icons/_icon_edit.svg b/app/views/shared/icons/_icon_edit.svg new file mode 100644 index 00000000000..cd4e34147e1 --- /dev/null +++ b/app/views/shared/icons/_icon_edit.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M888 1184l116-116-152-152-116 116v56h96v96h56zm440-720q-16-16-33 1l-350 350q-17 17-1 33t33-1l350-350q17-17 1-33zm80 594v190q0 119-84.5 203.5t-203.5 84.5h-832q-119 0-203.5-84.5t-84.5-203.5v-832q0-119 84.5-203.5t203.5-84.5h832q63 0 117 25 15 7 18 23 3 17-9 29l-49 49q-14 14-32 8-23-6-45-6h-832q-66 0-113 47t-47 113v832q0 66 47 113t113 47h832q66 0 113-47t47-113v-126q0-13 9-22l64-64q15-15 35-7t20 29zm-96-738l288 288-672 672h-288v-288zm444 132l-92 92-288-288 92-92q28-28 68-28t68 28l152 152q28 28 28 68t-28 68z"/></svg> diff --git a/app/views/shared/icons/_icon_empty_groups.svg b/app/views/shared/icons/_icon_empty_groups.svg index 9228be05f03..cf378145e59 100644 --- a/app/views/shared/icons/_icon_empty_groups.svg +++ b/app/views/shared/icons/_icon_empty_groups.svg @@ -1 +1 @@ -<svg width="249" height="368" viewBox="891 156 249 368" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="131" height="162" rx="10"/><mask id="e" x="0" y="0" width="131" height="162" fill="#fff"><use xlink:href="#a"/></mask><path d="M223.616 127.958V108.96c0-4.416-3.584-8-8.005-8h-23.985c-2.778 0-5.98 2.014-7.18 4.5l-5.07 10.5h-49.763c-5.527 0-9.996 4.475-9.996 9.997v53.005c0 5.513 4.475 9.997 9.996 9.997h84.01c5.525 0 9.994-4.477 9.994-9.998v-51.004z" id="b"/><mask id="f" x="0" y="0" width="104" height="88" fill="#fff"><use xlink:href="#b"/></mask><path d="M47 25h.996C53.52 25 58 29.472 58 34.99v20.02C58 60.526 53.52 65 47.996 65H10.004C4.48 65 0 60.528 0 55.01V34.99C0 29.474 4.48 25 10.004 25H11v-7c0-9.94 8.06-18 18-18s18 8.06 18 18v7zm-6 0H17v-7c0-6.627 5.373-12 12-12s12 5.373 12 12v7z" id="c"/><mask id="g" x="0" y="0" width="58" height="65" fill="#fff"><use xlink:href="#c"/></mask><path d="M0 10.008C0 4.48 4.476 0 10 0h218c5.523 0 10 4.473 10 10.008v140.94c0 5.53-4.062 11.882-9.08 14.196l-100.84 46.5c-5.015 2.31-13.142 2.312-18.16 0l-100.84-46.5C4.064 162.832 0 156.484 0 150.95V10.007z" id="d"/><mask id="h" x="0" y="0" width="238" height="213.417" fill="#fff"><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(891 156)"><g transform="rotate(8 -266.528 490.3)"><use stroke="#E5E5E5" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#a"/><rect fill="#FC8A51" x="20" y="31" width="12" height="4" rx="2"/><rect fill="#FC8A51" x="60" y="31" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="36" y="31" width="20" height="4" rx="2"/><rect fill="#6B4FBB" x="20" y="65" width="20" height="4" rx="2"/><rect fill="#FDE5D8" x="44" y="65" width="20" height="4" rx="2"/><rect fill="#FC8A51" x="36" y="80" width="20" height="4" rx="2"/><rect fill="#FDE5D8" x="20" y="80" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="20" y="48" width="12" height="4" rx="2"/><rect fill="#FC8A51" x="36" y="48" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="60" y="80" width="12" height="4" rx="2"/><rect fill="#6B4FBB" x="52" y="48" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="68" y="48" width="12" height="4" rx="2"/></g><use stroke="#B5A7DD" mask="url(#f)" stroke-width="8" fill="#FFF" transform="rotate(5 171.616 144.96)" xlink:href="#b"/><path d="M58 132c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#C1E7D0"/><path d="M90.143 132c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M74.686 133.875l-3.18-3.18c-.29-.29-.77-.296-1.06-.005l-1.55 1.55c-.287.287-.29.766.004 1.06l4.92 4.92c.504.504 1.32.504 1.823 0l.654-.653 7.804-7.804c.3-.3.29-.77-.005-1.067l-1.578-1.58c-.302-.3-.775-.298-1.068-.004l-6.764 6.763z" fill="#31AF64"/><path d="M4 66c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18S4 75.94 4 66z" fill="#D5ECF7"/><path d="M36.143 66c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M22 55.714c5.68 0 10.286 4.605 10.286 10.286 0 5.68-4.605 10.286-10.286 10.286-3.45 0-6.505-1.7-8.37-4.307L22 66V55.714z" fill="#2D9FD8"/><g transform="rotate(-8 748.533 18.147)"><use stroke="#FDE5D8" mask="url(#g)" stroke-width="8" fill="#FFF" xlink:href="#c"/><path d="M31 46.584c1.766-.772 3-2.534 3-4.584 0-2.76-2.24-5-5-5s-5 2.24-5 5c0 2.05 1.234 3.812 3 4.584v3.42c0 1.1.895 1.996 2 1.996 1.112 0 2-.894 2-1.997v-3.42z" fill="#FC8A51"/></g><g transform="translate(0 154)"><use stroke="#E5E5E5" mask="url(#h)" stroke-width="8" fill="#FFF" xlink:href="#d"/><g opacity=".3"><path d="M141.837 104.53l-2.56-7.993-5.074-15.843c-.26-.815-1.398-.815-1.66 0l-5.074 15.843h-16.85l-5.075-15.843c-.26-.815-1.398-.815-1.66 0l-5.073 15.843-2.56 7.993c-.234.73.022 1.528.633 1.98l22.16 16.33 22.16-16.33c.61-.452.866-1.25.632-1.98" fill="#A1A1A1"/><path fill="#5C5C5C" d="M119.044 122.84l8.425-26.303h-16.85l8.424 26.304"/><path fill="#787878" d="M119.044 122.84l-8.425-26.303H98.81l20.232 26.304"/><path fill="#787878" d="M119.044 122.84l8.425-26.303h11.807l-20.233 26.304"/><path d="M98.812 96.537l-2.56 7.993c-.234.73.022 1.528.633 1.98l22.16 16.33L98.81 96.538z" fill="#A1A1A1"/><path d="M98.812 96.537h11.807l-5.075-15.843c-.26-.815-1.398-.815-1.66 0l-5.073 15.843z" fill="#5C5C5C"/><path d="M139.277 96.537l2.56 7.993c.234.73-.022 1.528-.634 1.98l-22.16 16.33 20.234-26.303z" fill="#A1A1A1"/><path d="M139.277 96.537H127.47l5.074-15.843c.26-.815 1.398-.815 1.66 0l5.073 15.843z" fill="#5C5C5C"/></g><path d="M57 18.29c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83H41c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83H77c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm17 24.693c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V28.35c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.633zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V28.35c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.633zm202 32.923c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V61.274c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V61.274c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm202 32.923c0 1.01.895 1.828 2 1.828s2-.82 2-1.83V94.2c0-1.012-.895-1.83-2-1.83s-2 .818-2 1.83v14.63zm-202 0c0 1.01.895 1.828 2 1.828s2-.82 2-1.83V94.2c0-1.012-.895-1.83-2-1.83s-2 .818-2 1.83v14.63zm202 32.922c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V127.12c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V127.12c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm179.023 19.555c-.988.452-1.388 1.55-.894 2.454.493.904 1.694 1.27 2.682.82l14.31-6.545c.99-.452 1.39-1.55.896-2.454-.494-.902-1.696-1.27-2.684-.817l-14.31 6.544zm-32.2 14.723c-.987.452-1.388 1.55-.894 2.454.493.904 1.695 1.27 2.683.818l14.31-6.544c.99-.45 1.39-1.55.895-2.454-.494-.903-1.695-1.27-2.683-.818l-14.31 6.544zm-32.2 14.724c-.987.45-1.387 1.55-.893 2.454.494.903 1.695 1.27 2.683.818l14.31-6.544c.99-.452 1.39-1.55.896-2.454-.495-.904-1.697-1.27-2.685-.818l-14.31 6.544zm-23.67-2.023l-12.186-5.57c-.987-.452-2.19-.086-2.683.817-.494.904-.093 2.003.895 2.454l12.185 5.573c.754.345 1.57.645 2.438.898 1.052.307 2.177-.224 2.513-1.187.335-.962-.246-1.99-1.298-2.298-.677-.197-1.302-.426-1.864-.684zM62.57 168.437c-.988-.452-2.19-.086-2.683.818-.494.903-.094 2.002.894 2.454l14.31 6.544c.988.45 2.19.085 2.683-.818.494-.904.094-2.003-.894-2.454l-14.312-6.544zm-32.2-14.723c-.988-.452-2.19-.086-2.683.818-.494.904-.093 2.003.895 2.454l14.31 6.544c.988.452 2.19.086 2.684-.818.494-.903.093-2.002-.895-2.454l-14.312-6.543z" fill="#EEE"/></g><g><path d="M104 18c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#FADFD9"/><path d="M136.143 18c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M119.43 8.994c0-.707.57-1.28 1.283-1.28h2.574c.71 0 1.284.57 1.284 1.28v10.298c0 .706-.57 1.28-1.283 1.28h-2.574c-.71 0-1.284-.57-1.284-1.28V8.994zm0 15.433c0-.71.57-1.284 1.283-1.284h2.574c.71 0 1.284.57 1.284 1.284V27c0 .71-.57 1.286-1.283 1.286h-2.574c-.71 0-1.284-.57-1.284-1.285v-2.573z" fill="#E75E40"/></g><g><path d="M213 89c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#F6D4DC"/><path d="M245.143 89c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M231 86.348l-3.603-3.602c-.288-.29-.766-.286-1.063.01l-1.578 1.578c-.3.302-.3.773-.01 1.063L228.348 89l-3.602 3.603c-.29.288-.286.766.01 1.063l1.578 1.578c.302.3.773.3 1.063.01L231 91.652l3.603 3.602c.288.29.766.286 1.063-.01l1.578-1.578c.3-.302.3-.773.01-1.063L233.652 89l3.602-3.603c.29-.288.286-.766-.01-1.063l-1.578-1.578c-.302-.3-.773-.3-1.063-.01L231 86.348z" fill="#D22852"/></g></g></svg>
\ No newline at end of file +<svg width="249" height="368" viewBox="891 156 249 368" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="131" height="162" rx="10"/><mask id="e" x="0" y="0" width="131" height="162" fill="#fff"><use xlink:href="#a"/></mask><path d="M223.616 127.958V108.96c0-4.416-3.584-8-8.005-8h-23.985c-2.778 0-5.98 2.014-7.18 4.5l-5.07 10.5h-49.763c-5.527 0-9.996 4.475-9.996 9.997v53.005c0 5.513 4.475 9.997 9.996 9.997h84.01c5.525 0 9.994-4.477 9.994-9.998v-51.004z" id="b"/><mask id="f" x="0" y="0" width="104" height="88" fill="#fff"><use xlink:href="#b"/></mask><path d="M47 25h.996C53.52 25 58 29.472 58 34.99v20.02C58 60.526 53.52 65 47.996 65H10.004C4.48 65 0 60.528 0 55.01V34.99C0 29.474 4.48 25 10.004 25H11v-7c0-9.94 8.06-18 18-18s18 8.06 18 18v7zm-6 0H17v-7c0-6.627 5.373-12 12-12s12 5.373 12 12v7z" id="c"/><mask id="g" x="0" y="0" width="58" height="65" fill="#fff"><use xlink:href="#c"/></mask><path d="M0 10.008C0 4.48 4.476 0 10 0h218c5.523 0 10 4.473 10 10.008v140.94c0 5.53-4.062 11.882-9.08 14.196l-100.84 46.5c-5.015 2.31-13.142 2.312-18.16 0l-100.84-46.5C4.064 162.832 0 156.484 0 150.95V10.007z" id="d"/><mask id="h" x="0" y="0" width="238" height="213.417" fill="#fff"><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(891 156)"><g transform="rotate(8 -266.528 490.3)"><use stroke="#E5E5E5" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#a"/><rect fill="#FC8A51" x="20" y="31" width="12" height="4" rx="2"/><rect fill="#FC8A51" x="60" y="31" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="36" y="31" width="20" height="4" rx="2"/><rect fill="#6B4FBB" x="20" y="65" width="20" height="4" rx="2"/><rect fill="#FDE5D8" x="44" y="65" width="20" height="4" rx="2"/><rect fill="#FC8A51" x="36" y="80" width="20" height="4" rx="2"/><rect fill="#FDE5D8" x="20" y="80" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="20" y="48" width="12" height="4" rx="2"/><rect fill="#FC8A51" x="36" y="48" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="60" y="80" width="12" height="4" rx="2"/><rect fill="#6B4FBB" x="52" y="48" width="12" height="4" rx="2"/><rect fill="#FDE5D8" x="68" y="48" width="12" height="4" rx="2"/></g><use stroke="#B5A7DD" mask="url(#f)" stroke-width="8" fill="#FFF" transform="rotate(5 171.616 144.96)" xlink:href="#b"/><path d="M58 132c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#C1E7D0"/><path d="M90.143 132c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M74.686 133.875l-3.18-3.18c-.29-.29-.77-.296-1.06-.005l-1.55 1.55c-.287.287-.29.766.004 1.06l4.92 4.92c.504.504 1.32.504 1.823 0l.654-.653 7.804-7.804c.3-.3.29-.77-.005-1.067l-1.578-1.58c-.302-.3-.775-.298-1.068-.004l-6.764 6.763z" fill="#31AF64"/><path d="M4 66c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18S4 75.94 4 66z" fill="#D5ECF7"/><path d="M36.143 66c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M22 55.714c5.68 0 10.286 4.605 10.286 10.286 0 5.68-4.605 10.286-10.286 10.286-3.45 0-6.505-1.7-8.37-4.307L22 66V55.714z" fill="#2D9FD8"/><g transform="rotate(-8 748.533 18.147)"><use stroke="#FDE5D8" mask="url(#g)" stroke-width="8" fill="#FFF" xlink:href="#c"/><path d="M31 46.584c1.766-.772 3-2.534 3-4.584 0-2.76-2.24-5-5-5s-5 2.24-5 5c0 2.05 1.234 3.812 3 4.584v3.42c0 1.1.895 1.996 2 1.996 1.112 0 2-.894 2-1.997v-3.42z" fill="#FC8A51"/></g><g transform="translate(0 154)"><use stroke="#E5E5E5" mask="url(#h)" stroke-width="8" fill="#FFF" xlink:href="#d"/><g opacity=".3"><path d="M141.837 104.53l-2.56-7.993-5.074-15.843c-.26-.815-1.398-.815-1.66 0l-5.074 15.843h-16.85l-5.075-15.843c-.26-.815-1.398-.815-1.66 0l-5.073 15.843-2.56 7.993c-.234.73.022 1.528.633 1.98l22.16 16.33 22.16-16.33c.61-.452.866-1.25.632-1.98" fill="#A1A1A1"/><path fill="#5C5C5C" d="M119.044 122.84l8.425-26.303h-16.85l8.424 26.304"/><path fill="#787878" d="M119.044 122.84l-8.425-26.303H98.81l20.232 26.304"/><path fill="#787878" d="M119.044 122.84l8.425-26.303h11.807l-20.233 26.304"/><path d="M98.812 96.537l-2.56 7.993c-.234.73.022 1.528.633 1.98l22.16 16.33L98.81 96.538z" fill="#A1A1A1"/><path d="M98.812 96.537h11.807l-5.075-15.843c-.26-.815-1.398-.815-1.66 0l-5.073 15.843z" fill="#5C5C5C"/><path d="M139.277 96.537l2.56 7.993c.234.73-.022 1.528-.634 1.98l-22.16 16.33 20.234-26.303z" fill="#A1A1A1"/><path d="M139.277 96.537H127.47l5.074-15.843c.26-.815 1.398-.815 1.66 0l5.073 15.843z" fill="#5C5C5C"/></g><path d="M57 18.29c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83H41c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83H77c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm36 0c1.105 0 2-.818 2-1.828 0-1.01-.895-1.83-2-1.83h-16c-1.105 0-2 .82-2 1.83 0 1.01.895 1.83 2 1.83h16zm17 24.693c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V28.35c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.633zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V28.35c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.633zm202 32.923c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V61.274c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V61.274c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm202 32.923c0 1.01.895 1.828 2 1.828s2-.82 2-1.83V94.2c0-1.012-.895-1.83-2-1.83s-2 .818-2 1.83v14.63zm-202 0c0 1.01.895 1.828 2 1.828s2-.82 2-1.83V94.2c0-1.012-.895-1.83-2-1.83s-2 .818-2 1.83v14.63zm202 32.922c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V127.12c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm-202 0c0 1.01.895 1.83 2 1.83s2-.82 2-1.83V127.12c0-1.01-.895-1.83-2-1.83s-2 .82-2 1.83v14.632zm179.023 19.555c-.988.452-1.388 1.55-.894 2.454.493.904 1.694 1.27 2.682.82l14.31-6.545c.99-.452 1.39-1.55.896-2.454-.494-.902-1.696-1.27-2.684-.817l-14.31 6.544zm-32.2 14.723c-.987.452-1.388 1.55-.894 2.454.493.904 1.695 1.27 2.683.818l14.31-6.544c.99-.45 1.39-1.55.895-2.454-.494-.903-1.695-1.27-2.683-.818l-14.31 6.544zm-32.2 14.724c-.987.45-1.387 1.55-.893 2.454.494.903 1.695 1.27 2.683.818l14.31-6.544c.99-.452 1.39-1.55.896-2.454-.495-.904-1.697-1.27-2.685-.818l-14.31 6.544zm-23.67-2.023l-12.186-5.57c-.987-.452-2.19-.086-2.683.817-.494.904-.093 2.003.895 2.454l12.185 5.573c.754.345 1.57.645 2.438.898 1.052.307 2.177-.224 2.513-1.187.335-.962-.246-1.99-1.298-2.298-.677-.197-1.302-.426-1.864-.684zM62.57 168.437c-.988-.452-2.19-.086-2.683.818-.494.903-.094 2.002.894 2.454l14.31 6.544c.988.45 2.19.085 2.683-.818.494-.904.094-2.003-.894-2.454l-14.312-6.544zm-32.2-14.723c-.988-.452-2.19-.086-2.683.818-.494.904-.093 2.003.895 2.454l14.31 6.544c.988.452 2.19.086 2.684-.818.494-.903.093-2.002-.895-2.454l-14.312-6.543z" fill="#EEE"/></g><g><path d="M104 18c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#FADFD9"/><path d="M136.143 18c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M119.43 8.994c0-.707.57-1.28 1.283-1.28h2.574c.71 0 1.284.57 1.284 1.28v10.298c0 .706-.57 1.28-1.283 1.28h-2.574c-.71 0-1.284-.57-1.284-1.28V8.994zm0 15.433c0-.71.57-1.284 1.283-1.284h2.574c.71 0 1.284.57 1.284 1.284V27c0 .71-.57 1.286-1.283 1.286h-2.574c-.71 0-1.284-.57-1.284-1.285v-2.573z" fill="#E75E40"/></g><g><path d="M213 89c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18z" fill="#F6D4DC"/><path d="M245.143 89c0-7.81-6.332-14.143-14.143-14.143-7.81 0-14.143 6.332-14.143 14.143 0 7.81 6.332 14.143 14.143 14.143 7.81 0 14.143-6.332 14.143-14.143z" fill="#FFF"/><path d="M231 86.348l-3.603-3.602c-.288-.29-.766-.286-1.063.01l-1.578 1.578c-.3.302-.3.773-.01 1.063L228.348 89l-3.602 3.603c-.29.288-.286.766.01 1.063l1.578 1.578c.302.3.773.3 1.063.01L231 91.652l3.603 3.602c.288.29.766.286 1.063-.01l1.578-1.578c.3-.302.3-.773.01-1.063L233.652 89l3.602-3.603c.29-.288.286-.766-.01-1.063l-1.578-1.578c-.302-.3-.773-.3-1.063-.01L231 86.348z" fill="#D22852"/></g></g></svg> diff --git a/app/views/shared/icons/_icon_explore_groups_splash.svg b/app/views/shared/icons/_icon_explore_groups_splash.svg new file mode 100644 index 00000000000..79f17872739 --- /dev/null +++ b/app/views/shared/icons/_icon_explore_groups_splash.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="62" height="50" viewBox="260 141 62 50" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M24.6 7.7H56c3.3 0 6 2.7 6 6V44c0 3.3-2.7 6-6 6H6c-3.3 0-6-2.7-6-6V4.8C0 2 2.2 0 4.8 0h12c1.5 0 3 1 4 2l3.8 5.7z"/><mask id="e" width="62" height="50" x="0" y="0" fill="#fff"><use xlink:href="#a"/></mask><path id="b" d="M4.2 13c3.7 0 4-1.7 4-4.5S7 4.8 4.2 4.8 0 5.8 0 8.5C0 11.3.5 13 4.2 13z"/><mask id="f" width="10.7" height="10.7" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 3.6H9.5v10.7H-1.2z"/><use xlink:href="#b"/></mask><path id="c" d="M4.2 13c3.7 0 4-1.7 4-4.5S7 4.8 4.2 4.8 0 5.8 0 8.5C0 11.3.5 13 4.2 13z"/><mask id="g" width="10.7" height="10.7" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 3.6H9.5v10.7H-1.2z"/><use xlink:href="#c"/></mask><path id="d" d="M5.4 16c4.7 0 5.3-2.3 5.3-6 0-3.5-1.7-4.6-5.3-4.6C1.7 5.4 0 6.4 0 10s.6 6 5.4 6z"/><mask id="h" width="13.1" height="13.1" x="-1.2" y="-1.2"><path fill="#fff" d="M-1.2 4.2h13v13H-1z"/><use xlink:href="#d"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(260 141)"><use fill="#FFF" stroke="#EEE" stroke-width="4.8" mask="url(#e)" xlink:href="#a"/><g transform="translate(33.98 22.62)"><use fill="#B5A7DD" xlink:href="#b"/><use stroke="#FFF" stroke-width="2.4" mask="url(#f)" xlink:href="#b"/><ellipse cx="4.2" cy="3" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3" ry="3"/></g><g transform="translate(19.673 22.62)"><use fill="#B5A7DD" xlink:href="#c"/><use stroke="#FFF" stroke-width="2.4" mask="url(#g)" xlink:href="#c"/><ellipse cx="4.2" cy="3" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3" ry="3"/></g><g transform="translate(25.635 21.43)"><use fill="#B5A7DD" xlink:href="#d"/><use stroke="#FFF" stroke-width="2.4" mask="url(#h)" xlink:href="#d"/><ellipse cx="5.4" cy="3.6" fill="#B5A7DD" stroke="#FFF" stroke-width="1.2" rx="3.6" ry="3.6"/></g></g></svg> diff --git a/app/views/shared/icons/_icon_eye.svg b/app/views/shared/icons/_icon_eye.svg new file mode 100644 index 00000000000..2e2ae67142f --- /dev/null +++ b/app/views/shared/icons/_icon_eye.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1664 960q-152-236-381-353 61 104 61 225 0 185-131.5 316.5t-316.5 131.5-316.5-131.5-131.5-316.5q0-121 61-225-229 117-381 353 133 205 333.5 326.5t434.5 121.5 434.5-121.5 333.5-326.5zm-720-384q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm848 384q0 34-20 69-140 230-376.5 368.5t-499.5 138.5-499.5-139-376.5-368q-20-35-20-69t20-69q140-229 376.5-368t499.5-139 499.5 139 376.5 368q20 35 20 69z"/></svg> diff --git a/app/views/shared/icons/_icon_eye_slash.svg b/app/views/shared/icons/_icon_eye_slash.svg new file mode 100644 index 00000000000..a16c5dcb24b --- /dev/null +++ b/app/views/shared/icons/_icon_eye_slash.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M555 1335l78-141q-87-63-136-159t-49-203q0-121 61-225-229 117-381 353 167 258 427 375zm389-759q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm363-191q0 7-1 9-105 188-315 566t-316 567l-49 89q-10 16-28 16-12 0-134-70-16-10-16-28 0-12 44-87-143-65-263.5-173t-208.5-245q-20-31-20-69t20-69q153-235 380-371t496-136q89 0 180 17l54-97q10-16 28-16 5 0 18 6t31 15.5 33 18.5 31.5 18.5 19.5 11.5q16 10 16 27zm37 447q0 139-79 253.5t-209 164.5l280-502q8 45 8 84zm448 128q0 35-20 69-39 64-109 145-150 172-347.5 267t-419.5 95l74-132q212-18 392.5-137t301.5-307q-115-179-282-294l63-112q95 64 182.5 153t144.5 184q20 34 20 69z"/></svg> diff --git a/app/views/shared/icons/_icon_history.svg b/app/views/shared/icons/_icon_history.svg new file mode 100644 index 00000000000..41096da19c5 --- /dev/null +++ b/app/views/shared/icons/_icon_history.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1792" height="1792" viewBox="0 0 1792 1792"><path d="M1664 896q0 156-61 298t-164 245-245 164-298 61q-172 0-327-72.5T305 1387q-7-10-6.5-22.5t8.5-20.5l137-138q10-9 25-9 16 2 23 12 73 95 179 147t225 52q104 0 198.5-40.5T1258 1258t109.5-163.5T1408 896t-40.5-198.5T1258 534t-163.5-109.5T896 384q-98 0-188 35.5T548 521l137 138q31 30 14 69-17 40-59 40H192q-26 0-45-19t-19-45V256q0-42 40-59 39-17 69 14l130 129q107-101 244.5-156.5T896 128q156 0 298 61t245 164 164 245 61 298zm-640-288v448q0 14-9 23t-23 9H672q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224V608q0-14 9-23t23-9h64q14 0 23 9t9 23z"/></svg> diff --git a/app/views/shared/icons/_icon_merge.svg b/app/views/shared/icons/_icon_merge.svg new file mode 100644 index 00000000000..451ae12afbc --- /dev/null +++ b/app/views/shared/icons/_icon_merge.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg> diff --git a/app/views/shared/icons/_icon_merged.svg b/app/views/shared/icons/_icon_merged.svg new file mode 100644 index 00000000000..43d591daefa --- /dev/null +++ b/app/views/shared/icons/_icon_merged.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m2 3c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1m.761.85c.154 2.556 1.987 4.692 4.45 5.255.328-.655 1.01-1.105 1.789-1.105 1.105 0 2 .895 2 2 0 1.105-.895 2-2 2-.89 0-1.645-.582-1.904-1.386-1.916-.376-3.548-1.5-4.596-3.044v4.493c.863.222 1.5 1.01 1.5 1.937 0 1.105-.895 2-2 2-1.105 0-2-.895-2-2 0-.74.402-1.387 1-1.732v-8.535c-.598-.346-1-.992-1-1.732 0-1.105.895-2 2-2 1.105 0 2 .895 2 2 0 .835-.512 1.551-1.239 1.85m6.239 7.15c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1m-7 4c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1" transform="translate(3)"/></svg> diff --git a/app/views/shared/icons/_icon_mr_issue.svg b/app/views/shared/icons/_icon_mr_issue.svg index ae219a3ded2..a56af9c556c 100644 --- a/app/views/shared/icons/_icon_mr_issue.svg +++ b/app/views/shared/icons/_icon_mr_issue.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill-rule="evenodd"><path d="m8.411 1.012c-.136-.008-.273-.012-.411-.012-3.866 0-7 3.134-7 7 0 3.866 3.134 7 7 7 3.866 0 7-3.134 7-7 0-.138-.004-.275-.012-.411-.464.201-.964.334-1.488.386 0 .008 0 .016 0 .025 0 3.038-2.462 5.5-5.5 5.5-3.038 0-5.5-2.462-5.5-5.5 0-3.038 2.462-5.5 5.5-5.5.008 0 .016 0 .025 0 .052-.524.185-1.024.386-1.488"/><path d="m12 2h-1.01c-.54 0-.991.448-.991 1 0 .556.444 1 .991 1h1.01v1.01c0 .54.448.991 1 .991.556 0 1-.444 1-.991v-1.01h1.01c.54 0 .991-.448.991-1 0-.556-.444-1-.991-1h-1.01v-1.01c0-.54-.448-.991-1-.991-.556 0-1 .444-1 .991v1.01m-5 4.01c0-.557.444-1.01 1-1.01.552 0 1 .443 1 1.01v1.981c0 .557-.444 1.01-1 1.01-.552 0-1-.443-1-1.01v-1.981m1 5.991c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1"/></g></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill-rule="evenodd"><path d="m8.411 1.012c-.136-.008-.273-.012-.411-.012-3.866 0-7 3.134-7 7 0 3.866 3.134 7 7 7 3.866 0 7-3.134 7-7 0-.138-.004-.275-.012-.411-.464.201-.964.334-1.488.386 0 .008 0 .016 0 .025 0 3.038-2.462 5.5-5.5 5.5-3.038 0-5.5-2.462-5.5-5.5 0-3.038 2.462-5.5 5.5-5.5.008 0 .016 0 .025 0 .052-.524.185-1.024.386-1.488"/><path d="m12 2h-1.01c-.54 0-.991.448-.991 1 0 .556.444 1 .991 1h1.01v1.01c0 .54.448.991 1 .991.556 0 1-.444 1-.991v-1.01h1.01c.54 0 .991-.448.991-1 0-.556-.444-1-.991-1h-1.01v-1.01c0-.54-.448-.991-1-.991-.556 0-1 .444-1 .991v1.01m-5 4.01c0-.557.444-1.01 1-1.01.552 0 1 .443 1 1.01v1.981c0 .557-.444 1.01-1 1.01-.552 0-1-.443-1-1.01v-1.981m1 5.991c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1"/></g></svg> diff --git a/app/views/shared/icons/_icon_pencil.svg b/app/views/shared/icons/_icon_pencil.svg new file mode 100644 index 00000000000..a3b48404f87 --- /dev/null +++ b/app/views/shared/icons/_icon_pencil.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/></svg> diff --git a/app/views/shared/icons/_icon_play.svg b/app/views/shared/icons/_icon_play.svg index e965afa9a56..4c69fc99a9e 100644 --- a/app/views/shared/icons/_icon_play.svg +++ b/app/views/shared/icons/_icon_play.svg @@ -1,3 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 11" class="icon-play"> - <path fill-rule="evenodd" d="m9.283 6.47l-7.564 4.254c-.949.534-1.719.266-1.719-.576v-9.292c0-.852.756-1.117 1.719-.576l7.564 4.254c.949.534.963 1.392 0 1.934"/> - </svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 11" class="icon-play"><path fill-rule="evenodd" d="m9.283 6.47l-7.564 4.254c-.949.534-1.719.266-1.719-.576v-9.292c0-.852.756-1.117 1.719-.576l7.564 4.254c.949.534.963 1.392 0 1.934"/></svg> diff --git a/app/views/shared/icons/_icon_random.svg b/app/views/shared/icons/_icon_random.svg new file mode 100644 index 00000000000..763bd2d3dd8 --- /dev/null +++ b/app/views/shared/icons/_icon_random.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M666 481q-60 92-137 273-22-45-37-72.5t-40.5-63.5-51-56.5-63-35-81.5-14.5h-224q-14 0-23-9t-9-23v-192q0-14 9-23t23-9h224q250 0 410 225zm1126 799q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192q-32 0-85 .5t-81 1-73-1-71-5-64-10.5-63-18.5-58-28.5-59-40-55-53.5-56-69.5q59-93 136-273 22 45 37 72.5t40.5 63.5 51 56.5 63 35 81.5 14.5h256v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23zm0-896q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192h-256q-48 0-87 15t-69 45-51 61.5-45 77.5q-32 62-78 171-29 66-49.5 111t-54 105-64 100-74 83-90 68.5-106.5 42-128 16.5h-224q-14 0-23-9t-9-23v-192q0-14 9-23t23-9h224q48 0 87-15t69-45 51-61.5 45-77.5q32-62 78-171 29-66 49.5-111t54-105 64-100 74-83 90-68.5 106.5-42 128-16.5h256v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23z"/></svg> diff --git a/app/views/shared/icons/_icon_status_closed.svg b/app/views/shared/icons/_icon_status_closed.svg new file mode 100644 index 00000000000..de448ee1194 --- /dev/null +++ b/app/views/shared/icons/_icon_status_closed.svg @@ -0,0 +1 @@ +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><rect x="3.36" y="6.16" width="7.28" height="1.68" rx=".84"/></svg> diff --git a/app/views/shared/icons/_icon_status_open.svg b/app/views/shared/icons/_icon_status_open.svg new file mode 100644 index 00000000000..ed58d23c626 --- /dev/null +++ b/app/views/shared/icons/_icon_status_open.svg @@ -0,0 +1 @@ +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><path d="M7 9.219a2.218 2.218 0 1 0 0-4.436A2.218 2.218 0 0 0 7 9.22zm0 1.12a3.338 3.338 0 1 1 0-6.676 3.338 3.338 0 0 1 0 6.676z"/></svg> diff --git a/app/views/shared/icons/_icon_stopwatch.svg b/app/views/shared/icons/_icon_stopwatch.svg index f20de04538e..6c2a8b2773f 100644 --- a/app/views/shared/icons/_icon_stopwatch.svg +++ b/app/views/shared/icons/_icon_stopwatch.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14" enable-background="new 0 0 12 14"><path d="m11.5 2.4l-1.3-1.1-1 1.1 1.4 1.1.9-1.1"/><path d="m6.8 2v-.5h.5v-1.5h-2.6v1.5h.5v.5c-2.9.4-5.2 2.9-5.2 6 0 3.3 2.7 6 6 6s6-2.7 6-6c0-3-2.3-5.6-5.2-6m-.8 10.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5"/><path d="m6.2 8.9h-.5c-.1 0-.2-.1-.2-.2v-3.5c0-.1.1-.2.2-.2h.5c.1 0 .2.1.2.2v3.5c0 .1-.1.2-.2.2"/></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14" enable-background="new 0 0 12 14"><path d="m11.5 2.4l-1.3-1.1-1 1.1 1.4 1.1.9-1.1"/><path d="m6.8 2v-.5h.5v-1.5h-2.6v1.5h.5v.5c-2.9.4-5.2 2.9-5.2 6 0 3.3 2.7 6 6 6s6-2.7 6-6c0-3-2.3-5.6-5.2-6m-.8 10.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5"/><path d="m6.2 8.9h-.5c-.1 0-.2-.1-.2-.2v-3.5c0-.1.1-.2.2-.2h.5c.1 0 .2.1.2.2v3.5c0 .1-.1.2-.2.2"/></svg> diff --git a/app/views/shared/icons/_icon_tags.svg b/app/views/shared/icons/_icon_tags.svg new file mode 100644 index 00000000000..fc5acc89c5e --- /dev/null +++ b/app/views/shared/icons/_icon_tags.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M384 448q0-53-37.5-90.5t-90.5-37.5-90.5 37.5-37.5 90.5 37.5 90.5 90.5 37.5 90.5-37.5 37.5-90.5zm1067 576q0 53-37 90l-491 492q-39 37-91 37-53 0-90-37l-715-716q-38-37-64.5-101t-26.5-117v-416q0-52 38-90t90-38h416q53 0 117 26.5t102 64.5l715 714q37 39 37 91zm384 0q0 53-37 90l-491 492q-39 37-91 37-36 0-59-14t-53-45l470-470q37-37 37-90 0-52-37-91l-715-714q-38-38-102-64.5t-117-26.5h224q53 0 117 26.5t102 64.5l715 714q37 39 37 91z"/></svg> diff --git a/app/views/shared/icons/_icon_timer.svg b/app/views/shared/icons/_icon_timer.svg index 0b1e5804427..572a31ebcca 100644 --- a/app/views/shared/icons/_icon_timer.svg +++ b/app/views/shared/icons/_icon_timer.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><g fill="#8F8F8F" fill-rule="evenodd"><path d="M29.513 10.134A15.922 15.922 0 0 0 23 7.28V6h2.993C26.55 6 27 5.552 27 5V2a1 1 0 0 0-1.007-1H14.007C13.45 1 13 1.448 13 2v3a1 1 0 0 0 1.007 1H17v1.28C9.597 8.686 4 15.19 4 23c0 8.837 7.163 16 16 16s16-7.163 16-16c0-3.461-1.099-6.665-2.967-9.283l1.327-1.58a2.498 2.498 0 0 0-.303-3.53 2.499 2.499 0 0 0-3.528.315l-1.016 1.212zM20 34c6.075 0 11-4.925 11-11s-4.925-11-11-11S9 16.925 9 23s4.925 11 11 11z"/><path d="M19 21h-4.002c-.552 0-.998.452-.998 1.01v1.98c0 .567.447 1.01.998 1.01h7.004c.274 0 .521-.111.701-.291a.979.979 0 0 0 .297-.704v-8.01c0-.54-.452-.995-1.01-.995h-1.98a.997.997 0 0 0-1.01.995V21z"/></g></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><g fill="#8F8F8F" fill-rule="evenodd"><path d="M29.513 10.134A15.922 15.922 0 0 0 23 7.28V6h2.993C26.55 6 27 5.552 27 5V2a1 1 0 0 0-1.007-1H14.007C13.45 1 13 1.448 13 2v3a1 1 0 0 0 1.007 1H17v1.28C9.597 8.686 4 15.19 4 23c0 8.837 7.163 16 16 16s16-7.163 16-16c0-3.461-1.099-6.665-2.967-9.283l1.327-1.58a2.498 2.498 0 0 0-.303-3.53 2.499 2.499 0 0 0-3.528.315l-1.016 1.212zM20 34c6.075 0 11-4.925 11-11s-4.925-11-11-11S9 16.925 9 23s4.925 11 11 11z"/><path d="M19 21h-4.002c-.552 0-.998.452-.998 1.01v1.98c0 .567.447 1.01.998 1.01h7.004c.274 0 .521-.111.701-.291a.979.979 0 0 0 .297-.704v-8.01c0-.54-.452-.995-1.01-.995h-1.98a.997.997 0 0 0-1.01.995V21z"/></g></svg> diff --git a/app/views/shared/icons/_icon_trash_o.svg b/app/views/shared/icons/_icon_trash_o.svg new file mode 100644 index 00000000000..0d7a91ab536 --- /dev/null +++ b/app/views/shared/icons/_icon_trash_o.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M704 736v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm256 0v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm256 0v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm128 724v-948h-896v948q0 22 7 40.5t14.5 27 10.5 8.5h832q3 0 10.5-8.5t14.5-27 7-40.5zm-672-1076h448l-48-117q-7-9-17-11h-317q-10 2-17 11zm928 32v64q0 14-9 23t-23 9h-96v948q0 83-47 143.5t-113 60.5h-832q-66 0-113-58.5t-47-141.5v-952h-96q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h309l70-167q15-37 54-63t79-26h320q40 0 79 26t54 63l70 167h309q14 0 23 9t9 23z"/></svg> diff --git a/app/views/shared/icons/_icon_user.svg b/app/views/shared/icons/_icon_user.svg new file mode 100644 index 00000000000..9b8cd74d62b --- /dev/null +++ b/app/views/shared/icons/_icon_user.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1600 1405q0 120-73 189.5t-194 69.5h-874q-121 0-194-69.5t-73-189.5q0-53 3.5-103.5t14-109 26.5-108.5 43-97.5 62-81 85.5-53.5 111.5-20q9 0 42 21.5t74.5 48 108 48 133.5 21.5 133.5-21.5 108-48 74.5-48 42-21.5q61 0 111.5 20t85.5 53.5 62 81 43 97.5 26.5 108.5 14 109 3.5 103.5zm-320-893q0 159-112.5 271.5t-271.5 112.5-271.5-112.5-112.5-271.5 112.5-271.5 271.5-112.5 271.5 112.5 112.5 271.5z"/></svg> diff --git a/app/views/shared/icons/_illustration_no_commits.svg b/app/views/shared/icons/_illustration_no_commits.svg index 4f9d9add60d..34f177d7efa 100644 --- a/app/views/shared/icons/_illustration_no_commits.svg +++ b/app/views/shared/icons/_illustration_no_commits.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 107" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill="#eee" fill-rule="evenodd"><path d="m4.01 2h1.102c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-1.102c-2.218 0-4.01 1.788-4.01 4 0 .552.448 1 1 1 .552 0 1-.448 1-1 0-1.108.892-2 2.01-2m12.702 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m8.088 0c.822 0 1.554.503 1.86 1.254.208.512.791.758 1.303.55.512-.208.758-.791.55-1.303-.609-1.497-2.069-2.5-3.712-2.5h-2.188c-.552 0-1 .448-1 1 0 .552.448 1 1 1h2.188m2.01 12.518c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 11.6c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 11.6c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 6.282c0 1.108-.892 2-2.01 2h-.72c-.552 0-1 .448-1 1 0 .552.448 1 1 1h.72c2.218 0 4.01-1.788 4.01-4v-.382c0-.552-.448-1-1-1-.552 0-1 .448-1 1v.382m-14.325 2c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-8.47 0c-.755 0-1.438-.424-1.782-1.085-.255-.49-.859-.681-1.349-.426-.49.255-.681.859-.426 1.349.684 1.316 2.046 2.162 3.556 2.162h2.57c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-2.57m-2.01-12.136c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-11.6c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-11.6c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-6.664c0-.552-.448-1-1-1-.552 0-1 .448-1 1v.764c0 .552.448 1 1 1 .552 0 1-.448 1-1v-.764" id="0"/><circle cx="21" cy="24" r="10"/><rect width="33" height="3" x="37" y="18" rx="1.5" id="1"/><rect width="53" height="3" x="37" y="27" rx="1.5" id="2"/><path d="m131 29c0 .552.447.999.996.999h22.01c.545 0 .996-.451.996-.999v-9c0-.552-.447-.999-.996-.999h-22.01c-.545 0-.996.451-.996.999v9m.996-12h22.01c1.655 0 2.996 1.344 2.996 2.999v9c0 1.657-1.35 2.999-2.996 2.999h-22.01c-1.655 0-2.996-1.344-2.996-2.999v-9c0-1.657 1.35-2.999 2.996-2.999" id="3"/><g transform="translate(0 59)"><use xlink:href="#0"/><circle cx="21" cy="24" r="10"/><use xlink:href="#1"/><use xlink:href="#2"/><use xlink:href="#3"/></g></g></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 107" xmlns:xlink="http://www.w3.org/1999/xlink"><g fill="#eee" fill-rule="evenodd"><path d="m4.01 2h1.102c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-1.102c-2.218 0-4.01 1.788-4.01 4 0 .552.448 1 1 1 .552 0 1-.448 1-1 0-1.108.892-2 2.01-2m12.702 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m11.6 0c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7m8.088 0c.822 0 1.554.503 1.86 1.254.208.512.791.758 1.303.55.512-.208.758-.791.55-1.303-.609-1.497-2.069-2.5-3.712-2.5h-2.188c-.552 0-1 .448-1 1 0 .552.448 1 1 1h2.188m2.01 12.518c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 11.6c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 11.6c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7m0 6.282c0 1.108-.892 2-2.01 2h-.72c-.552 0-1 .448-1 1 0 .552.448 1 1 1h.72c2.218 0 4.01-1.788 4.01-4v-.382c0-.552-.448-1-1-1-.552 0-1 .448-1 1v.382m-14.325 2c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-11.6 0c-.552 0-1 .448-1 1 0 .552.448 1 1 1h5.7c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-5.7m-8.47 0c-.755 0-1.438-.424-1.782-1.085-.255-.49-.859-.681-1.349-.426-.49.255-.681.859-.426 1.349.684 1.316 2.046 2.162 3.556 2.162h2.57c.552 0 1-.448 1-1 0-.552-.448-1-1-1h-2.57m-2.01-12.136c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-11.6c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-11.6c0-.552-.448-1-1-1-.552 0-1 .448-1 1v5.7c0 .552.448 1 1 1 .552 0 1-.448 1-1v-5.7m0-6.664c0-.552-.448-1-1-1-.552 0-1 .448-1 1v.764c0 .552.448 1 1 1 .552 0 1-.448 1-1v-.764" id="0"/><circle cx="21" cy="24" r="10"/><rect width="33" height="3" x="37" y="18" rx="1.5" id="1"/><rect width="53" height="3" x="37" y="27" rx="1.5" id="2"/><path d="m131 29c0 .552.447.999.996.999h22.01c.545 0 .996-.451.996-.999v-9c0-.552-.447-.999-.996-.999h-22.01c-.545 0-.996.451-.996.999v9m.996-12h22.01c1.655 0 2.996 1.344 2.996 2.999v9c0 1.657-1.35 2.999-2.996 2.999h-22.01c-1.655 0-2.996-1.344-2.996-2.999v-9c0-1.657 1.35-2.999 2.996-2.999" id="3"/><g transform="translate(0 59)"><use xlink:href="#0"/><circle cx="21" cy="24" r="10"/><use xlink:href="#1"/><use xlink:href="#2"/><use xlink:href="#3"/></g></g></svg> diff --git a/app/views/shared/icons/_members.svg b/app/views/shared/icons/_members.svg deleted file mode 100644 index f8043b31fe8..00000000000 --- a/app/views/shared/icons/_members.svg +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="22px" height="16px" viewBox="0 0 22 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.7.2 (28276) - http://www.bohemiancoding.com/sketch --> - <title>Group</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <g id="Group" fill="#7E7C7C"> - <path d="M6.4357,11.8588 C7.1487,11.2798 7.8797,10.7808 8.5357,10.3708 C8.5837,10.3008 8.6187,10.2338 8.6187,10.1768 L8.6187,8.8088 C8.9197,8.5218 9.0927,8.1248 9.0927,7.7028 L9.0927,5.3748 C9.0927,3.9478 7.9187,2.7858 6.4757,2.7858 L5.9687,2.7858 C4.5247,2.7858 3.3507,3.9478 3.3507,5.3748 L3.3507,7.7028 C3.3507,8.1248 3.5247,8.5218 3.8247,8.8088 L3.8247,10.5838 C3.2537,10.8738 1.8797,11.6198 0.5967,12.6618 C0.2177,12.9698 -0.0003,13.4258 -0.0003,13.9138 L-0.0003,15.5088 C-0.0003,15.5438 0.0857,15.7668 0.3467,15.7778 C1.3257,15.8198 3.8417,15.8328 5.9617,15.9038 C5.8337,15.8148 5.7447,15.6748 5.7447,15.5088 L5.7447,13.5498 C5.7447,12.9848 5.9967,12.2158 6.4357,11.8588" id="Fill-1"></path> - <path d="M21.3092,12.1 C19.6932,10.787 17.9592,9.86 17.3042,9.53 L17.3042,7.235 C17.6722,6.9 17.8862,6.428 17.8862,5.925 L17.8862,3.066 C17.8862,1.376 16.4952,0 14.7852,0 L14.1632,0 C12.4532,0 11.0622,1.376 11.0622,3.066 L11.0622,5.925 C11.0622,6.428 11.2752,6.9 11.6442,7.235 L11.6442,9.53 C10.9892,9.86 9.2542,10.787 7.6392,12.1 C7.2002,12.457 6.9482,12.985 6.9482,13.55 L6.9482,15.509 C6.9482,15.78 7.1702,16 7.4442,16 L14.1172,16 L14.1172,11.704 C12.6812,11.595 11.5652,10.853 11.5652,9.945 C11.5652,9.804 11.5982,9.669 11.6482,9.538 C11.9502,10.326 13.0982,10.913 14.4762,10.913 C15.8532,10.913 17.0012,10.326 17.3032,9.538 C17.3532,9.669 17.3862,9.804 17.3862,9.945 C17.3862,10.793 16.4152,11.5 15.1172,11.679 L15.1172,16 L21.5032,16 C21.7772,16 22.0002,15.78 22.0002,15.509 L22.0002,13.55 C22.0002,12.985 21.7482,12.457 21.3092,12.1" id="Fill-4"></path> - </g> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_milestones.svg b/app/views/shared/icons/_milestones.svg deleted file mode 100644 index 3d62ecc0631..00000000000 --- a/app/views/shared/icons/_milestones.svg +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="16px" height="17px" viewBox="0 0 16 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.7.2 (28276) - http://www.bohemiancoding.com/sketch --> - <title>Group</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <g id="Group" fill="#7E7C7C"> - <path d="M15.1111,1 L0.8891,1 C0.3981,1 0.0001,1.446 0.0001,1.996 L0.0001,15.945 C0.0001,16.495 0.3981,16.941 0.8891,16.941 L15.1111,16.941 C15.6021,16.941 16.0001,16.495 16.0001,15.945 L16.0001,1.996 C16.0001,1.446 15.6021,1 15.1111,1 L15.1111,1 L15.1111,1 Z M14.0001,6.0002 L14.0001,14.949 L2.0001,14.949 L2.0001,6.0002 L14.0001,6.0002 Z M14.0001,4.0002 L14.0001,2.993 L2.0001,2.993 L2.0001,4.0002 L14.0001,4.0002 Z" id="Combined-Shape"></path> - <polygon id="Fill-11" points="3 2.0002 5 2.0002 5 0.0002 3 0.0002"></polygon> - <polygon id="Fill-16" points="11 2.0002 13 2.0002 13 0.0002 11 0.0002"></polygon> - <path d="M5.37709616,11.5511984 L6.92309616,12.7821984 C7.35112915,13.123019 7.97359761,13.0565604 8.32002627,12.6330535 L10.7740263,9.63305349 C11.1237073,9.20557058 11.0606364,8.57555475 10.6331535,8.22587373 C10.2056706,7.87619272 9.57565475,7.93926361 9.22597373,8.36674651 L6.77197373,11.3667465 L8.16890384,11.2176016 L6.62290384,9.98660159 C6.19085236,9.6425813 5.56172188,9.71394467 5.21770159,10.1459962 C4.8736813,10.5780476 4.94504467,11.2071781 5.37709616,11.5511984 L5.37709616,11.5511984 Z" id="Stroke-21"></path> - </g> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_mr.svg b/app/views/shared/icons/_mr.svg deleted file mode 100644 index dd3dbcc4473..00000000000 --- a/app/views/shared/icons/_mr.svg +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.7.2 (28276) - http://www.bohemiancoding.com/sketch --> - <title>Group</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <g id="Group" fill="#7E7C7C"> - <path d="M15.1111,0 L0.8891,0 C0.3981,0 0.0001,0.446 0.0001,0.996 L0.0001,14.945 C0.0001,15.495 0.3981,15.941 0.8891,15.941 L15.1111,15.941 C15.6021,15.941 16.0001,15.495 16.0001,14.945 L16.0001,0.996 C16.0001,0.446 15.6021,0 15.1111,0 L15.1111,0 L15.1111,0 Z M2.0001,13.949 L14.0001,13.949 L14.0001,1.993 L2.0001,1.993 L2.0001,13.949 Z M2,5.0002 L14,5.0002 L14,3.0002 L2,3.0002 L2,5.0002 Z" id="Combined-Shape"></path> - <path d="M8.547,12.0002 L12,12.0002 L12,10.0002 L8.547,10.0002 L8.547,12.0002 Z M5.2029,12 L3.9999,10.867 L5.2029,9.501 L3.9999,8.181 L5.2029,7 L7.4529,9.499 L5.2029,12 Z" id="Combined-Shape"></path> - </g> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_mr_bold.svg b/app/views/shared/icons/_mr_bold.svg index 2daa55a8652..5468545da2e 100644 --- a/app/views/shared/icons/_mr_bold.svg +++ b/app/views/shared/icons/_mr_bold.svg @@ -1 +1,2 @@ -<svg width="15" height="20" viewBox="0 0 12 14" xmlns="http://www.w3.org/2000/svg"><path d="M1 4.967a2.15 2.15 0 1 1 2.3 0v5.066a2.15 2.15 0 1 1-2.3 0V4.967zm7.85 5.17V5.496c0-.745-.603-1.346-1.35-1.346V6l-3-3 3-3v1.85c2.016 0 3.65 1.63 3.65 3.646v4.45a2.15 2.15 0 1 1-2.3.191z" fill-rule="nonzero"/></svg> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg> + diff --git a/app/views/shared/icons/_mr_widget_empty_state.svg b/app/views/shared/icons/_mr_widget_empty_state.svg new file mode 100644 index 00000000000..6a811893b2d --- /dev/null +++ b/app/views/shared/icons/_mr_widget_empty_state.svg @@ -0,0 +1 @@ +<svg width="256" height="146" viewBox="0 0 256 146" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>illustration</title><defs><rect id="a" width="178.714" height="115.389" rx="10"/><mask id="d" x="0" y="0" width="178.714" height="115.389" fill="#fff"><use xlink:href="#a"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="b"/><mask id="e" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#b"/></mask><path d="M8.796 31.515c.395.047.8.072 1.207.072h23.065c5.536 0 10.003-4.475 10.003-9.994v-11.6C43.07 4.476 38.594 0 33.07 0H10.003C4.467 0 0 4.475 0 9.994v11.6c0 1.248.23 2.444.65 3.547H0v7.414c0 4.094 2.394 5.113 5.342 2.28l3.454-3.32z" id="c"/><mask id="f" x="0" y="0" width="43.071" height="36.437" fill="#fff"><use xlink:href="#c"/></mask></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.868)" fill="#F9F9F9"><rect x="19.286" width="77.143" height="14.182" rx="7.091"/><rect y="28.364" width="84.857" height="14.182" rx="7.091"/><rect x="133.714" y="42.546" width="122.143" height="14.182" rx="7.091"/><rect x="82.929" y="126.992" width="101.571" height="14.182" rx="7.091"/><rect x="42.429" y="99.273" width="101.571" height="14.182" rx="7.091"/><rect x="19.929" y="70.909" width="225" height="14.182" rx="7.091"/><path d="M98.37 14.182H13.488h13.81a7.098 7.098 0 0 1 7.094 7.09 7.09 7.09 0 0 1-7.094 7.092h-13.81 84.88-23.452a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.096-7.092h23.452zm162 42.545h-75.238 23.452a7.098 7.098 0 0 1 7.095 7.09 7.09 7.09 0 0 1-7.096 7.092h-23.452 75.237-23.453a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.093h23.452zM103.512 85.09H28.275h23.452a7.098 7.098 0 0 1 7.095 7.092 7.09 7.09 0 0 1-7.095 7.09H28.275h75.237H80.06a7.098 7.098 0 0 1-7.095-7.09 7.09 7.09 0 0 1 7.095-7.09h23.452zm48.215 28.365H76.49 90.3a7.098 7.098 0 0 1 7.093 7.09 7.09 7.09 0 0 1-7.094 7.092H76.49h75.237-33.096a7.098 7.098 0 0 1-7.094-7.09 7.09 7.09 0 0 1 7.095-7.092h33.097z"/></g><g transform="translate(38.57 12.248)"><use stroke="#EEE" mask="url(#d)" stroke-width="8" fill="#FFF" xlink:href="#a"/><path fill="#EEE" d="M2.57 18.694h174.215v2.58H2.57z"/><g transform="translate(21.857 38.678)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 59.95)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#FC6D26" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(21.857 81.223)"><rect fill="#B5A7DD" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#FC6D26" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#FC6D26" opacity=".5" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#B5A7DD" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#B5A7DD" y="14.826" width="3.857" height="1.289" rx=".645"/></g><g transform="translate(100.93 38.033)"><rect fill="#FDE5D8" y=".645" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="34.071" y="7.091" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="30.857" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="9.643" y="14.182" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="18.643" y="7.091" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="21.857" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="7.091" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="7.736" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="14.826" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="21.917" width="3.857" height="1.289" rx=".645"/><rect fill="#EEE" x="9.643" y="21.273" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="37.286" y="14.182" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="25.071" y="35.455" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="28.364" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="30.857" y="21.273" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="35.455" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="21.273" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="30.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="39.857" y="28.364" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="49.5" y="14.182" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="29.008" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="36.099" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="43.19" width="3.857" height="1.289" rx=".645"/><rect fill="#6B4FBB" x="9.643" y="42.546" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="25.071" y="56.727" width="9.643" height="2.579" rx="1.289"/><rect fill="#6B4FBB" opacity=".5" x="34.071" y="49.636" width="9.643" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="56.727" width="12.857" height="2.579" rx="1.289"/><rect fill="#6B4FBB" x="18.643" y="49.636" width="12.857" height="2.579" rx="1.289"/><rect fill="#EEE" x="21.857" y="42.546" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="46.286" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#EEE" x="9.643" y="49.636" width="6.429" height="2.579" rx="1.289"/><rect fill="#FDE5D8" y="50.281" width="3.857" height="1.289" rx=".645"/><rect fill="#FDE5D8" y="57.372" width="3.857" height="1.289" rx=".645"/></g></g><g transform="translate(196.07)"><use stroke="#FDE5D8" mask="url(#e)" stroke-width="8" fill="#FFF" xlink:href="#b"/><rect fill="#FDB692" x="9" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="14.826" width="25.071" height="1.934" rx=".967"/><rect fill="#FDB692" x="9" y="20.628" width="18.643" height="1.934" rx=".967"/></g><g transform="translate(189 41.256)"><ellipse stroke="#FC6D26" stroke-width="3" fill="#FFF7F4" cx="10.286" cy="9.669" rx="9.643" ry="9.669"/><path d="M.023 9.002a8.352 8.352 0 0 0 7.94-4.308M9 .644c0-.21-.008-.416-.023-.62" stroke="#FC6D26" stroke-width="2"/><path d="M5.045 2.008A10.266 10.266 0 0 0 13.5 6.446c2.112 0 4.076-.638 5.71-1.733" stroke="#FC6D26" stroke-width="2"/><ellipse fill="#FC6D26" cx="6.75" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#FC6D26" cx="13.821" cy="11.281" rx=".964" ry=".967"/></g><g transform="translate(46.93 96.05)"><ellipse stroke="#6B4FBB" stroke-width="3" fill="#F4F1FA" cx="9.643" cy="10.314" rx="9.643" ry="9.669"/><path d="M12.86 4.51h-.005L11.25 2.58 9.645 4.51H9.64L8.036 2.58 6.43 4.51h-.002L4.82 2.58 3.215 4.512h-1.75A9.646 9.646 0 0 1 9.642 0c3.447 0 6.47 1.8 8.176 4.508h-1.75l-1.605-1.93L12.86 4.51z" fill="#6B4FBB"/><ellipse fill="#6B4FBB" cx="6.107" cy="11.281" rx=".964" ry=".967"/><ellipse fill="#6B4FBB" cx="13.179" cy="11.281" rx=".964" ry=".967"/></g><g transform="matrix(-1 0 0 1 56.57 54.794)"><use stroke="#E2DCF2" mask="url(#f)" stroke-width="8" fill="#FFF" xlink:href="#c"/><rect fill="#6B4FBB" opacity=".5" x="15.429" y="9.025" width="18.643" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="14.826" width="12.214" height="1.934" rx=".967"/><rect fill="#6B4FBB" opacity=".5" x="21.857" y="20.628" width="12.214" height="1.934" rx=".967"/></g></g></svg> diff --git a/app/views/shared/icons/_pipelines.svg b/app/views/shared/icons/_pipelines.svg deleted file mode 100644 index 794e8a27025..00000000000 --- a/app/views/shared/icons/_pipelines.svg +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch --> - <title>Pasted Image 246</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <path d="M12.5,14 C11.672,14 11,13.328 11,12.5 C11,11.672 11.672,11 12.5,11 C13.328,11 14,11.672 14,12.5 C14,13.328 13.328,14 12.5,14 M12.5,9 L3.5,9 C1.567,9 0,10.567 0,12.5 C0,14.433 1.567,16 3.5,16 L12.5,16 C14.433,16 16,14.433 16,12.5 C16,10.567 14.433,9 12.5,9 M3.5,2 C4.328,2 5,2.672 5,3.5 C5,4.328 4.328,5 3.5,5 C2.672,5 2,4.328 2,3.5 C2,2.672 2.672,2 3.5,2 M3.5,7 L12.5,7 C14.433,7 16,5.433 16,3.5 C16,1.567 14.433,0 12.5,0 L3.5,0 C1.567,0 0,1.567 0,3.5 C0,5.433 1.567,7 3.5,7" id="Pasted-Image-246" fill="#303030"></path> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/icons/_wiki.svg b/app/views/shared/icons/_wiki.svg deleted file mode 100644 index 182d91e23aa..00000000000 --- a/app/views/shared/icons/_wiki.svg +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> - <!-- Generator: Sketch 3.8.3 (29802) - http://www.bohemiancoding.com/sketch --> - <title>Pasted Image 241</title> - <desc>Created with Sketch.</desc> - <defs></defs> - <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> - <path d="M2.004,12.9999459 L3.939,12.9999459 L3.939,4.99994585 L2.004,4.99994585 L2.004,12.9999459 Z M7.017,9.99994585 L13.018,9.99994585 L13.018,8.99994585 L7.017,8.99994585 L7.017,9.99994585 Z M7.017,7.99994585 L13.018,7.99994585 L13.018,6.99994585 L7.017,6.99994585 L7.017,7.99994585 Z M7.017,5.99994585 L13.018,5.99994585 L13.018,4.99994585 L7.017,4.99994585 L7.017,5.99994585 Z M14.754,-5.41499267e-05 L4.938,-5.41499267e-05 C4.386,-5.41499267e-05 3.938,0.44794585 3.938,0.99994585 L3.938,2.99994585 L1,2.99994585 C0.448,2.99994585 0,3.44794585 0,3.99994585 L0,12.9999459 C0.037,13.4999459 -0.25,16.0509459 3.938,15.9999459 L12.408,15.9999459 C12.408,15.9999459 15.754,15.9169459 15.754,13.9999459 L15.754,0.99994585 C15.754,0.44794585 15.306,-5.41499267e-05 14.754,-5.41499267e-05 L14.754,-5.41499267e-05 Z" id="Pasted-Image-241" fill="#7E7D7D"></path> - </g> -</svg>
\ No newline at end of file diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml new file mode 100644 index 00000000000..217af7c9fac --- /dev/null +++ b/app/views/shared/issuable/_assignees.html.haml @@ -0,0 +1,14 @@ +- max_render = 3 +- max = [max_render, issue.assignees.length].min + +- issue.assignees.take(max).each do |assignee| + = link_to_member(@project, assignee, name: false, title: "Assigned to :name") + +- if issue.assignees.length > max_render + - counter = issue.assignees.length - max_render + + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{counter} more assignees" } } + - if counter < 99 + = "+#{counter}" + - else + 99+ diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 847a86e2e68..6cd03f028a9 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -21,7 +21,7 @@ - if params[:assignee_id].present? = hidden_field_tag(:assignee_id, params[:assignee_id]) = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user.try(:username), null_user: true, current_user: true, project_id: @project.try(:id), selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) + placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user.try(:username), null_user: true, current_user: true, project_id: @project.try(:id), group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } }) .filter-item.inline.milestone-filter = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true @@ -40,21 +40,21 @@ .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]", default_label: "Status" } } ) do + = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) 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", + = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle 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]", default_label: "Assignee" } }) .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]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle 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]", default_label: "Milestone", 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'], dropdown_title: 'Apply a label', 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]", default_label: "Subscription" } } ) do + = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do %ul %li %a{ href: "#", data: { id: "subscribe" } } Subscribe @@ -71,7 +71,6 @@ = render 'shared/labels_row', labels: @labels :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 17107f55a2d..7748351b333 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -17,7 +17,7 @@ = render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) -= render 'shared/issuable/form/description', issuable: issuable, form: form += render 'shared/issuable/form/description', issuable: issuable, form: form, project: project - if issuable.respond_to?(:confidential) .form-group diff --git a/app/views/shared/issuable/_milestone_dropdown.html.haml b/app/views/shared/issuable/_milestone_dropdown.html.haml index f0d50828e2a..6750921338a 100644 --- a/app/views/shared/issuable/_milestone_dropdown.html.haml +++ b/app/views/shared/issuable/_milestone_dropdown.html.haml @@ -6,7 +6,7 @@ - if selected.present? || params[:milestone_title].present? = hidden_field_tag(name, name == :milestone_title ? selected_text : selected.id) = dropdown_tag(milestone_dropdown_label(selected_text), options: { title: dropdown_title, toggle_class: "js-milestone-select js-filter-submit #{extra_class}", filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", - placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected.try(:title), project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do + placeholder: "Search milestones", footer_content: project.present?, data: { show_no: true, show_menu_above: show_menu_above, show_any: show_any, show_upcoming: show_upcoming, show_started: show_started, field_name: name, selected: selected_text, project_id: project.try(:id), milestones: milestones_filter_dropdown_path, default_label: "Milestone" } }) do - if project %ul.dropdown-footer-list - if can? current_user, :admin_milestone, project diff --git a/app/views/shared/issuable/_participants.html.haml b/app/views/shared/issuable/_participants.html.haml index 171da899937..db407363a09 100644 --- a/app/views/shared/issuable/_participants.html.haml +++ b/app/views/shared/issuable/_participants.html.haml @@ -12,9 +12,9 @@ - participants.each do |participant| .participants-author.js-participants-author = link_to_member(@project, participant, name: false, size: 24) - - if participants_extra > 0 - .participants-more - %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } - + #{participants_extra} more + - if participants_extra > 0 + .hide-collapsed.participants-more + %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } + + #{participants_extra} more :javascript IssuableContext.prototype.PARTICIPANTS_ROW_COUNT = #{participants_row}; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 330fa8a5b10..80974bdb066 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -10,85 +10,94 @@ .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 - .scroll-container - %ul.tokens-container.list-unstyled - %li.input-token - %input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: "filtered-search-#{type.to_s}", 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } - = 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: { action: 'submit' } } - %button.btn.btn-link - = icon('search') - %span - Press Enter or click to search - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link - -# Encapsulate static class name `{{icon}}` inside #{} to bypass - -# haml lint's ClassAttributeWithStaticValue - %i.fa{ class: "#{'{{icon}}'}" } - %span.js-filter-hint - {{hint}} - %span.js-filter-tag.dropdown-light-content - {{tag}} - #js-dropdown-author.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details + .issues-other-filters.filtered-search-wrapper + .filtered-search-box + - if type != :boards_modal && type != :boards + = dropdown_tag(custom_icon('icon_history'), + options: { wrapper_class: "filtered-search-history-dropdown-wrapper", + toggle_class: "filtered-search-history-dropdown-toggle-button", + dropdown_class: "filtered-search-history-dropdown", + content_class: "filtered-search-history-dropdown-content", + title: "Recent searches" }) do + .js-filtered-search-history-dropdown{ data: { project_full_path: @project.full_path } } + .filtered-search-box-input-container + .scroll-container + %ul.tokens-container.list-unstyled + %li.input-token + %input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } + = icon('filter') + %button.clear-search.hidden{ type: 'button' } + = icon('times') + #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { action: 'submit' } } + %button.btn.btn-link + = icon('search') %span - {{name}} - %span.dropdown-light-content - @{{username}} - #js-dropdown-assignee.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } - %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, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details - %span - {{name}} - %span.dropdown-light-content - @{{username}} - #js-dropdown-milestone.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } - %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.filter-dropdown-item{ 'data-value' => 'started' } - %button.btn.btn-link - Started - %li.divider - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value - {{title}} - #js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } - %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, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link - %span.dropdown-label-box{ style: 'background: {{color}}' } - %span.label-title.js-data-value + Press Enter or click to search + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link + -# Encapsulate static class name `{{icon}}` inside #{} to bypass + -# haml lint's ClassAttributeWithStaticValue + %i.fa{ class: "#{'{{icon}}'}" } + %span.js-filter-hint + {{hint}} + %span.js-filter-tag.dropdown-light-content + {{tag}} + #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.dropdown-user + %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } + %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, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.dropdown-user + %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } + %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.filter-dropdown-item{ 'data-value' => 'started' } + %button.btn.btn-link + Started + %li.divider + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value {{title}} + #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } + %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, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link + %span.dropdown-label-box{ style: 'background: {{color}}' } + %span.label-title.js-data-value + {{title}} .filter-dropdown-container - if type == :boards - if can?(current_user, :admin_list, @project) @@ -108,21 +117,26 @@ .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 + = 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]", default_label: "Status" } } ) do %ul %li %a{ href: "#", data: { id: "reopen" } } Open %li %a{ href: "#", data: { id: "close" } } Closed .filter-item.inline + - if type == :issues + - field_name = "update[assignee_ids][]" + - else + - field_name = "update[assignee_id]" + = 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]" } }) + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } }) .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 } }) + = 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, default_label: "Milestone" } }) .filter-item.inline.labels-filter - = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', 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 } + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', 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, default_label: "Labels" } .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 + = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do %ul %li %a{ href: "#", data: { id: "subscribe" } } Subscribe @@ -136,7 +150,6 @@ - unless type === :boards_modal :javascript - new UsersSelect(); new LabelsSelect(); new MilestoneSelect(); new IssueStatusSelect(); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 92d2d93a732..e49bd5ebb13 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,10 +1,10 @@ - todo = issuable_todo(issuable) - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('issuable') + = page_specific_javascript_bundle_tag('sidebar') -%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "102", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } - .issuable-sidebar +%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } + .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } } - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - if current_user @@ -20,36 +20,7 @@ .block.todo.hide-expanded = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true .block.assignee - .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 24) - - else - = icon('user', 'aria-hidden': 'true') - .title.hide-collapsed - Assignee - = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.hide-collapsed - - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) - %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } - = icon('exclamation-triangle', 'aria-hidden': 'true') - %span.username - = issuable.assignee.to_reference - - else - %span.assign-yourself.no-value - No assignee - - if can_edit_issuable - \- - %a.js-assign-yourself{ href: '#' } - assign yourself - - .selectbox.hide-collapsed - = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' - = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }) - + = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable .block.milestone .sidebar-collapsed-icon = icon('clock-o', 'aria-hidden': 'true') @@ -72,14 +43,13 @@ .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil - = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) + = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true }}) - if issuable.has_attribute?(:time_estimate) #issuable-time-tracker.block - %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') } - // Fallback while content is loading - .title.hide-collapsed - Time tracking - = icon('spinner spin', 'aria-hidden': 'true') + // Fallback while content is loading + .title.hide-collapsed + Time tracking + = icon('spinner spin', 'aria-hidden': 'true') - if issuable.has_attribute?(:due_date) .block.due_date .sidebar-collapsed-icon @@ -136,7 +106,7 @@ - selected_labels.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project) } } + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (namespace_project_labels_path(@project.namespace, @project, :json) if @project) } } %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') @@ -160,17 +130,22 @@ - project_ref = cross_project_reference(@project, issuable) .block.project-reference .sidebar-collapsed-icon.dont-change-state - = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left") + = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") .cross-project-reference.hide-collapsed %span Reference: %cite{ title: project_ref } = project_ref - = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left") + = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") :javascript - gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}'); - new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}"); + gl.sidebarOptions = { + endpoint: "#{issuable_json_path(issuable)}?basic=true", + editable: #{can_edit_issuable ? true : false}, + currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)}, + rootPath: "#{root_path}" + }; + new MilestoneSelect('{"full_path":"#{@project.full_path}"}'); new LabelsSelect(); new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}'); diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml new file mode 100644 index 00000000000..e9ce7b7ce9c --- /dev/null +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -0,0 +1,49 @@ +- if issuable.is_a?(Issue) + #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } +- else + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if issuable.assignee + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do + - if !issuable.can_be_merged_by?(issuable.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = issuable.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + +.selectbox.hide-collapsed + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil + + - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } + + - title = 'Select assignee' + + - if issuable.is_a?(Issue) + - unless issuable.assignees.any? + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil + - options[:toggle_class] += ' js-multiselect js-save-user-data' + - data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" } + - data[:multi_select] = true + - data['dropdown-title'] = title + - data['dropdown-header'] = 'Assignee' + - data['max-select'] = 1 + - options[:data].merge!(data) + + = dropdown_tag(title, options: options) diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 2793e7bcff4..203d2adc8db 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -10,12 +10,16 @@ = form.label :source_branch, class: 'control-label' .col-sm-10 .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 span2', disabled: true }) + = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 ref-name', disabled: true }) .form-group = form.label :target_branch, class: 'control-label' - .col-sm-10 + .col-sm-10.target-branch-select-dropdown-container .issuable-form-select-holder - = form.select(:target_branch, issuable.target_branches, { include_blank: true }, { class: 'target_branch select2 span2', disabled: issuable.new_record?, data: { placeholder: "Select branch" }}) + = form.select(:target_branch, issuable.target_branches, + { include_blank: true }, + { class: 'target_branch js-target-branch-select ref-name', + disabled: issuable.new_record?, + data: { placeholder: "Select branch" }}) - if issuable.new_record? = link_to 'Change branches', mr_change_branches_path(issuable) diff --git a/app/views/shared/issuable/form/_description.html.haml b/app/views/shared/issuable/form/_description.html.haml index dbace9ce401..7ef0ae96be2 100644 --- a/app/views/shared/issuable/form/_description.html.haml +++ b/app/views/shared/issuable/form/_description.html.haml @@ -1,15 +1,22 @@ +- project = local_assigns.fetch(:project) - issuable = local_assigns.fetch(:issuable) - form = local_assigns.fetch(:form) +- supports_slash_commands = issuable.new_record? + +- if supports_slash_commands + - preview_url = preview_markdown_path(project, slash_commands_target_type: issuable.class.name) +- else + - preview_url = preview_markdown_path(project) .form-group.detail-page-description = form.label :description, 'Description', class: 'control-label' .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea', placeholder: "Write a comment or drag your files here...", - supports_slash_commands: !issuable.persisted? - = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted? + supports_slash_commands: supports_slash_commands + = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands .clearfix .error-alert diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml new file mode 100644 index 00000000000..66091d95a91 --- /dev/null +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -0,0 +1,31 @@ +- issue = issuable +- assignees = issue.assignees +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee_list) } + - if assignees.any? + - assignees.each do |assignee| + = link_to_member(@project, assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if assignees.any? + - assignees.each do |assignee| + = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do + %span.username + = assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_ids', value: issuable.assignee_ids, id: 'issue_assignee_ids' + = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }) diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 03309722326..d23f79be2be 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -5,12 +5,3 @@ -# This check is duplicated below, to avoid conflicts with EE. - return unless issuable.can_remove_source_branch?(current_user) - -.form-group - .col-sm-10.col-sm-offset-2 - - if issuable.can_remove_source_branch?(current_user) - .checkbox - = label_tag 'merge_request[force_remove_source_branch]' do - = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil - = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch? - Remove source branch when merge request is accepted. diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml new file mode 100644 index 00000000000..18011d528a0 --- /dev/null +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -0,0 +1,31 @@ +- merge_request = issuable +.block.assignee + .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (merge_request.assignee.name if merge_request.assignee) } + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 24) + - else + = icon('user', 'aria-hidden': 'true') + .title.hide-collapsed + Assignee + = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') + - if can_edit_issuable + = link_to 'Edit', '#', class: 'edit-link pull-right' + .value.hide-collapsed + - if merge_request.assignee + = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do + - unless merge_request.can_be_merged_by?(merge_request.assignee) + %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } + = icon('exclamation-triangle', 'aria-hidden': 'true') + %span.username + = merge_request.assignee.to_reference + - else + %span.assign-yourself.no-value + No assignee + - if can_edit_issuable + \- + %a.js-assign-yourself{ href: '#' } + assign yourself + + .selectbox.hide-collapsed + = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id' + = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } }) diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 9dbfedb84f1..1608bd59cf1 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -10,13 +10,10 @@ .row %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } .form-group.issue-assignee - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = form.hidden_field :assignee_id - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" + - if issuable.is_a?(Issue) + = render "shared/issuable/form/metadata_issue_assignee", issuable: issuable, form: form, has_due_date: has_due_date + - else + = render "shared/issuable/form/metadata_merge_request_assignee", issuable: issuable, form: form, has_due_date: has_due_date .form-group.issue-milestone = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" .col-sm-10{ class: ("col-lg-8" if has_due_date) } diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml new file mode 100644 index 00000000000..8119f19291b --- /dev/null +++ b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml @@ -0,0 +1,11 @@ += form.label :assignee_ids, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" +.col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder.selectbox + - issuable.assignees.each do |assignee| + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } + + - if issuable.assignees.length === 0 + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } + + = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_dropdown_options(issuable,false)) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}" diff --git a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml new file mode 100644 index 00000000000..d0ea4e149df --- /dev/null +++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml @@ -0,0 +1,8 @@ += form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" +.col-sm-10{ class: ("col-lg-8" if has_due_date) } + .issuable-form-select-holder + = form.hidden_field :assignee_id + + = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", + placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) + = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}" diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index 647e05e5ff7..e8b04f56839 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -29,5 +29,5 @@ - if @label.persisted? = f.submit 'Save changes', class: 'btn btn-save js-save-button' - else - = f.submit 'Create Label', class: 'btn btn-create js-save-button' + = f.submit 'Create label', class: 'btn btn-create js-save-button' = link_to 'Cancel', back_path, class: 'btn btn-cancel' diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index 10050adfda5..92f6e7428ae 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -1,5 +1,5 @@ - if requesters.any? - .panel.panel-default + .panel.panel-default.prepend-top-default .panel-heading Users requesting access to %strong= membership_source.name diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index ed94773ef89..a74cdbe274b 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -3,10 +3,10 @@ = f.label :start_date, "Start Date", class: "control-label" .col-sm-10 = f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date" - %a.inline.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date + %a.inline.pull-right.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date .col-md-6 .form-group = f.label :due_date, "Due Date", class: "control-label" .col-sm-10 = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date" - %a.inline.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date + %a.inline.pull-right.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index 4c7d69d40d5..22547a30cdf 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -1,11 +1,14 @@ -# @project is present when viewing Project's milestone - project = @project || issuable.project -- assignee = issuable.assignee +- namespace = @project_namespace || project.namespace.becomes(Namespace) +- assignees = issuable.assignees - issuable_type = issuable.class.table_name -- base_url_args = [project.namespace.becomes(Namespace), project, issuable_type] +- base_url_args = [namespace, project] +- issuable_type_args = base_url_args + [issuable_type] +- issuable_url_args = base_url_args + [issuable] - can_update = can?(current_user, :"update_#{issuable.to_ability_name}", issuable) -%li{ id: dom_id(issuable, 'sortable'), class: "issuable-row #{'is-disabled' unless can_update}", 'data-iid' => issuable.iid, 'data-id' => issuable.id, 'data-url' => polymorphic_path(issuable) } +%li{ id: dom_id(issuable, 'sortable'), class: "issuable-row #{'is-disabled' unless can_update}", 'data-iid' => issuable.iid, 'data-id' => issuable.id, 'data-url' => polymorphic_path(issuable_url_args) } %span - if show_project_name %strong #{project.name} · @@ -13,17 +16,17 @@ %strong #{project.name_with_namespace} · - if issuable.is_a?(Issue) = confidential_icon(issuable) - = link_to_gfm issuable.title, [project.namespace.becomes(Namespace), project, issuable], title: issuable.title + = link_to_gfm issuable.title, issuable_url_args, title: issuable.title .issuable-detail = link_to [project.namespace.becomes(Namespace), project, issuable] do %span.issuable-number= issuable.to_reference - issuable.labels.each do |label| - = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do + = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do - render_colored_label(label) %span.assignee-icon - - if assignee - = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: issuable.assignee_id, state: 'all' }), + - assignees.each do |assignee| + = link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, assignee_id: assignee.id, state: 'all' }), class: 'has-tooltip', title: "Assigned to #{assignee.name}", data: { container: 'body' } do - - image_tag(avatar_icon(issuable.assignee, 16), class: "avatar s16", alt: '') + - image_tag(avatar_icon(assignee, 16), class: "avatar s16", alt: '') diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index 33f93dccd3c..a26b3b8009e 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -2,7 +2,7 @@ - labels.each do |label| - options = { milestone_title: @milestone.title, label_name: label.title } - %li + %li.is-not-draggable %span.label-row %span.label-name = link_to milestones_label_path(options) do @@ -10,10 +10,8 @@ %span.prepend-description-left = markdown_field(label, :description) - .pull-info-right - %span.append-right-20 - = link_to milestones_label_path(options.merge(state: 'opened')) do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' - %span.append-right-20 - = link_to milestones_label_path(options.merge(state: 'closed')) do - - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' + .pull-right.hidden-xs.hidden-sm.hidden-md + = link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do + - pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue' + = link_to milestones_label_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do + - pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue' diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 2810f1377b2..9bb87640319 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -1,4 +1,4 @@ -- affix_offset = local_assigns.fetch(:affix_offset, "102") +- affix_offset = local_assigns.fetch(:affix_offset, "50") - project = local_assigns[:project] %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => affix_offset, "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } @@ -64,7 +64,7 @@ %span.remaining-days= remaining_days - if !project || can?(current_user, :read_issue, project) - .block + .block.issues .sidebar-collapsed-icon %strong = icon('hashtag', 'aria-hidden': 'true') @@ -85,11 +85,11 @@ Closed: = milestone.issues_visible_to_user(current_user).closed.count - .block + .block.merge-requests .sidebar-collapsed-icon %strong = icon('exclamation', 'aria-hidden': 'true') - %span= milestone.issues_visible_to_user(current_user).count + %span= milestone.merge_requests.count .title.hide-collapsed Merge requests %span.badge= milestone.merge_requests.count @@ -122,10 +122,10 @@ - if milestone_ref.present? .block.reference .sidebar-collapsed-icon.dont-change-state - = clipboard_button(clipboard_text: milestone_ref, title: "Copy reference to clipboard", placement: "left") + = clipboard_button(text: milestone_ref, title: "Copy reference to clipboard", placement: "left") .cross-project-reference.hide-collapsed %span Reference: %cite{ title: milestone_ref } = milestone_ref - = clipboard_button(clipboard_text: milestone_ref, title: "Copy reference to clipboard", placement: "left") + = clipboard_button(text: milestone_ref, title: "Copy reference to clipboard", placement: "left") diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml new file mode 100644 index 00000000000..68458c2d0aa --- /dev/null +++ b/app/views/shared/milestones/_tab_loading.html.haml @@ -0,0 +1,2 @@ +.text-center.prepend-top-default + = icon('spin spinner 2x', 'aria-hidden': 'true', 'aria-label': 'Loading tab content') diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 9a4502873ef..6a6d817b344 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,27 +1,27 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= icon('angle-left') .fade-right= icon('angle-right') - %ul.nav-links.scrolling-tabs + %ul.nav-links.scrolling-tabs.js-milestone-tabs - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) %li.active = link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do Issues %span.badge= milestone.issues_visible_to_user(current_user).size %li - = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do + = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do Merge Requests %span.badge= milestone.merge_requests.size - else %li.active - = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do + = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do Merge Requests %span.badge= milestone.merge_requests.size %li - = link_to '#tab-participants', 'data-toggle' => 'tab' do + = link_to '#tab-participants', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do Participants %span.badge= milestone.participants.count %li - = link_to '#tab-labels', 'data-toggle' => 'tab' do + = link_to '#tab-labels', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do Labels %span.badge= milestone.labels.count @@ -30,14 +30,18 @@ .tab-content.milestone-content - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) - .tab-pane.active#tab-issues + .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } = render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user).include_associations, show_project_name: show_project_name, show_full_project_name: show_full_project_name - .tab-pane#tab-merge-requests - = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane#tab-merge-requests{ data: { sort_endpoint: (sort_merge_requests_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } + -# loaded async + = render "shared/milestones/tab_loading" - else - .tab-pane.active#tab-merge-requests - = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane.active#tab-merge-requests{ data: { sort_endpoint: (sort_merge_requests_namespace_project_milestone_path(@project.namespace, @project, @milestone) if @project && current_user) } } + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-participants - = render 'shared/milestones/participants_tab', users: milestone.participants + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-labels - = render 'shared/milestones/labels_tab', labels: milestone.labels + -# loaded async + = render "shared/milestones/tab_loading" diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml new file mode 100644 index 00000000000..29cf5825292 --- /dev/null +++ b/app/views/shared/notes/_comment_button.html.haml @@ -0,0 +1,30 @@ +- noteable_name = @note.noteable.human_class_name + +.pull-left.btn-group.append-right-10.comment-type-dropdown.js-comment-type-dropdown + %input.btn.btn-nr.btn-create.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' } + + - if @note.can_be_discussion_note? + = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle comment-btn js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => 'Open comment type dropdown' do + = icon('caret-down', class: 'toggle-icon') + + %ul#resolvable-comment-menu.dropdown-menu{ data: { dropdown: true } } + %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => 'Comment', 'close-text' => "Comment & close #{noteable_name}", 'reopen-text' => "Comment & reopen #{noteable_name}" } } + %a{ href: '#' } + = icon('check') + .description + %strong Comment + %p + Add a general comment to this #{noteable_name}. + + %li.divider.droplab-item-ignore + + %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } } + %a{ href: '#' } + = icon('check') + .description + %strong Start discussion + %p + = succeed '.' do + Discuss a specific suggestion or question + - if @note.noteable.supports_resolvable_notes? + that needs to be resolved diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml new file mode 100644 index 00000000000..f4b3aac29b4 --- /dev/null +++ b/app/views/shared/notes/_edit.html.haml @@ -0,0 +1 @@ +%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: note_url(note) } }= note.note diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml index e8e450742b5..8923e5602a4 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/shared/notes/_edit_form.html.haml @@ -2,13 +2,13 @@ = form_tag '#', method: :put, class: 'edit-note common-note-form js-quick-submit' do = hidden_field_tag :target_id, '', class: 'js-form-target-id' = hidden_field_tag :target_type, '', class: 'js-form-target-type' - = render layout: 'projects/md_preview', locals: { preview_class: 'md-preview', referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here..." - = render 'projects/notes/hints' + = render 'shared/notes/hints' .note-form-actions.clearfix - .settings-message.note-edit-warning.js-edit-warning + .settings-message.note-edit-warning.js-finish-edit-warning Finish editing this message first! - = submit_tag 'Save Comment', class: 'btn btn-nr btn-save js-comment-button' + = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-save-button' %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' } Cancel diff --git a/app/views/projects/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index b561052e721..eaf50bc2115 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -1,28 +1,40 @@ - supports_slash_commands = note_supports_slash_commands?(@note) +- if supports_slash_commands + - preview_url = preview_markdown_path(@project, slash_commands_target_type: @note.noteable_type, slash_commands_target_id: @note.noteable_id) +- else + - preview_url = preview_markdown_path(@project) -= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| += form_for form_resources, url: new_form_url, remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f| = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha) + = hidden_field_tag :in_reply_to_discussion_id + = note_target_fields(@note) - = f.hidden_field :commit_id - = f.hidden_field :line_code - = f.hidden_field :noteable_id = f.hidden_field :noteable_type + = f.hidden_field :noteable_id + = f.hidden_field :commit_id = f.hidden_field :type + + -# LegacyDiffNote + = f.hidden_field :line_code + + -# DiffNote = f.hidden_field :position - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do + = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here...", supports_slash_commands: supports_slash_commands - = render 'projects/notes/hints', supports_slash_commands: supports_slash_commands + = render 'shared/notes/hints', supports_slash_commands: supports_slash_commands .error-alert .note-form-actions.clearfix - = f.submit 'Comment', class: "btn btn-nr btn-create append-right-10 comment-btn js-comment-button" + = render partial: 'shared/notes/comment_button' + = yield(:note_actions) + %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } Discard draft diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml new file mode 100644 index 00000000000..7ce6130de60 --- /dev/null +++ b/app/views/shared/notes/_hints.html.haml @@ -0,0 +1,35 @@ +- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false) +.comment-toolbar.clearfix + .toolbar-text + = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1 + - if supports_slash_commands + and + = link_to 'slash commands', help_page_path('user/project/slash_commands'), target: '_blank', tabindex: -1 + are + - else + is + supported + + %span.uploading-container + %span.uploading-progress-container.hide + = icon('file-image-o', class: 'toolbar-button-icon') + %span.attaching-file-message + -# Populated by app/assets/javascripts/dropzone_input.js + %span.uploading-progress 0% + %span.uploading-spinner + = icon('spinner spin', class: 'toolbar-button-icon') + + %span.uploading-error-container.hide + %span.uploading-error-icon + = icon('file-image-o', class: 'toolbar-button-icon') + %span.uploading-error-message + -# Populated by app/assets/javascripts/dropzone_input.js + %button.retry-uploading-link{ type: 'button' } Try again + or + %button.attach-new-file.markdown-selector{ type: 'button' } attach a new file + + %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' } + = icon('file-image-o', class: 'toolbar-button-icon') + Attach a file + + %button.btn.btn-default.btn-xs.hide.button-cancel-uploading-files{ type: 'button' } Cancel diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml new file mode 100644 index 00000000000..a7bf610b9c7 --- /dev/null +++ b/app/views/shared/notes/_note.html.haml @@ -0,0 +1,65 @@ +- return unless note.author +- return if note.cross_reference_not_visible_for?(current_user) + +- note_editable = note_editable?(note) +%li.timeline-entry{ id: dom_id(note), + class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], + data: { author_id: note.author.id, + editable: note_editable, + note_id: note.id } } + .timeline-entry-inner + .timeline-icon + - if note.system + = icon_for_system_note(note) + - else + %a{ href: user_path(note.author) } + = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40' + .timeline-content + .note-header + .note-header-info + %a{ href: user_path(note.author) } + %span.hidden-xs + = sanitize(note.author.name) + %span.note-headline-light + = note.author.to_reference + %span.note-headline-light + %span.note-headline-meta + - unless note.system + commented + - if note.system + %span.system-note-message + = note.redacted_note_html + %a{ href: "##{dom_id(note)}" } + = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') + - unless note.system? + .note-actions + - if note.for_personal_snippet? + = render 'snippets/notes/actions', note: note, note_editable: note_editable + - else + = render 'projects/notes/actions', note: note, note_editable: note_editable + .note-body{ class: note_editable ? 'js-task-list-container' : '' } + .note-text.md + = note.redacted_note_html + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago') + .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } } + #{note.note} + - if note_editable + = render 'shared/notes/edit', note: note + .note-awards + = render 'award_emoji/awards_block', awardable: note, inline: false + - if note.system + .system-note-commit-list-toggler + Toggle commit list + %i.fa.fa-angle-down + - if note.attachment.url + .note-attachment + - if note.attachment.image? + = link_to note.attachment.url, target: '_blank' do + = image_tag note.attachment.url, class: 'note-image-attach' + .attachment + = link_to note.attachment.url, target: '_blank' do + = icon('paperclip') + = note.attachment_identifier + = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note), + title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do + = icon('trash-o', class: 'cred') diff --git a/app/views/shared/notes/_notes.html.haml b/app/views/shared/notes/_notes.html.haml new file mode 100644 index 00000000000..cfdfeeb9e97 --- /dev/null +++ b/app/views/shared/notes/_notes.html.haml @@ -0,0 +1,8 @@ +- if defined?(@discussions) + - @discussions.each do |discussion| + - if discussion.individual_note? + = render partial: "shared/notes/note", collection: discussion.notes, as: :note + - else + = render 'discussions/discussion', discussion: discussion +- else + = render partial: "shared/notes/note", collection: @notes, as: :note diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 90a150aa74c..05bb1970e21 100644 --- a/app/views/projects/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,18 +1,18 @@ %ul#notes-list.notes.main-notes-list.timeline - = render "projects/notes/notes" + = render "shared/notes/notes" -= render 'projects/notes/edit_form' += render 'shared/notes/edit_form', project: @project %ul.notes.notes-form.timeline %li.timeline-entry .flash-container.timeline-content - - if can? current_user, :create_note, @project + - if can_create_note? .timeline-icon.hidden-xs.hidden-sm %a.author_link{ href: user_path(current_user) } = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form - = render "projects/notes/form", view: diff_view + = render "shared/notes/form", view: diff_view - elsif !current_user .disabled-comment.text-center .disabled-comment-text.inline @@ -23,4 +23,4 @@ to post a comment :javascript - var notes = new Notes("#{namespace_project_noteable_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}") + var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}", false) diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml index a736bfd91e2..183ed34fba1 100644 --- a/app/views/shared/notifications/_custom_notifications.html.haml +++ b/app/views/shared/notifications/_custom_notifications.html.haml @@ -1,9 +1,9 @@ -.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), aria: { labelledby: "custom-notifications-title" } } +.modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), "aria-labelledby": "custom-notifications-title" } .modal-dialog .modal-content .modal-header - %button.close{ type: "button", data: { dismiss: "modal" }, aria: { label: "close" } } - %span{ aria: { hidden: "true" } } × + %button.close{ type: "button", "aria-label": "close", data: { dismiss: "modal" } } + %span{ "aria-hidden": "true" } } × %h4#custom-notifications-title.modal-title Custom notification events @@ -25,7 +25,7 @@ .form-group .checkbox{ class: ("prepend-top-0" if index == 0) } %label{ for: field_id } - = check_box("notification_setting", event, id: field_id, class: "js-custom-notification-event", checked: notification_setting.events[event]) + = check_box("notification_setting", event, id: field_id, class: "js-custom-notification-event", checked: notification_setting.public_send(event)) %strong = notification_event_name(event) = icon("spinner spin", class: "custom-notification-event-loading") diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index 2d25b8aad62..8939aeb6c3a 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,4 +1,4 @@ -- @sort ||= sort_value_recently_updated +- @sort ||= sort_value_latest_activity .dropdown - toggle_text = projects_sort_options_hash[@sort] = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' }) diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index c0699b13719..aaffc0927eb 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -7,6 +7,7 @@ - skip_namespace = false unless local_assigns[:skip_namespace] == true - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true - remote = false unless local_assigns[:remote] == true +- load_pipeline_status(projects) .js-projects-list-holder - if projects.any? diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 761f0b606b5..cf0540afb38 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -7,15 +7,17 @@ - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = project_list_cache_key(project) +- updated_tooltip = time_ago_with_tooltip(project.updated_at) %li.project-row{ class: css_class } = cache(cache_key) do - if avatar .avatar-container.s40 - - if use_creator_avatar - = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:'' - - else - = project_icon(project, alt: '', class: 'avatar project-avatar s40') + = link_to project_path(project), class: dom_class(project) do + - if use_creator_avatar + = image_tag avatar_icon(project.creator.email, 40), class: "avatar s40", alt:'' + - else + = project_icon(project, alt: '', class: 'avatar project-avatar s40') .project-details %h3.prepend-top-0.append-bottom-0 = link_to project_path(project), class: dom_class(project) do @@ -36,18 +38,21 @@ = markdown_field(project, :description) .controls - - if project.archived - %span.prepend-left-10.label.label-warning archived - - if project.pipeline_status.has_status? - %span.prepend-left-10 - = render_project_pipeline_status(project.pipeline_status) - - if forks - %span.prepend-left-10 - = icon('code-fork') - = number_with_delimiter(project.forks_count) - - if stars - %span.prepend-left-10 - = icon('star') - = number_with_delimiter(project.star_count) - %span.prepend-left-10.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project) } - = visibility_level_icon(project.visibility_level, fw: true) + .prepend-top-0 + - if project.archived + %span.prepend-left-10.label.label-warning archived + - if project.pipeline_status.has_status? + %span.prepend-left-10 + = render_project_pipeline_status(project.pipeline_status) + - if forks + %span.prepend-left-10 + = icon('code-fork') + = number_with_delimiter(project.forks_count) + - if stars + %span.prepend-left-10 + = icon('star') + = number_with_delimiter(project.star_count) + %span.prepend-left-10.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project) } + = visibility_level_icon(project.visibility_level, fw: true) + .prepend-top-0 + updated #{updated_tooltip} diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 74f71e6cbd1..11f0fa7c49f 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -1,29 +1,14 @@ +- blob = @snippet.blob .js-file-title.file-title-flex-parent - .file-header-content - = blob_icon @snippet.mode, @snippet.path - - %strong.file-title-name - = @snippet.path - - = copy_file_path_button(@snippet.path) + = render 'projects/blob/header_content', blob: blob .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob + .btn-group{ role: "group" }< - = copy_blob_content_button(@snippet) - = open_raw_file_button(raw_path) + = copy_blob_source_button(blob) + = open_raw_blob_button(blob) - - if defined?(download_path) && download_path - = link_to icon('download'), download_path, class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' } + = link_to icon('download'), download_snippet_path(@snippet), target: '_blank', class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' } -- if @snippet.content.empty? - .file-content.code - .nothing-here-block Empty file -- else - - if markup?(@snippet.file_name) - .file-content.wiki - - if gitlab_markdown?(@snippet.file_name) - = preserve(markdown_field(@snippet, :content)) - - else - = render_markup(@snippet.file_name, @snippet.content) - - else - = render 'shared/file_highlight', blob: @snippet += render 'projects/blob/content', blob: blob diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index d084f5e9684..501c09d71d5 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -21,4 +21,4 @@ = markdown_field(@snippet, :title) - if @snippet.updated_at != @snippet.created_at - = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago') + = edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true) diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 37e2a377a69..1f0e7629fb4 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -1,102 +1,82 @@ -.row.prepend-top-default - .col-lg-3 - %h4.prepend-top-0 - = page_title - %p - #{link_to "Webhooks", help_page_path("user/project/integrations/webhooks")} can be - used for binding events when something is happening within the project. - .col-lg-9.append-bottom-default - = form_for hook, as: :hook, url: polymorphic_path(url_components + [:hooks]) do |f| - = form_errors(hook) += form_errors(hook) - .form-group - = f.label :url, "URL", class: 'label-light' - = f.text_field :url, class: "form-control", placeholder: 'http://example.com/trigger-ci.json' - .form-group - = f.label :token, "Secret Token", class: 'label-light' - = f.text_field :token, class: "form-control", placeholder: '' - %p.help-block - Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header. - .form-group - = f.label :url, "Trigger", class: 'label-light' - %ul.list-unstyled - %li - = f.check_box :push_events, class: 'pull-left' - .prepend-left-20 - = f.label :push_events, class: 'list-label' do - %strong Push events - %p.light - This URL will be triggered by a push to the repository - %li - = f.check_box :tag_push_events, class: 'pull-left' - .prepend-left-20 - = f.label :tag_push_events, class: 'list-label' do - %strong Tag push events - %p.light - This URL will be triggered when a new tag is pushed to the repository - %li - = f.check_box :note_events, class: 'pull-left' - .prepend-left-20 - = f.label :note_events, class: 'list-label' do - %strong Comments - %p.light - This URL will be triggered when someone adds a comment - %li - = f.check_box :issues_events, class: 'pull-left' - .prepend-left-20 - = f.label :issues_events, class: 'list-label' do - %strong Issues events - %p.light - This URL will be triggered when an issue is created/updated/merged - %li - = f.check_box :confidential_issues_events, class: 'pull-left' - .prepend-left-20 - = f.label :confidential_issues_events, class: 'list-label' do - %strong Confidential Issues events - %p.light - This URL will be triggered when a confidential issue is created/updated/merged - %li - = f.check_box :merge_requests_events, class: 'pull-left' - .prepend-left-20 - = f.label :merge_requests_events, class: 'list-label' do - %strong Merge Request events - %p.light - This URL will be triggered when a merge request is created/updated/merged - %li - = f.check_box :build_events, class: 'pull-left' - .prepend-left-20 - = f.label :build_events, class: 'list-label' do - %strong Jobs events - %p.light - This URL will be triggered when the job status changes - %li - = f.check_box :pipeline_events, class: 'pull-left' - .prepend-left-20 - = f.label :pipeline_events, class: 'list-label' do - %strong Pipeline events - %p.light - This URL will be triggered when the pipeline status changes - %li - = f.check_box :wiki_page_events, class: 'pull-left' - .prepend-left-20 - = f.label :wiki_page_events, class: 'list-label' do - %strong Wiki Page events - %p.light - This URL will be triggered when a wiki page is created/updated - .form-group - = f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox' - .checkbox - = f.label :enable_ssl_verification do - = f.check_box :enable_ssl_verification - %strong Enable SSL verification - = f.submit "Add Webhook", class: "btn btn-create" - %hr - %h5.prepend-top-default - Webhooks (#{hooks.count}) - - if hooks.any? - %ul.well-list - - hooks.each do |hook| - = render "project_hook", hook: hook - - else - %p.settings-message.text-center.append-bottom-0 - No webhooks found, add one in the form above. +.form-group + = form.label :url, 'URL', class: 'label-light' + = form.text_field :url, class: 'form-control', placeholder: 'http://example.com/trigger-ci.json' +.form-group + = form.label :token, 'Secret Token', class: 'label-light' + = form.text_field :token, class: 'form-control', placeholder: '' + %p.help-block + Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header. +.form-group + = form.label :url, 'Trigger', class: 'label-light' + %ul.list-unstyled + %li + = form.check_box :push_events, class: 'pull-left' + .prepend-left-20 + = form.label :push_events, class: 'list-label' do + %strong Push events + %p.light + This URL will be triggered by a push to the repository + %li + = form.check_box :tag_push_events, class: 'pull-left' + .prepend-left-20 + = form.label :tag_push_events, class: 'list-label' do + %strong Tag push events + %p.light + This URL will be triggered when a new tag is pushed to the repository + %li + = form.check_box :note_events, class: 'pull-left' + .prepend-left-20 + = form.label :note_events, class: 'list-label' do + %strong Comments + %p.light + This URL will be triggered when someone adds a comment + %li + = form.check_box :issues_events, class: 'pull-left' + .prepend-left-20 + = form.label :issues_events, class: 'list-label' do + %strong Issues events + %p.light + This URL will be triggered when an issue is created/updated/merged + %li + = form.check_box :confidential_issues_events, class: 'pull-left' + .prepend-left-20 + = form.label :confidential_issues_events, class: 'list-label' do + %strong Confidential Issues events + %p.light + This URL will be triggered when a confidential issue is created/updated/merged + %li + = form.check_box :merge_requests_events, class: 'pull-left' + .prepend-left-20 + = form.label :merge_requests_events, class: 'list-label' do + %strong Merge Request events + %p.light + This URL will be triggered when a merge request is created/updated/merged + %li + = form.check_box :job_events, class: 'pull-left' + .prepend-left-20 + = form.label :job_events, class: 'list-label' do + %strong Job events + %p.light + This URL will be triggered when the job status changes + %li + = form.check_box :pipeline_events, class: 'pull-left' + .prepend-left-20 + = form.label :pipeline_events, class: 'list-label' do + %strong Pipeline events + %p.light + This URL will be triggered when the pipeline status changes + %li + = form.check_box :wiki_page_events, class: 'pull-left' + .prepend-left-20 + = form.label :wiki_page_events, class: 'list-label' do + %strong Wiki Page events + %p.light + This URL will be triggered when a wiki page is created/updated +.form-group + = form.label :enable_ssl_verification, 'SSL verification', class: 'label-light checkbox' + .checkbox + = form.label :enable_ssl_verification do + = form.check_box :enable_ssl_verification + %strong Enable SSL verification diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml index 915bf98eb3e..18ebeb78f87 100644 --- a/app/views/snippets/edit.html.haml +++ b/app/views/snippets/edit.html.haml @@ -1,4 +1,4 @@ -- page_title "Edit", @snippet.title, "Snippets" +- page_title "Edit", "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" %h3.page-title Edit Snippet %hr diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml new file mode 100644 index 00000000000..e8119642ab8 --- /dev/null +++ b/app/views/snippets/notes/_actions.html.haml @@ -0,0 +1,13 @@ +- if current_user + - if note.emoji_awardable? + - user_authored = note.user_authored?(current_user) + = link_to '#', title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do + = icon('spinner spin') + %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') + %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') + %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') + - if note_editable + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do + = icon('pencil', class: 'link-highlight') + = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do + = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index da9fb755a36..51dbbc32cc9 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -1,9 +1,12 @@ -- page_title @snippet.title, "Snippets" +- page_title "#{@snippet.title} (#{@snippet.to_reference})", "Snippets" = render 'shared/snippets/header' -%article.file-holder.snippet-file-content - = render 'shared/snippets/blob', raw_path: raw_snippet_path(@snippet), download_path: download_snippet_path(@snippet) +.personal-snippets + %article.file-holder.snippet-file-content + = render 'shared/snippets/blob' -.row-content-block.top-block.content-component-block - = render 'award_emoji/awards_block', awardable: @snippet, inline: true + .row-content-block.top-block.content-component-block + = render 'award_emoji/awards_block', awardable: @snippet, inline: true + + #notes= render "shared/notes/notes_with_form" diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml index adc07bcba73..00788e77b6b 100644 --- a/app/views/u2f/_register.html.haml +++ b/app/views/u2f/_register.html.haml @@ -7,13 +7,13 @@ - if current_user.two_factor_otp_enabled? .row.append-bottom-10 .col-md-3 - %button#js-setup-u2f-device.btn.btn-info Setup New U2F Device + %button#js-setup-u2f-device.btn.btn-info Setup new U2F device .col-md-9 %p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left. - else .row.append-bottom-10 .col-md-3 - %button#js-setup-u2f-device.btn.btn-info{ disabled: true } Setup New U2F Device + %button#js-setup-u2f-device.btn.btn-info{ disabled: true } Setup new U2F device .col-md-9 %p.text-warning You need to register a two-factor authentication app before you can set up a U2F device. @@ -36,7 +36,7 @@ = text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: "Pick a name" .col-md-3 = hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response" - = submit_tag "Register U2F Device", class: "btn btn-success" + = submit_tag "Register U2F device", class: "btn btn-success" :javascript var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f); diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml new file mode 100644 index 00000000000..0545cab538c --- /dev/null +++ b/app/views/users/_deletion_guidance.html.haml @@ -0,0 +1,10 @@ +- user = local_assigns.fetch(:user) + +%ul + %li + %p + Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the + = link_to 'user account deletion documentation.', help_page_path("user/profile/account/delete_account", anchor: "associated-records") + - personal_projects_count = user.personal_projects.count + - unless personal_projects_count.zero? + %li #{pluralize(personal_projects_count, 'personal project')} will be removed and cannot be restored diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 969ea7ab9e6..2b70d70e360 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -10,7 +10,7 @@ = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity") .user-profile - .cover-block.user-cover-block + .cover-block.user-cover-block.layout-nav .cover-controls - if @user == current_user = link_to profile_path, class: 'btn btn-gray has-tooltip', title: 'Edit profile', 'aria-label': 'Edit profile' do @@ -56,11 +56,11 @@ = icon('skype') - unless @user.linkedin.blank? .profile-link-holder.middle-dot-divider - = link_to "https://www.linkedin.com/in/#{@user.linkedin}", title: "LinkedIn" do + = link_to linkedin_url(@user), title: "LinkedIn" do = icon('linkedin-square') - unless @user.twitter.blank? .profile-link-holder.middle-dot-divider - = link_to "https://twitter.com/#{@user.twitter}", title: "Twitter" do + = link_to twitter_url(@user), title: "Twitter" do = icon('twitter-square') - unless @user.website_url.blank? .profile-link-holder.middle-dot-divider @@ -82,21 +82,21 @@ .scrolling-tabs-container .fade-left= icon('angle-left') .fade-right= icon('angle-right') - %ul.nav-links.center.user-profile-nav.scrolling-tabs + %ul.nav-links.user-profile-nav.scrolling-tabs %li.js-activity-tab - = link_to user_path, data: {target: 'div#activity', action: 'activity', toggle: 'tab'} do + = link_to user_path, data: { target: 'div#activity', action: 'activity', toggle: 'tab' } do Activity %li.js-groups-tab - = link_to user_groups_path, data: {target: 'div#groups', action: 'groups', toggle: 'tab'} do + = link_to user_groups_path, data: { target: 'div#groups', action: 'groups', toggle: 'tab', endpoint: user_groups_path(format: :json) } do Groups %li.js-contributed-tab - = link_to user_contributed_projects_path, data: {target: 'div#contributed', action: 'contributed', toggle: 'tab'} do + = link_to user_contributed_projects_path, data: { target: 'div#contributed', action: 'contributed', toggle: 'tab', endpoint: user_contributed_projects_path(format: :json) } do Contributed projects %li.js-projects-tab - = link_to user_projects_path, data: {target: 'div#projects', action: 'projects', toggle: 'tab'} do + = link_to user_projects_path, data: { target: 'div#projects', action: 'projects', toggle: 'tab', endpoint: user_projects_path(format: :json) } do Personal projects %li.js-snippets-tab - = link_to user_snippets_path, data: {target: 'div#snippets', action: 'snippets', toggle: 'tab'} do + = link_to user_snippets_path, data: { target: 'div#snippets', action: 'snippets', toggle: 'tab', endpoint: user_snippets_path(format: :json) } do Snippets %div{ class: container_class } diff --git a/app/workers/build_coverage_worker.rb b/app/workers/build_coverage_worker.rb index def0ab1dde1..f7ae996bb17 100644 --- a/app/workers/build_coverage_worker.rb +++ b/app/workers/build_coverage_worker.rb @@ -3,7 +3,6 @@ class BuildCoverageWorker include BuildQueue def perform(build_id) - Ci::Build.find_by(id: build_id) - .try(:update_coverage) + Ci::Build.find_by(id: build_id)&.update_coverage end end diff --git a/app/workers/clear_database_cache_worker.rb b/app/workers/clear_database_cache_worker.rb deleted file mode 100644 index c4cb4733482..00000000000 --- a/app/workers/clear_database_cache_worker.rb +++ /dev/null @@ -1,24 +0,0 @@ -# This worker clears all cache fields in the database, working in batches. -class ClearDatabaseCacheWorker - include Sidekiq::Worker - include DedicatedSidekiqQueue - - BATCH_SIZE = 1000 - - def perform - CacheMarkdownField.caching_classes.each do |kls| - fields = kls.cached_markdown_fields.html_fields - clear_cache_fields = fields.each_with_object({}) do |field, memo| - memo[field] = nil - end - - Rails.logger.debug("Clearing Markdown cache for #{kls}: #{fields.inspect}") - - kls.unscoped.in_batches(of: BATCH_SIZE) do |relation| - relation.update_all(clear_cache_fields) - end - end - - nil - end -end diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb index eb403c134d1..7b59e976492 100644 --- a/app/workers/expire_build_instance_artifacts_worker.rb +++ b/app/workers/expire_build_instance_artifacts_worker.rb @@ -8,7 +8,7 @@ class ExpireBuildInstanceArtifactsWorker .reorder(nil) .find_by(id: build_id) - return unless build.try(:project) + return unless build&.project && !build.project.pending_delete Rails.logger.info "Removing artifacts for build #{build.id}..." build.erase_artifacts! diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb new file mode 100644 index 00000000000..603e2f1aaea --- /dev/null +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -0,0 +1,57 @@ +class ExpirePipelineCacheWorker + include Sidekiq::Worker + include PipelineQueue + + def perform(pipeline_id) + pipeline = Ci::Pipeline.find_by(id: pipeline_id) + return unless pipeline + + project = pipeline.project + store = Gitlab::EtagCaching::Store.new + + store.touch(project_pipelines_path(project)) + store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit + store.touch(new_merge_request_pipelines_path(project)) + each_pipelines_merge_request_path(project, pipeline) do |path| + store.touch(path) + end + + Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline) + end + + private + + def project_pipelines_path(project) + Gitlab::Routing.url_helpers.namespace_project_pipelines_path( + project.namespace, + project, + format: :json) + end + + def commit_pipelines_path(project, commit) + Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path( + project.namespace, + project, + commit.id, + format: :json) + end + + def new_merge_request_pipelines_path(project) + Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path( + project.namespace, + project, + format: :json) + end + + def each_pipelines_merge_request_path(project, pipeline) + pipeline.all_merge_requests.each do |merge_request| + path = Gitlab::Routing.url_helpers.pipelines_namespace_project_merge_request_path( + project.namespace, + project, + merge_request, + format: :json) + + yield(path) + end + end +end diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb new file mode 100644 index 00000000000..2f02235b0ac --- /dev/null +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -0,0 +1,31 @@ +class GitlabUsagePingWorker + LEASE_TIMEOUT = 86400 + + include Sidekiq::Worker + include CronjobQueue + include HTTParty + + def perform + return unless current_application_settings.usage_ping_enabled + + # Multiple Sidekiq workers could run this. We should only do this at most once a day. + return unless try_obtain_lease + + begin + HTTParty.post(url, + body: Gitlab::UsageData.to_json(force_refresh: true), + headers: { 'Content-type' => 'application/json' } + ) + rescue HTTParty::Error => e + Rails.logger.info "Unable to contact GitLab, Inc.: #{e}" + end + end + + def try_obtain_lease + Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain + end + + def url + 'https://version.gitlab.com/usage_data' + end +end diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index c9658b3fe17..22f67fa9e9f 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -142,10 +142,10 @@ class IrkerWorker end def files_count(commit) - diffs = commit.raw_diffs(deltas_only: true) + diff_size = commit.raw_deltas.size - files = "#{diffs.real_size} file" - files += 's' if diffs.size > 1 + files = "#{diff_size} file" + files += 's' if diff_size > 1 files end diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb new file mode 100644 index 00000000000..bfae0c77700 --- /dev/null +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -0,0 +1,43 @@ +# Worker to destroy projects that do not have a namespace +# +# It destroys everything it can without having the info about the namespace it +# used to belong to. Projects in this state should be rare. +# The worker will reject doing anything for projects that *do* have a +# namespace. For those use ProjectDestroyWorker instead. +class NamespacelessProjectDestroyWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def self.bulk_perform_async(args_list) + Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list) + end + + def perform(project_id) + begin + project = Project.unscoped.find(project_id) + rescue ActiveRecord::RecordNotFound + return + end + return unless project.namespace_id.nil? # Reject doing anything for projects that *do* have a namespace + + project.team.truncate + + unlink_fork(project) if project.forked? + + # Override Project#remove_pages for this instance so it doesn't do anything + def project.remove_pages + end + + project.destroy! + end + + private + + def unlink_fork(project) + merge_requests = project.forked_from_project.merge_requests.opened.from_project(project) + + merge_requests.update_all(state: 'closed') + + project.forked_project_link.destroy + end +end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb new file mode 100644 index 00000000000..7eb0e84acb2 --- /dev/null +++ b/app/workers/pipeline_schedule_worker.rb @@ -0,0 +1,25 @@ +class PipelineScheduleWorker + include Sidekiq::Worker + include CronjobQueue + + def perform + Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now) + .preload(:owner, :project).find_each do |schedule| + begin + unless schedule.runnable_by_owner? + schedule.deactivate! + next + end + + Ci::CreatePipelineService.new(schedule.project, + schedule.owner, + ref: schedule.ref) + .execute(save_on_errors: false, schedule: schedule) + rescue => e + Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}" + ensure + schedule.schedule_next_run! + end + end + end +end diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 015a41b6e82..c29571d3c62 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -2,34 +2,50 @@ class PostReceive include Sidekiq::Worker include DedicatedSidekiqQueue - def perform(repo_path, identifier, changes) - repo_relative_path = Gitlab::RepoPath.strip_storage_path(repo_path) + def perform(project_identifier, identifier, changes) + project, is_wiki = parse_project_identifier(project_identifier) + + if project.nil? + log("Triggered hook for non-existing project with identifier \"#{project_identifier}\"") + return false + end changes = Base64.decode64(changes) unless changes.include?(' ') # Use Sidekiq.logger so arguments can be correlated with execution # time and thread ID's. Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS'] - post_received = Gitlab::GitPostReceive.new(repo_relative_path, identifier, changes) + post_received = Gitlab::GitPostReceive.new(project, identifier, changes) - if post_received.project.nil? - log("Triggered hook for non-existing project with full path \"#{repo_relative_path}\"") - return false - end - - if post_received.wiki? + if is_wiki # Nothing defined here yet. - elsif post_received.regular_project? - process_project_changes(post_received) else - log("Triggered hook for unidentifiable repository type with full path \"#{repo_relative_path}\"") - false + process_project_changes(post_received) + process_repository_update(post_received) end end - def process_project_changes(post_received) - post_received.changes.each do |change| - oldrev, newrev, ref = change.strip.split(' ') + def process_repository_update(post_received) + changes = [] + refs = Set.new + post_received.changes_refs do |oldrev, newrev, ref| + @user ||= post_received.identify(newrev) + + unless @user + log("Triggered hook for non-existing user \"#{post_received.identifier}\"") + return false + end + + changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref) + refs << ref + end + + hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, @user, changes, refs.to_a) + SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks) + end + + def process_project_changes(post_received) + post_received.changes_refs do |oldrev, newrev, ref| @user ||= post_received.identify(newrev) unless @user @@ -47,6 +63,21 @@ class PostReceive private + # To maintain backwards compatibility, we accept both gl_repository or + # repository paths as project identifiers. Our plan is to migrate to + # gl_repository only with the following plan: + # 9.2: Handle both possible values. Keep Gitlab-Shell sending only repo paths + # 9.3 (or patch release): Make GitLab Shell pass gl_repository if present + # 9.4 (or patch release): Make GitLab Shell always pass gl_repository + # 9.5 (or patch release): Handle only gl_repository as project identifier on this method + def parse_project_identifier(project_identifier) + if project_identifier.start_with?('/') + Gitlab::RepoPath.parse(project_identifier) + else + Gitlab::GlRepository.parse(project_identifier) + end + end + def log(message) Gitlab::GitLogger.error("POST-RECEIVE: #{message}") end diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index e9a5bd7f24e..d6ed0e253ad 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -23,6 +23,9 @@ class ProcessCommitWorker return unless user commit = build_commit(project, commit_hash) + + return unless commit.matches_cross_reference_regex? + author = commit.author || user process_commit_message(project, commit, user, author, default) @@ -53,6 +56,8 @@ class ProcessCommitWorker def update_issue_metrics(commit, author) mentioned_issues = commit.all_references(author).issues + return if mentioned_issues.empty? + Issue::Metrics.where(issue_id: mentioned_issues.map(&:id), first_mentioned_in_commit_at: nil). update_all(first_mentioned_in_commit_at: commit.committed_date) end diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb new file mode 100644 index 00000000000..5ce0e0405d0 --- /dev/null +++ b/app/workers/propagate_service_template_worker.rb @@ -0,0 +1,21 @@ +# Worker for updating any project specific caches. +class PropagateServiceTemplateWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + LEASE_TIMEOUT = 4.hours.to_i + + def perform(template_id) + return unless try_obtain_lease_for(template_id) + + Projects::PropagateServiceTemplate.propagate(Service.find_by(id: template_id)) + end + + private + + def try_obtain_lease_for(template_id) + Gitlab::ExclusiveLease. + new("propagate_service_template_worker:#{template_id}", timeout: LEASE_TIMEOUT). + try_obtain + end +end diff --git a/app/workers/repository_check/clear_worker.rb b/app/workers/repository_check/clear_worker.rb index 1f1b38540ee..85bc9103538 100644 --- a/app/workers/repository_check/clear_worker.rb +++ b/app/workers/repository_check/clear_worker.rb @@ -8,7 +8,7 @@ module RepositoryCheck Project.select(:id).find_in_batches(batch_size: 100) do |batch| Project.where(id: batch.map(&:id)).update_all( last_repository_check_failed: nil, - last_repository_check_at: nil, + last_repository_check_at: nil ) end end diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb index 3d8bfc6fc6c..164586cf0b7 100644 --- a/app/workers/repository_check/single_repository_worker.rb +++ b/app/workers/repository_check/single_repository_worker.rb @@ -7,7 +7,7 @@ module RepositoryCheck project = Project.find(project_id) project.update_columns( last_repository_check_failed: !check(project), - last_repository_check_at: Time.now, + last_repository_check_at: Time.now ) end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index c8a77e21c12..b33ba2ed7c1 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -1,8 +1,9 @@ class RepositoryImportWorker include Sidekiq::Worker - include Gitlab::ShellAdapter include DedicatedSidekiqQueue + sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_EXPIRATION + attr_accessor :project, :current_user def perform(project_id) @@ -13,7 +14,7 @@ class RepositoryImportWorker import_url: @project.import_url, path: @project.path_with_namespace) - project.update_column(:import_error, nil) + project.update_columns(import_jid: self.jid, import_error: nil) result = Projects::ImportService.new(project, current_user).execute diff --git a/app/workers/schedule_update_user_activity_worker.rb b/app/workers/schedule_update_user_activity_worker.rb new file mode 100644 index 00000000000..6c2c3e437f3 --- /dev/null +++ b/app/workers/schedule_update_user_activity_worker.rb @@ -0,0 +1,10 @@ +class ScheduleUpdateUserActivityWorker + include Sidekiq::Worker + include CronjobQueue + + def perform(batch_size = 500) + Gitlab::UserActivities.new.each_slice(batch_size) do |batch| + UpdateUserActivityWorker.perform_async(Hash[batch]) + end + end +end diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb new file mode 100644 index 00000000000..bfc5e667bb6 --- /dev/null +++ b/app/workers/stuck_import_jobs_worker.rb @@ -0,0 +1,37 @@ +class StuckImportJobsWorker + include Sidekiq::Worker + include CronjobQueue + + IMPORT_EXPIRATION = 15.hours.to_i + + def perform + stuck_projects.find_in_batches(batch_size: 500) do |group| + jids = group.map(&:import_jid) + + # Find the jobs that aren't currently running or that exceeded the threshold. + completed_jids = Gitlab::SidekiqStatus.completed_jids(jids) + + if completed_jids.any? + completed_ids = group.select { |project| completed_jids.include?(project.import_jid) }.map(&:id) + + fail_batch!(completed_jids, completed_ids) + end + end + end + + private + + def stuck_projects + Project.select('id, import_jid').with_import_status(:started).where.not(import_jid: nil) + end + + def fail_batch!(completed_jids, completed_ids) + Project.where(id: completed_ids).update_all(import_status: 'failed', import_error: error_message) + + Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_jids.join(', ')}") + end + + def error_message + "Import timed out. Import took longer than #{IMPORT_EXPIRATION} seconds" + end +end diff --git a/app/workers/system_hook_worker.rb b/app/workers/system_hook_worker.rb index baf2f12eeac..55d4e7d6dab 100644 --- a/app/workers/system_hook_worker.rb +++ b/app/workers/system_hook_worker.rb @@ -2,6 +2,8 @@ class SystemHookWorker include Sidekiq::Worker include DedicatedSidekiqQueue + sidekiq_options retry: 4 + def perform(hook_id, data, hook_name) SystemHook.find(hook_id).execute(data, hook_name) end diff --git a/app/workers/update_user_activity_worker.rb b/app/workers/update_user_activity_worker.rb new file mode 100644 index 00000000000..b3c2f13aa33 --- /dev/null +++ b/app/workers/update_user_activity_worker.rb @@ -0,0 +1,26 @@ +class UpdateUserActivityWorker + include Sidekiq::Worker + include DedicatedSidekiqQueue + + def perform(pairs) + pairs = cast_data(pairs) + ids = pairs.keys + conditions = 'WHEN id = ? THEN ? ' * ids.length + + User.where(id: ids). + update_all([ + "last_activity_on = CASE #{conditions} ELSE last_activity_on END", + *pairs.to_a.flatten + ]) + + Gitlab::UserActivities.new.delete(*ids) + end + + private + + def cast_data(pairs) + pairs.each_with_object({}) do |(key, value), new_pairs| + new_pairs[key.to_i] = Time.at(value.to_i).to_s(:db) + end + end +end |