diff options
Diffstat (limited to 'app')
421 files changed, 6738 insertions, 4150 deletions
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 67106e85a37..ce426741637 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -51,7 +51,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"> 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/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..3d162b24413 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.disable(); + $form.submit(); + } +}); + +// 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..4c9ad128e6c 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -1,44 +1,43 @@ -/* 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) { + 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/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js new file mode 100644 index 00000000000..aa9a4e1c99a --- /dev/null +++ b/app/assets/javascripts/blob/blob_fork_suggestion.js @@ -0,0 +1,15 @@ +function BlobForkSuggestion(openButton, cancelButton, suggestionSection) { + if (openButton) { + openButton.addEventListener('click', () => { + suggestionSection.classList.remove('hidden'); + }); + } + + if (cancelButton) { + cancelButton.addEventListener('click', () => { + suggestionSection.classList.add('hidden'); + }); + } +} + +export default BlobForkSuggestion; diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index e057ac8df02..b749ef43cd3 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -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: { @@ -81,6 +85,7 @@ $(() => { if (list.type === 'closed') { list.position = Infinity; + list.label = { description: 'Shows all closed issues. Moving an issue to this list closes it' }; } }); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index a4629b092bf..e48d3344a2b 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -20,6 +20,7 @@ import eventHub from '../eventhub'; list: { type: Object, required: false, + default: () => ({}), }, rootPath: { type: String, @@ -31,6 +32,26 @@ import eventHub from '../eventhub'; default: false, }, }, + computed: { + cardUrl() { + return `${this.issueLinkBase}/${this.issue.id}`; + }, + assigneeUrl() { + return `${this.rootPath}${this.issue.assignee.username}`; + }, + assigneeUrlTitle() { + return `Assigned to ${this.issue.assignee.name}`; + }, + avatarUrlTitle() { + return `Avatar for ${this.issue.assignee.name}`; + }, + issueId() { + return `#${this.issue.id}`; + }, + showLabelFooter() { + return this.issue.labels.find(l => this.showLabel(l)) !== undefined; + }, + }, methods: { showLabel(label) { if (!this.list) return true; @@ -67,35 +88,41 @@ import eventHub from '../eventhub'; }, template: ` <div> - <h4 class="card-title"> - <i - class="fa fa-eye-slash confidential-icon" - v-if="issue.confidential"></i> - <a - :href="issueLinkBase + '/' + issue.id" - :title="issue.title"> - {{ issue.title }} - </a> - </h4> - <div class="card-footer"> - <span - class="card-number" - v-if="issue.id"> - #{{ issue.id }} - </span> + <div class="card-header"> + <h4 class="card-title"> + <i + class="fa fa-eye-slash confidential-icon" + v-if="issue.confidential" + aria-hidden="true" + /> + <a + class="js-no-trigger" + :href="cardUrl" + :title="issue.title">{{ issue.title }}</a> + <span + class="card-number" + v-if="issue.id" + > + {{ issueId }} + </span> + </h4> <a class="card-assignee has-tooltip js-no-trigger" - :href="rootPath + issue.assignee.username" - :title="'Assigned to ' + issue.assignee.name" + :href="assigneeUrl" + :title="assigneeUrlTitle" v-if="issue.assignee" - data-container="body"> + 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" /> + :alt="avatarUrlTitle" + /> </a> + </div> + <div class="card-footer" v-if="showLabelFooter"> <button class="label color-label has-tooltip js-no-trigger" v-for="label in issue.labels" diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index fe54ecffdfe..0aad95c2fe3 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -1,24 +1,28 @@ -/* 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'; +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() { +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.updateDropdown = bind(this.updateDropdown, this); + this.$body = $('body'); this.$buildTrace = $('#build-trace'); this.$autoScrollContainer = $('.autoscroll-container'); @@ -29,112 +33,112 @@ 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); + data: { + state: this.state, + }, + success: ((log) => { + const $buildContainer = $('.js-build-output'); + gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`); - if (window.location.hash === DOWN_BUILD_TRACE) { - $("html,body").scrollTop(this.$buildTrace.height()); + + if (log.state) { + this.state = log.state; } - if (removeRefreshStatuses.indexOf(buildData.status) !== -1) { + + if (log.append) { + $buildContainer.append(log.html); + } else { + $buildContainer.html(log.html); + if (log.truncated) { + $('.js-truncated-info-size').html(` ${log.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 @@ -146,7 +150,7 @@ window.Build = (function() { } }; - Build.prototype.initScrollButtonAffix = function() { + Build.prototype.initScrollButtonAffix = function () { // Hide everything initially this.$scrollTopBtn.hide(); this.$scrollBottomBtn.hide(); @@ -167,15 +171,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(); @@ -186,10 +192,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(); @@ -197,17 +206,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(); @@ -218,65 +232,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..1d16c64e07e 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -1,12 +1,14 @@ import Vue from 'vue'; +import Visibility from 'visibilityjs'; 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 EmptyState from '../../vue_pipelines_index/components/empty_state.vue'; +import ErrorState from '../../vue_pipelines_index/components/error_state.vue'; import '../../lib/utils/common_utils'; import '../../vue_shared/vue_resource_interceptor'; +import Poll from '../../lib/utils/poll'; /** * @@ -20,6 +22,7 @@ import '../../vue_shared/vue_resource_interceptor'; */ export default Vue.component('pipelines-table', { + components: { 'pipelines-table-component': PipelinesTableComponent, 'error-state': ErrorState, @@ -42,6 +45,7 @@ export default Vue.component('pipelines-table', { state: store.state, isLoading: false, hasError: false, + isMakingRequest: false, }; }, @@ -64,17 +68,41 @@ export default Vue.component('pipelines-table', { * */ beforeMount() { - this.endpoint = this.$el.dataset.endpoint; - this.helpPagePath = this.$el.dataset.helpPagePath; + const element = document.querySelector('#commit-pipeline-table-view'); + + this.endpoint = element.dataset.endpoint; + this.helpPagePath = element.dataset.helpPagePath; this.service = new PipelinesService(this.endpoint); - this.fetchPipelines(); + this.poll = new Poll({ + resource: this.service, + method: 'getPipelines', + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: this.setIsMakingRequest, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + this.poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); eventHub.$on('refreshPipelines', this.fetchPipelines); }, beforeUpdate() { - if (this.state.pipelines.length && this.$children) { + if (this.state.pipelines.length && + this.$children && + !this.isMakingRequest && + !this.isLoading) { this.store.startTimeAgoLoops.call(this, Vue); } }, @@ -83,21 +111,35 @@ export default Vue.component('pipelines-table', { 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(); + + // 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; + }, + + errorCallback() { + this.hasError = true; + this.isLoading = false; + }, + + setIsMakingRequest(isMakingRequest) { + this.isMakingRequest = isMakingRequest; }, }, 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/diff.js b/app/assets/javascripts/diff.js index 88180149715..5aa3eb46a69 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -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..eb76b7d15fd 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -42,10 +42,14 @@ import Vue from 'vue'; } }, created() { - this.discussion = CommentsStore.state[this.discussionId]; + if (this.discussionId) { + this.discussion = CommentsStore.state[this.discussionId]; + } }, mounted: function () { - const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); + 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', () => { @@ -53,7 +57,9 @@ import Vue from 'vue'; }); }, destroyed: function () { - $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn'); + if (!this.discussionId) return; + + $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off('input.comment-and-resolve-btn'); } }); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 9c7acc903d1..f277e1dddc7 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -24,7 +24,6 @@ /* global Search */ /* global Admin */ /* global NamespaceSelects */ -/* global ShortcutsDashboardNavigation */ /* global Project */ /* global ProjectAvatar */ /* global CompareAutocomplete */ @@ -38,12 +37,15 @@ import Issue from './issue'; import BindInOut from './behaviors/bind_in_out'; +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 BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; +import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -86,6 +88,12 @@ const ShortcutsBlob = require('./shortcuts_blob'); skipResetBindings: true, fileBlobPermalinkUrl, }); + + new BlobForkSuggestion( + document.querySelector('.js-edit-blob-link-fork-toggler'), + document.querySelector('.js-cancel-fork-suggestion'), + document.querySelector('.js-file-fork-suggestion-section'), + ); } switch (page) { @@ -264,8 +272,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(); @@ -323,8 +332,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(); @@ -371,7 +384,6 @@ const ShortcutsBlob = require('./shortcuts_blob'); break; case 'dashboard': case 'root': - shortcut_handler = new ShortcutsDashboardNavigation(); new UserCallout(); break; case 'groups': diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js new file mode 100644 index 00000000000..a23d914772a --- /dev/null +++ b/app/assets/javascripts/droplab/constants.js @@ -0,0 +1,11 @@ +const DATA_TRIGGER = 'data-dropdown-trigger'; +const DATA_DROPDOWN = 'data-dropdown'; +const SELECTED_CLASS = 'droplab-item-selected'; +const ACTIVE_CLASS = 'droplab-item-active'; + +export { + DATA_TRIGGER, + DATA_DROPDOWN, + SELECTED_CLASS, + ACTIVE_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..9588921ebcd --- /dev/null +++ b/app/assets/javascripts/droplab/drop_down.js @@ -0,0 +1,139 @@ +/* eslint-disable */ + +import utils from './utils'; +import { SELECTED_CLASS } from './constants'; + +var DropDown = function(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; +}; + +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) { + if (e.target.tagName === 'UL') return; + + var selected = utils.closest(e.target, 'LI'); + if (!selected) return; + + this.addSelectedClass(selected); + + e.preventDefault(); + this.hide(); + + var listEvent = new CustomEvent('click.dl', { + detail: { + list: this, + selected: selected, + data: e.target.dataset, + }, + }); + this.list.dispatchEvent(listEvent); + }, + + addSelectedClass: function (selected) { + this.removeSelectedClasses(); + selected.classList.add(SELECTED_CLASS); + }, + + removeSelectedClasses: function () { + const items = this.items || this.getItems(); + + items.forEach(item => item.classList.remove(SELECTED_CLASS)); + }, + + addEvents: function() { + this.eventWrapper.clickEvent = this.clickEvent.bind(this) + this.list.addEventListener('click', this.eventWrapper.clickEvent); + }, + + toggle: function() { + this.hidden ? this.show() : this.hide(); + }, + + setData: function(data) { + this.data = data; + this.render(data); + }, + + addData: function(data) { + this.data = (this.data || []).concat(data); + this.render(this.data); + }, + + render: function(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: function(data) { + var html = utils.t(this.templateString, data); + var template = document.createElement('div'); + + template.innerHTML = html; + this.setImagesSrc(template); + template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block'; + + return template.firstChild.outerHTML; + }, + + setImagesSrc: function(template) { + const images = [].slice.call(template.querySelectorAll('img[data-src]')); + + images.forEach((image) => { + image.src = image.getAttribute('data-src'); + image.removeAttribute('data-src'); + }); + }, + + show: function() { + if (!this.hidden) return; + this.list.style.display = 'block'; + this.currentIndex = 0; + this.hidden = false; + }, + + hide: function() { + if (this.hidden) return; + this.list.style.display = 'none'; + this.currentIndex = 0; + this.hidden = true; + }, + + toggle: function () { + this.hidden ? this.show() : this.hide(); + }, + + destroy: function() { + this.hide(); + this.list.removeEventListener('click', this.eventWrapper.clickEvent); + } +}); + +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..6eb9f314af7 --- /dev/null +++ b/app/assets/javascripts/droplab/drop_lab.js @@ -0,0 +1,152 @@ +/* eslint-disable */ + +import HookButton from './hook_button'; +import HookInput from './hook_input'; +import utils from './utils'; +import Keyboard from './keyboard'; +import { DATA_TRIGGER } from './constants'; + +var DropLab = function() { + this.ready = false; + this.hooks = []; + this.queuedData = []; + this.config = {}; + + this.eventWrapper = {}; +}; + +Object.assign(DropLab.prototype, { + loadStatic: function(){ + var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`)); + this.addHooks(dropdownTriggers); + }, + + addData: function () { + var args = [].slice.apply(arguments); + this.applyArgs(args, '_addData'); + }, + + setData: function() { + var args = [].slice.apply(arguments); + this.applyArgs(args, '_setData'); + }, + + destroy: function() { + this.hooks.forEach(hook => hook.destroy()); + this.hooks = []; + this.removeEvents(); + }, + + applyArgs: function(args, methodName) { + if (this.ready) return this[methodName].apply(this, args); + + 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) { + this.hooks.forEach((hook) => { + if (Array.isArray(trigger)) hook.list[methodName](trigger); + + if (hook.trigger.id === trigger) hook.list[methodName](data); + }); + }, + + addEvents: function() { + this.eventWrapper.documentClicked = this.documentClicked.bind(this) + document.addEventListener('click', this.eventWrapper.documentClicked); + }, + + documentClicked: function(e) { + let thisTag = e.target; + + if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL'); + if (utils.isDropDownParts(thisTag, this.hooks) || utils.isDropDownParts(e.target, this.hooks)) return; + + this.hooks.forEach(hook => hook.list.hide()); + }, + + removeEvents: function(){ + document.removeEventListener('click', this.eventWrapper.documentClicked); + }, + + changeHookList: function(trigger, list, plugins, config) { + const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger; + + + this.hooks.forEach((hook, i) => { + hook.list.list.dataset.dropdownActive = false; + + if (hook.trigger !== availableTrigger) return; + + hook.destroy(); + this.hooks.splice(i, 1); + this.addHook(availableTrigger, list, plugins, config); + }); + }, + + addHook: function(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: function(hooks, plugins, config) { + hooks.forEach(hook => this.addHook(hook, null, plugins, config)); + return this; + }, + + setConfig: function(obj){ + this.config = obj; + }, + + fireReady: function() { + const readyEvent = new CustomEvent('ready.dl', { + detail: { + dropdown: this, + }, + }); + document.dispatchEvent(readyEvent); + + this.ready = true; + }, + + init: function (hook, list, plugins, config) { + hook ? this.addHook(hook, list, plugins, config) : 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..2f840083571 --- /dev/null +++ b/app/assets/javascripts/droplab/hook.js @@ -0,0 +1,22 @@ +/* eslint-disable */ + +import DropDown from './drop_down'; + +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.id; +}; + +Object.assign(Hook.prototype, { + + addEvents: function(){}, + + constructor: Hook, +}); + +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..be8aead1303 --- /dev/null +++ b/app/assets/javascripts/droplab/hook_button.js @@ -0,0 +1,65 @@ +/* eslint-disable */ + +import Hook from './hook'; + +var HookButton = function(trigger, list, plugins, config) { + Hook.call(this, trigger, list, plugins, config); + + this.type = 'button'; + this.event = 'click'; + + this.eventWrapper = {}; + + this.addEvents(); + this.addPlugins(); +}; + +HookButton.prototype = Object.create(Hook.prototype); + +Object.assign(HookButton.prototype, { + addPlugins: function() { + this.plugins.forEach(plugin => plugin.init(this)); + }, + + clicked: function(e){ + var buttonEvent = new CustomEvent('click.dl', { + detail: { + hook: this, + }, + bubbles: true, + cancelable: true + }); + e.target.dispatchEvent(buttonEvent); + + this.list.toggle(); + }, + + addEvents: function(){ + this.eventWrapper.clicked = this.clicked.bind(this); + this.trigger.addEventListener('click', this.eventWrapper.clicked); + }, + + removeEvents: function(){ + this.trigger.removeEventListener('click', this.eventWrapper.clicked); + }, + + restoreInitialState: function() { + this.list.list.innerHTML = this.list.initialState; + }, + + removePlugins: function() { + this.plugins.forEach(plugin => plugin.destroy()); + }, + + destroy: function() { + this.restoreInitialState(); + + this.removeEvents(); + this.removePlugins(); + }, + + constructor: HookButton, +}); + + +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..05082334045 --- /dev/null +++ b/app/assets/javascripts/droplab/hook_input.js @@ -0,0 +1,119 @@ +/* eslint-disable */ + +import Hook from './hook'; + +var HookInput = function(trigger, list, plugins, config) { + Hook.call(this, trigger, list, plugins, config); + + this.type = 'input'; + this.event = 'input'; + + this.eventWrapper = {}; + + this.addEvents(); + this.addPlugins(); +}; + +Object.assign(HookInput.prototype, { + addPlugins: function() { + this.plugins.forEach(plugin => plugin.init(this)); + }, + + addEvents: function(){ + 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: function() { + 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: function(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: function(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: function(e) { + if (this.hasRemovedEvents) return; + + this.keyEvent(e, 'keyup.dl'); + }, + + keydown: function(e) { + if (this.hasRemovedEvents) return; + + this.keyEvent(e, 'keydown.dl'); + }, + + keyEvent: function(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: function() { + this.list.list.innerHTML = this.list.initialState; + }, + + removePlugins: function() { + this.plugins.forEach(plugin => plugin.destroy()); + }, + + destroy: function() { + 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..12afe53ed76 --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/ajax.js @@ -0,0 +1,65 @@ +/* eslint-disable */ + +const Ajax = { + _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.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; + } + 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); + }, config.onError).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..c149a33a1e9 --- /dev/null +++ b/app/assets/javascripts/droplab/utils.js @@ -0,0 +1,38 @@ +/* eslint-disable */ + +import { DATA_TRIGGER, DATA_DROPDOWN } from './constants'; + +const utils = { + toCamelCase(attr) { + return this.camelize(attr.split('-').slice(1).join(' ')); + }, + + t(s, d) { + for (const p in d) { + if (Object.prototype.hasOwnProperty.call(d, p)) { + s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]); + } + } + return s; + }, + + 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/environments/folder/environments_folder_view.js b/app/assets/javascripts/environments/folder/environments_folder_view.js index 8abbcf0c227..d2514593e3a 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.js +++ b/app/assets/javascripts/environments/folder/environments_folder_view.js @@ -31,12 +31,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, @@ -163,9 +157,6 @@ export default Vue.component('environment-folder-view', { :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" diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 3f041172ff3..59d6508fc02 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -55,14 +55,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 +81,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 +131,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..9126422b335 --- /dev/null +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -0,0 +1,87 @@ +import eventHub from '../event_hub'; + +export default { + name: 'RecentSearchesDropdownContent', + + props: { + items: { + type: Array, + required: 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> + <ul v-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..381c40c03d8 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -1,13 +1,13 @@ -require('./filtered_search_dropdown'); +import Filter from '~/droplab/plugins/filter'; -/* global droplabFilter */ +require('./filtered_search_dropdown'); (() => { class DropdownHint extends gl.FilteredSearchDropdown { constructor(droplab, dropdown, input, filter) { super(droplab, dropdown, input, filter); this.config = { - droplabFilter: { + Filter: { template: 'hint', filterFunction: gl.DropdownUtils.filterHint.bind(null, input), }, @@ -56,7 +56,7 @@ require('./filtered_search_dropdown'); renderContent() { const dropdownData = []; - [].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { + [].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( @@ -69,12 +69,12 @@ require('./filtered_search_dropdown'); } }); - this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config); + 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(); + this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init(); } } diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index b3dc3e502c5..6296965b911 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,7 +1,9 @@ -require('./filtered_search_dropdown'); +/* global Flash */ + +import Ajax from '~/droplab/plugins/ajax'; +import Filter from '~/droplab/plugins/filter'; -/* global droplabAjax */ -/* global droplabFilter */ +require('./filtered_search_dropdown'); (() => { class DropdownNonUser extends gl.FilteredSearchDropdown { @@ -9,13 +11,19 @@ require('./filtered_search_dropdown'); super(droplab, dropdown, input, filter); this.symbol = symbol; this.config = { - droplabAjax: { + 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: { + Filter: { filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input), + template: 'title', }, }; } @@ -29,13 +37,13 @@ require('./filtered_search_dropdown'); renderContent(forceShowList = false) { this.droplab - .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config); + .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(); + .addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init(); } } diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 04e2afad02f..38b5d315bcf 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -1,13 +1,15 @@ -require('./filtered_search_dropdown'); +/* global Flash */ + +import AjaxFilter from '~/droplab/plugins/ajax_filter'; -/* global droplabAjaxFilter */ +require('./filtered_search_dropdown'); (() => { class DropdownUser extends gl.FilteredSearchDropdown { constructor(droplab, dropdown, input, filter) { super(droplab, dropdown, input, filter); this.config = { - droplabAjaxFilter: { + AjaxFilter: { endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`, searchKey: 'search', params: { @@ -18,6 +20,11 @@ require('./filtered_search_dropdown'); }, 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 */ + }, }, }; } @@ -28,7 +35,7 @@ require('./filtered_search_dropdown'); } renderContent(forceShowList = false) { - this.droplab.changeHookList(this.hookId, this.dropdown, [droplabAjaxFilter], this.config); + this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config); super.renderContent(forceShowList); } @@ -56,7 +63,7 @@ require('./filtered_search_dropdown'); } init() { - this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init(); + this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init(); } } diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 432b0c0dfd2..6c5c20447f7 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -129,7 +129,9 @@ import FilteredSearchContainer from './container'; } }); - return values.join(' '); + return values + .map(value => value.trim()) + .join(' '); } static getSearchInput(filteredSearchInput) { 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_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index e7bf530d343..d58eeeebf81 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -4,7 +4,7 @@ class FilteredSearchDropdown { constructor(droplab, dropdown, input, filter) { this.droplab = droplab; - this.hookId = input && input.getAttribute('data-id'); + this.hookId = input && input.id; this.input = input; this.filter = filter; this.dropdown = dropdown; 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..ec481b9ef97 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -1,4 +1,4 @@ -/* global DropLab */ +import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; (() => { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 22352950452..b93a8f1d322 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,18 +1,56 @@ +/* global Flash */ + import FilteredSearchContainer from './container'; +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(); + let recentSearchesKey = 'issue-recent-searches'; + if (page === 'merge_requests') { + recentSearchesKey = 'merge-request-recent-searches'; + } + this.recentSearchesService = new RecentSearchesService(recentSearchesKey); + + // Fetch recent searches from localStorage + this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() + .catch(() => { + // eslint-disable-next-line no-new + new 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); + }); + if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); + this.recentSearchesRoot = new RecentSearchesRoot( + this.recentSearchesStore, + this.recentSearchesService, + document.querySelector('.js-filtered-search-history-dropdown'), + ); + this.recentSearchesRoot.init(); + this.bindEvents(); this.loadSearchParamsFromURL(); this.dropdownManager.setDropdown(); @@ -25,6 +63,10 @@ import FilteredSearchContainer from './container'; cleanup() { this.unbindEvents(); document.removeEventListener('beforeunload', this.cleanupWrapper); + + if (this.recentSearchesRoot) { + this.recentSearchesRoot.destroy(); + } } bindEvents() { @@ -34,7 +76,7 @@ import FilteredSearchContainer from './container'; 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.onClearSearchWrapper = this.onClearSearch.bind(this); this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); @@ -42,8 +84,8 @@ import FilteredSearchContainer from './container'; 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.filteredSearchInputForm = this.filteredSearchInput.form; this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); @@ -56,11 +98,12 @@ import FilteredSearchContainer from './container'; this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); - this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); + 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.removeSelectedTokenWrapper); + eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); } unbindEvents() { @@ -76,11 +119,12 @@ import FilteredSearchContainer from './container'; this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); - this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); + 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.removeSelectedTokenWrapper); + eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); } checkForBackspace(e) { @@ -110,7 +154,7 @@ import FilteredSearchContainer from './container'; if (e.keyCode === 13) { const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; const dropdownEl = dropdown.element; - const activeElements = dropdownEl.querySelectorAll('.dropdown-active'); + const activeElements = dropdownEl.querySelectorAll('.droplab-item-active'); e.preventDefault(); @@ -131,7 +175,7 @@ import FilteredSearchContainer from './container'; } addInputContainerFocus() { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + const inputContainer = this.filteredSearchInput.closest('.filtered-search-box'); if (inputContainer) { inputContainer.classList.add('focus'); @@ -139,7 +183,7 @@ import FilteredSearchContainer from './container'; } removeInputContainerFocus(e) { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + 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; @@ -161,7 +205,7 @@ import FilteredSearchContainer from './container'; } unselectEditTokens(e) { - const inputContainer = this.container.querySelector('.filtered-search-input-container'); + 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'); @@ -215,9 +259,12 @@ import FilteredSearchContainer from './container'; } } - clearSearch(e) { + onClearSearch(e) { e.preventDefault(); + this.clearSearch(); + } + clearSearch() { this.filteredSearchInput.value = ''; const removeElements = []; @@ -289,6 +336,17 @@ import FilteredSearchContainer from './container'; 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); + } + }); + } + loadSearchParamsFromURL() { const params = gl.utils.getUrlParamsArray(); const usernameParams = this.getUsernameParams(); @@ -343,6 +401,8 @@ import FilteredSearchContainer from './container'; } }); + this.saveCurrentSearchQuery(); + if (hasFilteredSearch) { this.clearSearchButton.classList.remove('hidden'); this.handleInputPlaceholder(); @@ -351,8 +411,12 @@ import FilteredSearchContainer from './container'; search() { const paths = []; + const searchQuery = gl.DropdownUtils.getSearchQuery(); + + this.saveCurrentSearchQuery(); + const { tokens, searchToken } - = this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery()); + = this.tokenizer.processTokens(searchQuery); const currentState = gl.utils.getParameterByName('state') || 'opened'; paths.push(`state=${currentState}`); @@ -416,6 +480,13 @@ import FilteredSearchContainer from './container'; currentDropdownRef.dispatchInputEvent(); } } + + onrecentSearchesItemSelected(text) { + this.clearSearch(); + this.filteredSearchInput.value = text; + this.filteredSearchInput.dispatchEvent(new CustomEvent('input')); + this.search(); + } } window.gl = window.gl || {}; 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..4e38409e12a --- /dev/null +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -0,0 +1,59 @@ +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() { + this.vm = new Vue({ + el: this.wrapperElement, + data: this.store.state, + template: ` + <recent-searches-dropdown-content + :items="recentSearches" /> + `, + 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..3e402d5aed0 --- /dev/null +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -0,0 +1,26 @@ +class RecentSearchesService { + constructor(localStorageKey = 'issuable-recent-searches') { + this.localStorageKey = localStorageKey; + } + + fetch() { + 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 = []) { + window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches)); + } +} + +export default RecentSearchesService; 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..066be69766a --- /dev/null +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -0,0 +1,23 @@ +import _ from 'underscore'; + +class RecentSearchesStore { + constructor(initialState = {}) { + this.state = Object.assign({ + 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..b62b2cec4d8 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -50,7 +50,7 @@ window.gl.GfmAutoComplete = { template: '<li>${title}</li>' }, Loading: { - template: '<li style="pointer-events: none;"><i class="fa fa-refresh fa-spin"></i> Loading...</li>' + template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>' }, DefaultOptions: { sorter: function(query, items, searchKey) { diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index e7c98e16581..ff10f19a4fe 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -29,7 +29,8 @@ 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.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion')); + gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); 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/issue.js b/app/assets/javascripts/issue.js index 47e675f537e..011043e992f 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -20,57 +20,60 @@ class Issue { }); Issue.initIssueBtnEventListeners(); } + + Issue.$btnNewBranch = $('#new-branch'); + Issue.initMergeRequests(); Issue.initRelatedBranches(); Issue.initCanCreateBranch(); } 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; + 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', function(e) { + var $this, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); $this = $(this); - isClose = $this.hasClass('btn-close'); shouldSubmit = $this.hasClass('btn-comment'); if (shouldSubmit) { Issue.submitNoteForm($this.closest('form')); } $this.prop('disabled', true); + Issue.setNewBranchButtonState(true, null); url = $this.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; - } 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; - } - $('.issue_counter').text(gl.text.addDelimiter(total)); - } else { - new Flash(issueFailMessage, 'alert'); - } - return $this.prop('disabled', false); + url: url + }).fail(function(jqXHR, textStatus, errorThrown) { + new Flash(issueFailMessage); + Issue.initCanCreateBranch(); + }).done(function(data, textStatus, jqXHR) { + if ('id' in data) { + $(document).trigger('issuable:change'); + + const isClosed = $this.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)); + } else { + new Flash(issueFailMessage); } + + $this.prop('disabled', false); + Issue.initCanCreateBranch(); }); }); } @@ -86,9 +89,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,9 +101,9 @@ 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); } @@ -108,24 +111,27 @@ class Issue { } 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) { + if (Issue.$btnNewBranch.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(); - } + return $.getJSON(Issue.$btnNewBranch.data('path')).fail(function() { + Issue.setNewBranchButtonState(false, false); + new Flash('Failed to check if a new branch can be created.'); + }).done(function(data) { + Issue.setNewBranchButtonState(false, data.can_create_branch); }); } + + static setNewBranchButtonState(isPending, canCreate) { + if (Issue.$btnNewBranch.length === 0) { + return; + } + + Issue.$btnNewBranch.find('.available').toggle(!isPending && canCreate); + Issue.$btnNewBranch.find('.unavailable').toggle(!isPending && !canCreate); + } } export default Issue; 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/main.js b/app/assets/javascripts/main.js index 5b50bc62876..c50ec24c818 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'; @@ -75,12 +68,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'; @@ -285,7 +272,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/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 3c4e6102469..f7f6a773036 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -3,9 +3,6 @@ /* global Flash */ import Cookies from 'js-cookie'; - -import CommitPipelinesTable from './commit/pipelines/pipelines_table'; - import './breakpoints'; import './flash'; @@ -90,6 +87,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 +97,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 +128,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 +137,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 +230,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) { diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index ac4fad88fe5..773fe3233a7 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) { @@ -151,12 +149,12 @@ import Vue from 'vue'; 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({ + 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'); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 1d563c63f39..15f7a813626 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -5,6 +5,7 @@ /* global mrRefreshWidgetUrl */ import Cookies from 'js-cookie'; +import CommentTypeToggle from './comment_type_toggle'; require('./autosave'); window.autosize = require('vendor/autosize'); @@ -110,7 +111,6 @@ require('./task_list'); $(document).on("visibilitychange", this.visibilityChange); // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); - // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; @@ -137,6 +137,26 @@ require('./task_list'); $(document).off("click", '.system-note-commit-list-toggler'); }; + 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) { var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; if (gl.utils.isMetaKey(e)) { @@ -192,7 +212,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 +233,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) @@ -276,8 +292,12 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderNote = function(note) { + Notes.prototype.renderNote = function(note, $form) { var $notesList; + if (note.discussion_html != null) { + return this.renderDiscussionNote(note, $form); + } + if (!note.valid) { if (note.errors.commands_only) { new Flash(note.errors.commands_only, 'notice', this.parentTimeline); @@ -317,61 +337,50 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderDiscussionNote = function(note) { - var discussionContainer, form, note_html, row, lineType, diffAvatarContainer; + Notes.prototype.renderDiscussionNote = function(note, $form) { + var discussionContainer, form, row, lineType, diffAvatarContainer; if (!this.isNewNote(note)) { 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); - } + form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.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 + "']"); + 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 (note.diff_discussion_html) { + var $discussion = $(note.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="' + note.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(); + if ($('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) { + $('ul.main-notes-list').append($(note.discussion_html).renderGFM()); } } else { // append new note to all matching discussions - discussionContainer.append(note_html); + discussionContainer.append($(note.html).renderGFM()); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_id) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) { gl.diffNotesCompileComponents(); this.renderDiscussionAvatar(diffAvatarContainer, note); } @@ -455,9 +464,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 +484,24 @@ require('./task_list'); */ Notes.prototype.setupNoteForm = function(form) { - var textarea; + var textarea, key; new gl.GLForm(form); 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); }; /* @@ -510,7 +538,7 @@ require('./task_list'); } } - this.renderDiscussionNote(note); + this.renderNote(note, $form); // cleanup after successfully creating a diff/discussion note this.removeDiscussionNoteForm($form); }; @@ -656,7 +684,7 @@ require('./task_list'); return function(i, el) { var note, notes; note = $(el); - notes = note.closest(".notes"); + notes = note.closest(".discussion-notes"); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -673,14 +701,13 @@ require('./task_list'); // "Discussions" tab 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 @@ -711,7 +738,7 @@ require('./task_list'); Notes.prototype.replyToDiscussionNote = function(e) { var form, replyLink; - form = this.formClone.clone(); + form = this.cleanForm(this.formClone.clone()); replyLink = $(e.target).closest(".js-discussion-reply-button"); // insert the form after the button replyLink @@ -727,29 +754,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 +799,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); }; /* @@ -823,7 +862,7 @@ require('./task_list'); } if (addForm) { - newForm = this.formClone.clone(); + newForm = this.cleanForm(this.formClone.clone()); newForm.appendTo(notesContent); // show the form return this.setupDiscussionNoteForm($link, newForm); @@ -900,9 +939,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); } @@ -1009,6 +1049,20 @@ require('./task_list'); }); }; + 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; + }; + return Notes; })(); }).call(window); 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..fff83f3af3b --- /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(item, $el, e) { + 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..5ff4e443262 --- /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: (item, $el, e) => { + 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/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/shortcuts.js b/app/assets/javascripts/shortcuts.js index fd5097696ad..5b6bb2bf3f5 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -1,6 +1,7 @@ /* 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); }; }; @@ -14,11 +15,33 @@ } 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() { 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_navigation.js b/app/assets/javascripts/shortcuts_navigation.js index 3f5d6724417..c74ab0afd0c 100644 --- a/app/assets/javascripts/shortcuts_navigation.js +++ b/app/assets/javascripts/shortcuts_navigation.js @@ -1,6 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-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 */ +import findAndFollowLink from './shortcuts_dashboard_navigation'; require('./shortcuts'); @@ -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/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/users_select.js b/app/assets/javascripts/users_select.js index 48e20cf501f..3325a7d429c 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -2,8 +2,6 @@ /* global Issuable */ /* global ListUser */ -import Vue from 'vue'; - (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, slice = [].slice; @@ -74,7 +72,7 @@ import Vue from 'vue'; e.preventDefault(); if ($dropdown.hasClass('js-issue-board-sidebar')) { - Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({ + gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ id: _this.currentUser.id, username: _this.currentUser.username, name: _this.currentUser.name, @@ -225,14 +223,14 @@ import Vue from 'vue'; 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({ + gl.issueBoards.boardStoreIssueSet('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'); + gl.issueBoards.boardStoreIssueDelete('assignee'); } updateIssueBoardsIssue(); diff --git a/app/assets/javascripts/vue_pipelines_index/components/async_button.js b/app/assets/javascripts/vue_pipelines_index/components/async_button.vue index 58b8db4d519..11da6e908b7 100644 --- a/app/assets/javascripts/vue_pipelines_index/components/async_button.js +++ b/app/assets/javascripts/vue_pipelines_index/components/async_button.vue @@ -1,3 +1,4 @@ +<script> /* eslint-disable no-new, no-alert */ /* global Flash */ import '~/flash'; @@ -65,29 +66,31 @@ export default { this.isLoading = true; 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"></i> + <i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading"></i> + </button> +</template> 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/empty_state.vue b/app/assets/javascripts/vue_pipelines_index/components/empty_state.vue new file mode 100644 index 00000000000..ba158bc4a1e --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/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"> + <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/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/error_state.vue b/app/assets/javascripts/vue_pipelines_index/components/error_state.vue new file mode 100644 index 00000000000..90cee68163e --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/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/vue_pipelines_index/pipelines.js b/app/assets/javascripts/vue_pipelines_index/pipelines.js index 9bdc232b7da..6eea4812f33 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipelines.js +++ b/app/assets/javascripts/vue_pipelines_index/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 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 Poll from '../lib/utils/poll'; export default { props: { @@ -47,6 +49,7 @@ export default { pagenum: 1, isLoading: false, hasError: false, + isMakingRequest: false, }; }, @@ -120,18 +123,49 @@ 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(); + 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, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); eventHub.$on('refreshPipelines', this.fetchPipelines); }, beforeUpdate() { - if (this.state.pipelines.length && this.$children) { + if (this.state.pipelines.length && + this.$children && + !this.isMakingRequest && + !this.isLoading) { this.store.startTimeAgoLoops.call(this, Vue); } }, @@ -154,27 +188,35 @@ 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; + }, + + errorCallback() { + this.hasError = true; + this.isLoading = false; + }, + + setIsMakingRequest(isMakingRequest) { + this.isMakingRequest = isMakingRequest; }, }, diff --git a/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js b/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js index 708f5068dd3..255cd513490 100644 --- a/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js +++ b/app/assets/javascripts/vue_pipelines_index/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 }); } 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..8ebe12cb1c5 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ -import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button'; +import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button.vue'; 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'; 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/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 2ede47e9de6..7767826b033 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -177,10 +177,6 @@ border-radius: $border-radius-base; box-shadow: 0 2px 4px $dropdown-shadow-color; - .filtered-search-input-container & { - max-width: 280px; - } - &.is-loading { .dropdown-content { display: none; @@ -191,6 +187,15 @@ } } + .shortcut-mappings { + display: none; + } + + &.shortcuts .shortcut-mappings { + display: inline-block; + margin-right: 5px; + } + ul { margin: 0; padding: 0; @@ -467,6 +472,11 @@ overflow-y: auto; } +.dropdown-info-note { + color: $gl-text-color-secondary; + text-align: center; +} + .dropdown-footer { padding-top: 10px; margin-top: 10px; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index ddea1cf540b..a5a8522739e 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -281,3 +281,16 @@ span.idiff { 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..11d44df4867 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; } } @@ -151,11 +150,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 +164,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 +222,116 @@ } } +.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; + padding-right: 10px; + + border-radius: 0; + border-top: 0; + border-left: 0; + border-bottom: 0; + border-right: 1px solid $border-color; + + color: $gl-text-color-secondary; + line-height: 1; + + transition: color 0.1s linear; + + &:hover, + &:focus { + color: $gl-text-color; + border-color: $dropdown-input-focus-border; + outline: none; + } + + .dropdown-toggle-text { + display: inline-block; + color: inherit; + + .fa { + vertical-align: middle; + color: inherit; + } + } + + .fa { + position: static; + } + +} + +.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 +351,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 +444,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/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index ff185cd8767..cd23deb6d75 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -1,15 +1,18 @@ .timeline { @include basic-list; - margin: 0; padding: 0; .timeline-entry { - padding: $gl-padding $gl-btn-padding 11px; + padding: $gl-padding $gl-btn-padding 14px; border-color: $white-normal; color: $gl-text-color; border-bottom: 1px solid $border-white-light; + .timeline-entry-inner { + position: relative; + } + &:target { background: $line-target-blue; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 7c0fc1008d0..0be1c215959 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -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; @@ -217,6 +217,8 @@ } .confidential-icon { + position: relative; + top: 1px; margin-right: 5px; } } @@ -224,34 +226,43 @@ .card-title { margin: 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-left: auto; margin-right: 5px; + padding-left: 10px; + height: 20px; } .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..144adbcdaef 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,37 @@ margin-right: 5px; } } + + .truncated-info { + text-align: center; + border-bottom: 1px solid; + background-color: $black-transparent; + height: 45px; + + &.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; + } + } } .scroll-controls { @@ -186,8 +217,9 @@ white-space: pre; overflow-x: auto; font-size: 12px; + position: relative; - .fa-refresh { + .fa-spinner { font-size: 24px; } @@ -334,7 +366,7 @@ background-color: $row-hover; } - .fa-refresh { + .fa-spinner { font-size: 13px; margin-left: 3px; } 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/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/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e84a05e3e9e..0bca3e93e4c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -196,6 +196,7 @@ transition: width .3s; background: $gray-light; padding: 10px 20px; + z-index: 2; &.right-sidebar-expanded { width: $gutter_width; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 2f946ab2f59..6a419384a34 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -523,11 +523,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; } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 927bf9805ce..b637994adf8 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -310,3 +310,94 @@ 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%; + + .comment-btn { + flex-grow: 1; + flex-shrink: 0; + width: auto; + } + + .dropdown-toggle { + flex-grow: 0; + flex-shrink: 1; + width: auto; + } + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 603ef461ffe..ad0f2f6efbb 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,112 @@ 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; + + &.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; + } + } + } + } + .system-note { font-size: 14px; padding: 0; clear: both; + @media (min-width: $screen-sm-min) { + margin-left: 65px; + } + &.timeline-entry::after { clear: none; } @@ -66,6 +176,14 @@ ul.notes { .timeline-content { padding: 14px 10px; + + @media (min-width: $screen-sm-min) { + margin-left: 20px; + } + } + + .note-header { + padding-bottom: 0; } .note-body { @@ -130,116 +248,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; - } - } - } - } } @@ -294,6 +302,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; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 0fa1f68e034..717ebb44a23 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -744,7 +744,8 @@ pre.light-well { text-align: left; } -.protected-branches-list { +.protected-branches-list, +.protected-tags-list { margin-bottom: 30px; a { @@ -776,6 +777,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 { 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/groups_controller.rb b/app/controllers/admin/groups_controller.rb index cea3d088e94..f28bbdeff5a 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -72,7 +72,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/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/application_controller.rb b/app/controllers/application_controller.rb index 6a6e335d314..e77094fe2a8 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 @@ -151,12 +151,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,23 +259,6 @@ 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 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/renders_notes.rb b/app/controllers/concerns/renders_notes.rb new file mode 100644 index 00000000000..dd21066ac13 --- /dev/null +++ b/app/controllers/concerns/renders_notes.rb @@ -0,0 +1,20 @@ +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) + 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/groups_controller.rb b/app/controllers/groups_controller.rb index 78c9f1f7004..593001e6396 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -151,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 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..df0fc3132ed --- /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/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/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 80a95c6158b..73706bf8dae 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController # 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] @@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController 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 diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 3f3c90a49ab..04e8cdf6256 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -19,6 +19,11 @@ class Projects::BuildsController < Projects::ApplicationController else @builds end + @builds = @builds.includes([ + { pipeline: :project }, + :project, + :tags + ]) @builds = @builds.page(params[:page]).per(30) end @@ -31,25 +36,25 @@ 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 @@ -86,10 +91,12 @@ class Projects::BuildsController < Projects::ApplicationController 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 diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index cc67f688d51..d25bbddd1bb 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,6 +36,8 @@ 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) .represent(@pipelines) @@ -111,22 +114,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/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/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index b668a9331e7..1e41f980f31 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -10,7 +10,7 @@ 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 @@ -49,7 +49,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 a50e16fa4ff..cbf67137261 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 @@ -84,15 +84,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 diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a79d801991a..09dc8b38229 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -3,7 +3,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController include DiffForPath include DiffHelper include IssuableActions - include NotesHelper + include RendersNotes include ToggleAwardEmoji include IssuableCollections @@ -16,7 +16,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController 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_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 :apply_diff_view_cookie!, only: [:new_diffs] @@ -39,7 +38,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController @collection_type = "MergeRequest" @merge_requests = merge_requests_collection @merge_requests = @merge_requests.page(params[:page]) - @merge_requests = @merge_requests.includes(merge_request_diff: :merge_request) + @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 @@ -101,34 +100,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 @@ -140,16 +116,17 @@ 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 + @diffs = @merge_request.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 @@ -233,6 +210,8 @@ 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) .represent(@pipelines) @@ -246,6 +225,8 @@ 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) @@ -452,7 +433,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController if pipeline status = pipeline.status - coverage = pipeline.try(:coverage) + coverage = pipeline.coverage status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings? @@ -570,20 +551,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) + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end def define_widget_vars @@ -595,23 +563,47 @@ 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 + + @diffs = + if @start_sha + @merge_request_diff.compare_with(@start_sha).diffs(diff_options) + else + @merge_request_diff.diffs(diff_options) + end + end + def define_diff_comment_vars - @comments_target = { + @new_diff_note_attrs = { noteable_type: 'MergeRequest', noteable_id: @merge_request.id } + @diff_notes_disabled = !@merge_request_diff.latest? || @start_sha + @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(@merge_request_diff.diff_refs) + @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) end def define_pipelines_vars @@ -694,16 +686,6 @@ 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) - end - - def original_diff_version - @diff_notes_disabled = !@merge_request_diff.latest? - @diffs = @merge_request_diff.diffs(diff_options) - end - def close_merge_request_without_source_project if !@merge_request.source_project && @merge_request.open? @merge_request.close diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index d00177e7612..405ea3c0a4f 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,4 +1,5 @@ class Projects::NotesController < Projects::ApplicationController + include RendersNotes include ToggleAwardEmoji # Authorize @@ -6,13 +7,15 @@ class Projects::NotesController < Projects::ApplicationController 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 = 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) @@ -23,7 +26,10 @@ class Projects::NotesController < Projects::ApplicationController end def create - create_params = note_params.merge(merge_request_diff_head_sha: params[:merge_request_diff_head_sha]) + 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) @@ -111,6 +117,17 @@ class Projects::NotesController < Projects::ApplicationController ) end + def discussion_html(discussion) + return if discussion.individual_note? + + render_to_string( + "discussions/_discussion", + layout: false, + formats: [:html], + locals: { discussion: discussion } + ) + end + def diff_discussion_html(discussion) return unless discussion.diff_discussion? @@ -118,13 +135,13 @@ class Projects::NotesController < Projects::ApplicationController template = "discussions/_parallel_diff_discussion" locals = if params[:line_type] == 'old' - { discussion_left: discussion, discussion_right: nil } + { discussions_left: [discussion], discussions_right: nil } else - { discussion_left: nil, discussion_right: discussion } + { discussions_left: nil, discussions_right: [discussion] } end else template = "discussions/_diff_discussion" - locals = { discussion: discussion } + locals = { discussions: [discussion] } end render_to_string( @@ -135,54 +152,28 @@ class Projects::NotesController < Projects::ApplicationController ) 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 + commands_changes: note.commands_changes } if note.persisted? - Banzai::NoteRenderer.render([note], @project, current_user) - attrs.merge!( valid: true, - discussion_id: note.discussion_id, + id: note.id, + discussion_id: note.discussion_id(noteable), html: note_html(note), note: note.note ) - if note.diff_note? - discussion = note.to_discussion - + 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) ) - - # 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!( @@ -191,7 +182,6 @@ class Projects::NotesController < Projects::ApplicationController ) end - attrs[:commands_changes] = note.commands_changes attrs end @@ -205,14 +195,30 @@ class Projects::NotesController < Projects::ApplicationController def note_params params.require(:note).permit( - :note, :noteable, :noteable_id, :noteable_type, :project_id, - :attachment, :line_code, :commit_id, :type, :position + :project_id, + :noteable_type, + :noteable_id, + :commit_id, + :noteable, + :type, + + :note, + :attachment, + + # LegacyDiffNote + :line_code, + + # DiffNote + :position ) end - def find_current_user_notes - @notes = NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) - .execute.inc_author + def notes_finder + @notes_finder ||= NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) + end + + def noteable + @noteable ||= notes_finder.target end def last_fetched_at diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 43a1abaa662..1780cc0233c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -29,6 +29,8 @@ class Projects::PipelinesController < Projects::ApplicationController respond_to do |format| format.html format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + render json: { pipelines: PipelineSerializer .new(project: @project, user: @current_user) @@ -114,7 +116,7 @@ class Projects::PipelinesController < Projects::ApplicationController 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..ff50602831c 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -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/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/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/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..5c9e0d4d1a1 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -1,4 +1,5 @@ class Projects::SnippetsController < Projects::ApplicationController + include RendersNotes include ToggleAwardEmoji include SpammableActions include SnippetsActions @@ -55,8 +56,10 @@ class Projects::SnippetsController < Projects::ApplicationController def show @note = @project.notes.new(noteable: @snippet) - @notes = Banzai::NoteRenderer.render(@snippet.notes.fresh, @project, current_user) @noteable = @snippet + + @discussions = @snippet.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end def destroy 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_controller.rb b/app/controllers/projects_controller.rb index 47f7e0b1b28..6807c37f972 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -345,7 +345,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 +363,8 @@ 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 end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d8561871098..d3091a4f8e9 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, diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 6630c6384f2..3c499184b41 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 @@ -69,17 +86,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 +98,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/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 101fe579da2..9c71d6c7f4c 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -64,18 +64,6 @@ 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 - end - def unlink_allowed?(provider) %w(saml cas3).exclude?(provider.to_s) end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 8631bc54509..6c3f3a61e0a 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 fork_path(project = @project, ref = @ref, path = @path, options = {}) + continue_params = { + to: edit_path, + notice: edit_in_new_fork_notice, + 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 + + 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 - 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) - continue_params = { - to: edit_path, - 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} 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_edit_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) + button_tag 'Edit', class: "#{common_classes} js-edit-blob-link-fork-toggler" end end @@ -97,7 +102,7 @@ module BlobHelper if Gitlab::MarkupHelper.previewable?(filename) 'Preview' else - 'Preview Changes' + 'Preview changes' end end @@ -113,6 +118,10 @@ module BlobHelper blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw? end + def blob_rendered_as_text?(blob) + blob_text_viewable?(blob) && blob.to_partial_path(@project) == 'text' + end + def blob_size(blob) if blob.lfs_pointer? blob.lfs_size @@ -205,13 +214,13 @@ 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_content_button(blob) return if markup?(blob.name) - clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard") + clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard") end def open_raw_file_button(path) diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index 3fc85dc6b2b..b7a28b1b4a7 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -1,6 +1,6 @@ module BranchesHelper def can_remove_branch?(project, branch_name) - if project.protected_branch? branch_name + if ProtectedBranch.protected?(project, branch_name) false elsif branch_name == project.repository.root_ref false @@ -29,4 +29,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/button_helper.rb b/app/helpers/button_helper.rb index 0b30471f2ae..c85e96cf78d 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -1,23 +1,42 @@ 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}", diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index aed1d7c839f..dc144906548 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 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/events_helper.rb b/app/helpers/events_helper.rb index fb872a13f74..5f5c76d3722 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,4 +1,15 @@ module EventsHelper + 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) author = event.author @@ -183,4 +194,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/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/notes_helper.rb b/app/helpers/notes_helper.rb index b0331f36a2f..eab0738a368 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -24,57 +24,24 @@ module NotesHelper 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, } - 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,34 @@ 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) - end - - def preload_noteable_for_regular_notes(notes) - ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable) - end - def note_max_access_for_user(note) note.project.team.human_max_access(note.author_id) end def discussion_diff_path(discussion) - return unless discussion.diff_discussion? - - if discussion.for_merge_request? && discussion.active? - diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) + if discussion.for_merge_request? && discussion.diff_discussion? + if discussion.active? + # Without a diff ID, the link always points to the latest diff version + diff_id = nil + elsif merge_request_diff = discussion.latest_merge_request_diff + diff_id = merge_request_diff.id + else + # If the discussion is not active, and we cannot find the latest + # merge request diff for this discussion, we return no path at all. + return + end + + diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, diff_id: diff_id, anchor: discussion.line_code) elsif discussion.for_commit? - namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) + anchor = discussion.line_code if discussion.diff_discussion? + + namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor) 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/system_note_helper.rb b/app/helpers/system_note_helper.rb new file mode 100644 index 00000000000..1ea60e39386 --- /dev/null +++ b/app/helpers/system_note_helper.rb @@ -0,0 +1,26 @@ +module SystemNoteHelper + ICON_NAMES_BY_ACTION = { + 'commit' => 'icon_commit', + '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/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/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/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 801d3442803..55872acef51 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -42,12 +42,16 @@ class Blob < SimpleDelegator size && truncated? end + def extension + extname.downcase.delete('.') + end + def svg? text? && language && language.name == 'SVG' end def pdf? - name && File.extname(name) == '.pdf' + extension == 'pdf' end def ipython_notebook? @@ -55,11 +59,15 @@ class Blob < SimpleDelegator end def sketch? - binary? && extname.downcase.delete('.') == 'sketch' + binary? && extension == 'sketch' end def stl? - extname.downcase.delete('.') == 'stl' + extension == 'stl' + end + + def markup? + text? && Gitlab::MarkupHelper.markup?(name) end def size_within_svg_limits? @@ -77,8 +85,10 @@ class Blob < SimpleDelegator else 'text' end - elsif image? || svg? + elsif image? 'image' + elsif svg? + 'svg' elsif pdf? 'pdf' elsif ipython_notebook? @@ -87,8 +97,18 @@ class Blob < SimpleDelegator 'sketch' elsif stl? 'stl' + elsif markup? + if only_display_raw? + 'too_large' + else + 'markup' + end elsif text? - 'text' + if only_display_raw? + 'too_large' + else + 'text' + end else 'download' end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index b3acb25b9ce..971ab7cb0ee 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -103,18 +103,13 @@ 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) Ci::PlayBuildService .new(project, current_user) @@ -126,8 +121,7 @@ module Ci end def retryable? - project.builds_enabled? && has_commands? && - (success? || failed? || canceled?) + success? || failed? || canceled? end def retried? @@ -166,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 @@ -239,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) + trace.exist? 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 + def trace=(data) + raise NotImplementedError end - def trace=(trace) - recreate_trace_dir - trace = hide_secrets(trace) - File.write(path_to_trace, trace) + def old_trace + read_attribute(:trace) end - def recreate_trace_dir - unless Dir.exist?(dir_to_trace) - FileUtils.mkdir_p(dir_to_trace) - end - 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 # @@ -550,6 +430,15 @@ module Ci 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 @@ -561,7 +450,7 @@ module Ci end def erase_trace! - self.trace = nil + trace.erase! end def update_erased!(user = nil) @@ -623,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/pipeline.rb b/app/models/ci/pipeline.rb index 49dec770096..445247f1b41 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -4,14 +4,25 @@ 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' + + 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 + 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 +31,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,6 +75,10 @@ module Ci 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) } end @@ -82,6 +96,8 @@ module Ci pipeline.run_after_commit do PipelineHooksWorker.perform_async(id) + Ci::ExpirePipelineCacheService.new(project, nil) + .execute(pipeline) end end @@ -160,10 +176,6 @@ module Ci end end - def artifacts - builds.latest.with_artifacts_not_expired.includes(project: [:namespace]) - end - def valid_commit_sha if self.sha == Gitlab::Git::BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") @@ -200,27 +212,37 @@ module Ci !tag? end - def manual_actions - builds.latest.manual_actions.includes(project: [:namespace]) - end - def stuck? - builds.pending.includes(:project).any?(&:stuck?) + pending_builds.any?(&:stuck?) end def retryable? - builds.latest.failed_or_canceled.any?(&:retryable?) + retryable_builds.any? end def cancelable? - statuses.cancelable.any? + 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) @@ -328,7 +350,6 @@ module Ci when 'manual' then block end end - refresh_build_status_cache end def predefined_variables @@ -370,10 +391,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_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/trigger.rb b/app/models/ci/trigger.rb index cba1d81a861..2f64f70685a 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -7,12 +7,15 @@ module Ci belongs_to :project belongs_to :owner, class_name: "User" - has_many :trigger_requests, dependent: :destroy + has_many :trigger_requests + has_one :trigger_schedule, dependent: :destroy validates :token, presence: true, uniqueness: true before_validation :set_default_values + accepts_nested_attributes_for :trigger_schedule + def set_default_values self.token = SecureRandom.hex(15) if self.token.blank? end @@ -36,5 +39,9 @@ module Ci def can_access_project? self.owner_id.blank? || Ability.allowed?(self.owner, :create_build, project) end + + def trigger_schedule + super || build_trigger_schedule(project: project) + end end end diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/trigger_schedule.rb new file mode 100644 index 00000000000..012a18eb439 --- /dev/null +++ b/app/models/ci/trigger_schedule.rb @@ -0,0 +1,41 @@ +module Ci + class TriggerSchedule < ActiveRecord::Base + extend Ci::Model + include Importable + + acts_as_paranoid + + belongs_to :project + belongs_to :trigger + + validates :trigger, presence: { unless: :importing? } + 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? } + + before_save :set_next_run_at + + scope :active, -> { where(active: true) } + + def importing_or_inactive? + importing? || !active? + 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['trigger_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/commit.rb b/app/models/commit.rb index ce92cc369ad..8b8b3f00202 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 @@ -203,6 +204,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 @@ -321,14 +326,13 @@ 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) - else - raw.diffs(*args) - end + # NOTE: This feature is intentionally disabled until + # https://gitlab.com/gitlab-org/gitaly/issues/178 is resolved + # if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + # Gitlab::GitalyClient::Commit.diff_from_parent(self, *args) + # else + raw.diffs(*args) + # end end def diffs(diff_options = nil) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 17b322b5ae3..2c4033146bf 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 @@ -137,6 +138,10 @@ class CommitStatus < ActiveRecord::Base false end + def auto_canceled? + canceled? && auto_canceled_by_id? + end + # Added in 9.0 to keep backward compatibility for projects exported in 8.17 # and prior. def gl_project_id diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb new file mode 100644 index 00000000000..8ee42875670 --- /dev/null +++ b/app/models/concerns/discussion_on_diff.rb @@ -0,0 +1,49 @@ +# 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?, + + 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 0a1a65da05a..dff7b6e3523 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -68,7 +68,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') } @@ -76,6 +76,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/issuable.rb b/app/models/concerns/issuable.rb index b4dded7e27e..3d2258d5e3e 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -292,17 +292,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 { diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index b8dd27a7afe..6c27dd5aa5c 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,17 @@ module NoteOnDiff raise NotImplementedError end - def can_be_award_emoji? - false + def active?(diff_refs = nil) + raise NotImplementedError 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..c41b807df8a 100644 --- a/app/models/concerns/protected_branch_access.rb +++ b/app/models/concerns/protected_branch_access.rb @@ -2,20 +2,10 @@ module ProtectedBranchAccess extend ActiveSupport::Concern included do - 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 + include ProtectedRefAccess - def humanize - self.class.human_access_levels[self.access_level] - end - - def check_access(user) - return true if user.is_admin? + belongs_to :protected_branch - project.team.max_member_access(user.id) >= access_level + delegate :project, to: :protected_branch 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/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..aca99feee53 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -83,6 +83,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 diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb new file mode 100644 index 00000000000..82f4182d59a --- /dev/null +++ b/app/models/container_repository.rb @@ -0,0 +1,81 @@ +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('/') + 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/diff_discussion.rb b/app/models/diff_discussion.rb new file mode 100644 index 00000000000..6a6466b493b --- /dev/null +++ b/app/models/diff_discussion.rb @@ -0,0 +1,27 @@ +# 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, + :latest_merge_request_diff, + + to: :first_note + + def legacy_diff_discussion? + false + 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..abe4518d62a 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 - end - - def new_diff_note? - true + def discussion_class(*) + DiffDiscussion end - def diff_attributes - { position: position.to_json } - 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 position=(new_position) - if new_position.is_a?(String) - new_position = JSON.parse(new_position) rescue nil - end + if new_position.is_a?(Hash) + new_position = new_position.with_indifferent_access + new_position = Gitlab::Diff::Position.new(new_position) + end - if new_position.is_a?(Hash) - new_position = new_position.with_indifferent_access - new_position = Gitlab::Diff::Position.new(new_position) + super(new_position) end - - super(new_position) end def diff_file @@ -88,41 +65,10 @@ 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? + def latest_merge_request_diff + return unless for_merge_request? - 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? - - self.noteable.find_diff_discussion(self.discussion_id) + self.noteable.merge_request_diff_for(self.position.diff_refs) end private @@ -131,42 +77,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/group.rb b/app/models/group.rb index 60274386103..106084175ff 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -27,11 +27,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. @@ -223,4 +226,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/individual_note_discussion.rb b/app/models/individual_note_discussion.rb new file mode 100644 index 00000000000..c3f21c55240 --- /dev/null +++ b/app/models/individual_note_discussion.rb @@ -0,0 +1,13 @@ +# 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 +end diff --git a/app/models/issue.rb b/app/models/issue.rb index f9704b0d754..d39ae3a6c92 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 @@ -25,8 +26,6 @@ class Issue < ActiveRecord::Base 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 :without_due_date, -> { where(due_date: nil) } diff --git a/app/models/label.rb b/app/models/label.rb index 568fa6d44f5..d8b0e250732 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 @@ -193,4 +195,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..e617ce36f56 --- /dev/null +++ b/app/models/legacy_diff_discussion.rb @@ -0,0 +1,33 @@ +# 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 legacy_diff_discussion? + true + end + + def self.note_class + LegacyDiffNote + end + + def active?(*args) + return @active if @active.present? + + @active = first_note.active?(*args) + end + + def collapsed? + !active? + 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/members/group_member.rb b/app/models/members/group_member.rb index 446f9f8f8a7..483425cd30f 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 diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 8d740adb771..1d4827375d7 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1,6 +1,7 @@ class MergeRequest < ActiveRecord::Base include InternalId include Issuable + include Noteable include Referable include Sortable @@ -103,7 +104,6 @@ class MergeRequest < ActiveRecord::Base 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) } @@ -366,6 +366,14 @@ class MergeRequest < ActiveRecord::Base merge_request_diff(true) end + def merge_request_diff_for(diff_refs) + @merge_request_diffs_by_diff_refs ||= Hash.new do |h, diff_refs| + h[diff_refs] = merge_request_diffs.viewable.select_without_diff.find_by_diff_refs(diff_refs) + end + + @merge_request_diffs_by_diff_refs[diff_refs] + end + def reload_diff_if_branch_changed if source_branch_changed? || target_branch_changed? reload_diff @@ -442,7 +450,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 @@ -475,43 +483,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? @@ -857,8 +829,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? diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 6ad56b842b2..6604af2b47e 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 diff --git a/app/models/milestone.rb b/app/models/milestone.rb index ac205b9b738..652b1551928 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -153,10 +153,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/note.rb b/app/models/note.rb index 16d66cb1427..630d0adbece 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,6 +11,10 @@ class Note < ActiveRecord::Base include FasterCacheKeys include CacheMarkdownField include AfterCommitQueue + include ResolvableNote + include IgnorableColumn + + ignore_column :original_discussion_id cache_markdown_field :note, pipeline: :note @@ -32,9 +39,6 @@ class Note < ActiveRecord::Base 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" - has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy has_one :system_note_metadata @@ -54,10 +58,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 +74,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 +82,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 +93,33 @@ 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) + diff_notes. + fresh. + discussions. + select { |n| n.active?(diff_refs) }. + group_by(&:line_code) end def count_for_collection(ids, type) @@ -121,35 +130,19 @@ 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? + def latest_merge_request_diff + nil end def max_attachment_size @@ -228,7 +221,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 +232,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 +314,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/out_of_context_discussion.rb b/app/models/out_of_context_discussion.rb new file mode 100644 index 00000000000..85794630f70 --- /dev/null +++ b/app/models/out_of_context_discussion.rb @@ -0,0 +1,22 @@ +# 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 +end diff --git a/app/models/project.rb b/app/models/project.rb index 1f95d00baf8..a160efba912 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -116,6 +116,7 @@ class Project < ActiveRecord::Base 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 @@ -134,6 +135,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' @@ -159,6 +161,7 @@ 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' @@ -170,6 +173,8 @@ class Project < ActiveRecord::Base has_many :environments, dependent: :destroy has_many :deployments, dependent: :destroy + 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 @@ -258,6 +263,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. @@ -406,32 +413,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') @@ -870,14 +860,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 @@ -892,16 +874,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? @@ -922,10 +896,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) @@ -1100,25 +1074,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 @@ -1212,7 +1182,7 @@ class Project < ActiveRecord::Base end 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) @@ -1272,7 +1242,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 @@ -1368,11 +1338,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 @@ -1405,4 +1370,15 @@ 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 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..7621a5fa2d8 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 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..4628d9b1a7b 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] @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 #{branch_link} #{ref_type} by #{user_name} #{humanized_status}", + subtitle: "in #{project_link}", + text: "in #{duration} #{time_measure}", + 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 #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}" end def humanized_status @@ -74,5 +84,9 @@ module ChatMessage def pipeline_link "[##{pipeline_id}](#{pipeline_url})" end + + def time_measure + 'second'.pluralize(duration) + end end end diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb index 2d73b71ec37..c52dd6ef8ef 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 @@ -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 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..fa782c6fbb7 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -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" diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 3b90fd1c2c7..97e997d3899 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -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 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..9b218fd81b4 --- /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/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_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/repository.rb b/app/models/repository.rb index dc1c1fab880..2b11ed6128e 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -6,6 +6,8 @@ class Repository attr_accessor :path_with_namespace, :project + delegate :ref_name_for_sha, to: :raw_repository + CommitError = Class.new(StandardError) CreateTreeError = Class.new(StandardError) @@ -405,8 +407,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. @@ -700,14 +700,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 @@ -971,13 +963,15 @@ class Repository end def is_ancestor?(ancestor_id, descendant_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 + # NOTE: This feature is intentionally disabled until + # https://gitlab.com/gitlab-org/gitlab-ce/issues/30586 is resolved + # 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? @@ -1162,6 +1156,8 @@ class Repository @project.repository_storage_path end + delegate :gitaly_channel, :gitaly_repository, to: :raw_repository + def initialize_raw_repository Gitlab::Git::Repository.new(project.repository_storage, path_with_namespace + '.git') end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index f4bcb49b34d..bfaf0eb2fae 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 5a0ec58d193..dc76bf925d3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -237,6 +237,7 @@ class Service < ActiveRecord::Base slack_slash_commands slack teamcity + microsoft_teams ] if Rails.env.development? service_names += %w[mock_ci mock_deployment mock_monitoring] diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 30aca62499c..380835707e8 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -2,6 +2,7 @@ class Snippet < ActiveRecord::Base include Gitlab::VisibilityLevel include Linguist::BlobHelper include CacheMarkdownField + include Noteable include Participable include Referable include Sortable diff --git a/app/models/user.rb b/app/models/user.rb index 95a766f2ede..457ba05fb04 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -89,7 +89,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' @@ -484,6 +485,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 +555,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 +587,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 @@ -955,6 +956,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 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/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 cb58c115d54..87398303c68 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -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 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/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 3f16dd66d54..ad8b4d43e8f 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -69,13 +69,13 @@ class PipelineEntity < Grape::Entity alias_method :pipeline, :object def can_retry? - pipeline.retryable? && - can?(request.user, :update_pipeline, pipeline) + can?(request.user, :update_pipeline, pipeline) && + pipeline.retryable? end def can_cancel? - pipeline.cancelable? && - can?(request.user, :update_pipeline, pipeline) + can?(request.user, :update_pipeline, pipeline) && + pipeline.cancelable? end def detailed_status diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 7829df9fada..e7a9df8ac4e 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -13,7 +13,15 @@ class PipelineSerializer < BaseSerializer def represent(resource, opts = {}) if resource.is_a?(ActiveRecord::Relation) - resource = resource.includes(project: :namespace) + resource = resource.preload([ + :retryable_builds, + :cancelable_statuses, + :trigger_requests, + :project, + { pending_builds: :project }, + { manual_actions: :project }, + { artifacts: :project } + ]) end if paginated? 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/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 38a85e9fc42..21350be5557 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -53,6 +53,8 @@ module Ci .execute(pipeline) end + cancel_pending_pipelines if project.auto_cancel_pending_pipelines? + pipeline.tap(&:process!) end @@ -63,6 +65,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 diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb new file mode 100644 index 00000000000..91d9c1d2ba1 --- /dev/null +++ b/app/services/ci/expire_pipeline_cache_service.rb @@ -0,0 +1,51 @@ +module Ci + class ExpirePipelineCacheService < BaseService + attr_reader :pipeline + + def execute(pipeline) + @pipeline = pipeline + store = Gitlab::EtagCaching::Store.new + + store.touch(project_pipelines_path) + store.touch(commit_pipelines_path) if pipeline.commit + store.touch(new_merge_request_pipelines_path) + merge_requests_pipelines_paths.each { |path| store.touch(path) } + + Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(@pipeline) + end + + private + + def project_pipelines_path + Gitlab::Routing.url_helpers.namespace_project_pipelines_path( + project.namespace, + project, + format: :json) + end + + def commit_pipelines_path + Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path( + project.namespace, + project, + pipeline.commit.id, + format: :json) + end + + def new_merge_request_pipelines_path + Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path( + project.namespace, + project, + format: :json) + end + + def merge_requests_pipelines_paths + pipeline.merge_requests.collect do |merge_request| + Gitlab::Routing.url_helpers.pipelines_namespace_project_merge_request_path( + project.namespace, + project, + merge_request, + format: :json) + end + end + end +end diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index f72ddbf690c..ecc6173a96a 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -7,9 +7,7 @@ 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| Ci::RetryBuildService.new(project, current_user) .reprocess(build) 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..38a113caec7 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -11,7 +11,7 @@ class DeleteBranchService < BaseService return error('Cannot remove HEAD branch', 405) end - if project.protected_branch?(branch_name) + if ProtectedBranch.protected?(project, branch_name) return error('Protected branch cant be removed', 405) end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index bc7431c89a8..45411c779cc 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -127,7 +127,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/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/notes/build_service.rb b/app/services/notes/build_service.rb new file mode 100644 index 00000000000..ea7cacc956c --- /dev/null +++ b/app/services/notes/build_service.rb @@ -0,0 +1,25 @@ +module Notes + class BuildService < ::BaseService + def execute + in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id) + + if project && in_reply_to_discussion_id.present? + discussion = project.notes.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 + 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/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/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/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 595653ea58a..49d45ec9dbd 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -7,6 +7,8 @@ module SlashCommands # 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 = {} diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 35cfcc3682e..c9e25c7aaa2 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -228,12 +228,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 diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index a847a71a66a..93ca7b1141a 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -11,7 +11,7 @@ module Users user = User.new(build_user_params) - if current_user&.is_admin? + if current_user&.admin? if params[:reset_password] @reset_token = user.generate_reset_token params[:force_random_password] = true @@ -47,7 +47,7 @@ module Users private def can_create_user? - (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.is_admin? + (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.admin? end # Allowed params for creating a user (admins only) @@ -94,7 +94,7 @@ module Users end def build_user_params - if current_user&.is_admin? + if current_user&.admin? user_params = params.slice(*admin_create_params) user_params[:created_by_id] = current_user&.id diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index a3b32a71a64..ba58b174cc0 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -26,7 +26,7 @@ module Users ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute end - move_issues_to_ghost_user(user) + MigrateToGhostUserService.new(user).execute # 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..1e1ed1791ec --- /dev/null +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -0,0 +1,59 @@ +# 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 + # 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 + + user.transaction do + @ghost_user = User.ghost + + migrate_issues + migrate_merge_requests + migrate_notes + migrate_abuse_reports + migrate_award_emoji + end + + user.reload + end + + private + + 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_emoji + user.award_emoji.update_all(user_id: ghost_user.id) + end + end +end diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index d6ccf0dc92c..d2783ce5b2f 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -38,10 +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 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/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 5d51a2b5cbc..f4ba44096d3 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| @@ -571,6 +571,7 @@ 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/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index ebca9beb035..8c9fdc9ae42 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -125,7 +125,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 +133,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 +141,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..6a208d76a38 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -13,7 +13,7 @@ = 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: diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index 551edf14361..d9c7948763a 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -51,7 +51,7 @@ = f.check_box :enable_ssl_verification %strong Enable SSL verification .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,7 +62,7 @@ - @hooks.each do |hook| %li .controls - = link_to 'Test Hook', admin_hook_test_path(hook), class: "btn btn-sm" + = 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" .monospace= hook.url %div 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/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..c7cd86527d3 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -33,7 +33,7 @@ = 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' + = 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 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/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index 13eaba41f4c..0e848386ebb 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -11,4 +11,4 @@ = 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/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..549364761e6 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -7,7 +7,7 @@ .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..8440fb3d785 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_diff_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: 'monospace' - 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..34789808f10 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,18 +1,20 @@ -%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: "projects/notes/note", collection: discussion.notes, as: :note -- 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/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/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/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..8d3aa4d1a74 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -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/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..700c5e61a14 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' } 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/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/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index a611481a0a4..19473b6ab27 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,9 +28,9 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" - = javascript_include_tag(*webpack_asset_paths("runtime")) - = javascript_include_tag(*webpack_asset_paths("common")) - = javascript_include_tag(*webpack_asset_paths("main")) + = webpack_bundle_tag "runtime" + = webpack_bundle_tag "common" + = webpack_bundle_tag "main" - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 23abf6897d4..a9893dea68f 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -29,11 +29,11 @@ - 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 @@ -47,17 +47,19 @@ %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 = cached_assigned_issuables_count(current_user, :issues, :opened) + %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 = cached_assigned_issuables_count(current_user, :merge_requests, :opened) + %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) %li.header-user.dropdown = link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do 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..444ecc414c0 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)) = 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)) = 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/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/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/_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..c762578971a 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? %p Assignee: #{@issue.assignee_name} + +- if @issue.description + %div + = markdown(@issue.description, pipeline: :email, author: @issue.author) 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_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 4beb6fcee5d..a83faa839df 100644 --- a/app/views/notify/pipeline_failed_email.html.haml +++ b/app/views/notify/pipeline_failed_email.html.haml @@ -137,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 c1a4ea40cf5..294238eee51 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>. 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/project_was_exported_email.html.haml b/app/views/notify/project_was_exported_email.html.haml index 76440926a2b..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), rel: 'nofollow', download: '', 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/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 5ce2220c907..d843cacd52d 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? 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/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/_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/_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/_last_push.html.haml b/app/views/projects/_last_push.html.haml index a08436715d2..768bc1fb323 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -10,9 +10,9 @@ - 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/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 4ad77b6266d..35885b2c7b4 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -7,7 +7,7 @@ #blob-content-holder.tree-holder .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 diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 2b2ee6ed987..9aafff343f0 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -25,4 +25,10 @@ #blob-content-holder.blob-content-holder %article.file-holder = render "projects/blob/header", blob: blob - = render blob.to_partial_path(@project), blob: blob + + - if blob.empty? + .file-content.code + .nothing-here-block + Empty file + - else + = render blob.to_partial_path(@project), blob: blob diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index deeeae3d64a..7a4a293548c 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -1,3 +1,4 @@ +- blame = local_assigns.fetch(:blame, false) .js-file-title.file-title-flex-parent .file-header-content = blob_icon blob.mode, blob.name @@ -12,15 +13,15 @@ .file-actions.hidden-xs .btn-group{ role: "group" }< - = copy_blob_content_button(blob) if blob_text_viewable?(blob) + = copy_blob_content_button(blob) if !blame && blob_rendered_as_text?(blob) = open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id)) = 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), + - if blame + = 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), @@ -32,8 +33,15 @@ = 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) + .btn-group{ role: "group" }< + = edit_blob_link if blob_text_viewable?(blob) + - if current_user = replace_blob_link = delete_blob_link +- if current_user + .js-file-fork-suggestion-section.file-fork-suggestion.hidden + %span.file-fork-suggestion-note + You don't have permission to edit this file. Try forking this project to edit the file. + = link_to 'Fork', fork_path, method: :post, class: 'btn btn-grouped btn-inverted btn-new' + %button.js-cancel-fork-suggestion.btn.btn-grouped{ type: 'button' } + Cancel diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml index ea3cecb86a9..73877d730f5 100644 --- a/app/views/projects/blob/_image.html.haml +++ b/app/views/projects/blob/_image.html.haml @@ -1,15 +1,2 @@ .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}" } + %img{ src: namespace_project_raw_path(@project.namespace, @project, @id), alt: blob.name } diff --git a/app/views/projects/blob/_markup.html.haml b/app/views/projects/blob/_markup.html.haml new file mode 100644 index 00000000000..4ee4b03ff04 --- /dev/null +++ b/app/views/projects/blob/_markup.html.haml @@ -0,0 +1,4 @@ +- blob.load_all_data!(@repository) + +.file-content.wiki + = render_markup(blob.name, blob.data) diff --git a/app/views/projects/blob/_svg.html.haml b/app/views/projects/blob/_svg.html.haml new file mode 100644 index 00000000000..93be58fc658 --- /dev/null +++ b/app/views/projects/blob/_svg.html.haml @@ -0,0 +1,9 @@ +- 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) + .file-content.image_file + %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}", alt: blob.name } +- else + = render 'too_large' diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index d52733d2bd6..2a178325041 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -5,7 +5,7 @@ .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 } } ) + = 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 diff --git a/app/views/projects/blob/_text.html.haml b/app/views/projects/blob/_text.html.haml index 7b16d266982..20638f6961d 100644 --- a/app/views/projects/blob/_text.html.haml +++ b/app/views/projects/blob/_text.html.haml @@ -1,19 +1,2 @@ -- 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 +- blob.load_all_data!(@repository) += render 'shared/file_highlight', blob: blob, repository: @repository diff --git a/app/views/projects/blob/_too_large.html.haml b/app/views/projects/blob/_too_large.html.haml new file mode 100644 index 00000000000..a505f87df40 --- /dev/null +++ b/app/views/projects/blob/_too_large.html.haml @@ -0,0 +1,5 @@ +.file-content.code + .nothing-here-block + The file 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. diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 9eb610ba9c0..0f9ef3eded3 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -15,13 +15,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 diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index 7eb17e887e7..104db85809c 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -1,14 +1,16 @@ +- pipeline = @build.pipeline + .content-block.build-header.top-area .header-content - = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false + = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title Job %strong.js-build-id ##{@build.id} in pipeline - = link_to pipeline_path(@build.pipeline) do - %strong ##{@build.pipeline.id} + = link_to pipeline_path(pipeline) do + %strong ##{pipeline.id} for commit - = link_to namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha) do - %strong= @build.pipeline.short_sha + = link_to namespace_project_commit_path(@project.namespace, @project, pipeline.sha) do + %strong= pipeline.short_sha from = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do %code diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 6f45d5b0689..c4159ce1a36 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -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 @@ -136,7 +136,7 @@ - else = build.id - if build.retried? - %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } + %i.fa.fa-spinner.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } :javascript new Sidebar(); 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..65162aacda1 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/builds/index.html.haml @@ -17,7 +17,7 @@ = link_to 'Get started with CI/CD 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..0faad57a312 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 + %span< + Showing last + %span.js-truncated-info-size>< + KiB of log %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 769640c4842..3e1c8f25dea 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, namespace_project_commits_path(job.project.namespace, job.project, job.ref), class: "monospace branch-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-id monospace" - - 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 - 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), rel: 'nofollow', download: '', 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 && can?(current_user, :play_build, build) - = 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, :play_build, jop) + = 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..f604d6e5fbb 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -1,7 +1,7 @@ .page-content-header .header-main-content %strong Commit #{@commit.short_id} - = clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard") + = 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 +20,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 diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml index c2b32a22170..3ee85723ebe 100644 --- a/app/views/projects/commit/_pipeline.html.haml +++ b/app/views/projects/commit/_pipeline.html.haml @@ -47,7 +47,6 @@ %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 diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index d5fc283aa8d..0d11da2451a 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -10,6 +10,7 @@ - else .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment + = render "projects/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 4b1ff75541a..8f32d2b72e5 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") + = clipboard_button(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_browse_code(project, commit) 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..0f080b6acee 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -21,6 +21,6 @@ = 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/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index c09c7b87e24..3e426ee9e7d 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 + - 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/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/show.html.haml b/app/views/projects/environments/show.html.haml index ff6aaebda22..7315e671056 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -8,9 +8,9 @@ %h3.page-title= @environment.name .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/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..f458646522c 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 @@ -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/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/index.html.haml b/app/views/projects/issues/index.html.haml index f3a429d12d9..4ac0bc1d028 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -24,9 +24,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/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index cfb44bd206c..15b5a51c1d0 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -1,9 +1,9 @@ - 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 }} diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index e7fcac4c477..03069804c86 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -53,5 +53,6 @@ :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/_how_to_merge.html.haml b/app/views/projects/merge_requests/show/_how_to_merge.html.haml index cde0ce08e14..f3372c7657f 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} diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml index 74a7b1dc498..547be78992e 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -72,13 +72,16 @@ = 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 @diff_notes_disabled .comments-disabled-notif.content-block = icon('info-circle') - if @start_sha Comments are disabled because you're comparing two versions of this merge request. - 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' + Discussions on this version of the merge request are displayed but comment creation is disabled. + + .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/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml index caf3bf54eef..a0f54bd28ec 100644 --- a/app/views/projects/merge_requests/widget/_merged_buttons.haml +++ b/app/views/projects/merge_requests/widget/_merged_buttons.haml @@ -7,7 +7,7 @@ - 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 + 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 diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml index e5ec151a61d..4cbd22150c7 100644 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ b/app/views/projects/merge_requests/widget/open/_accept.html.haml @@ -10,24 +10,24 @@ - 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 + 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 + Select merge moment %ul.js-merge-dropdown.dropdown-menu.dropdown-menu-right{ role: 'menu' } %li - = link_to "#", class: "merge_when_pipeline_succeeds" do + = link_to "#", class: "merge-when-pipeline-succeeds" do = icon('check fw') - Merge When Pipeline Succeeds + Merge when pipeline succeeds %li = link_to "#", class: "accept-merge-request" do = icon('warning fw') - Merge Immediately + Merge immediately - else = f.button class: "btn btn-grouped js-merge-button accept-merge-request" do - Accept Merge Request + Accept merge request - if @merge_request.force_remove_source_branch? .accept-control The source branch will be removed. 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 index 5f347acce4d..76cc1ecd8a5 100644 --- 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 @@ -26,8 +26,8 @@ - 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 + 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 + Cancel automatic merge diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index b6340a00b29..8e85b2e8a20 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -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/show.html.haml b/app/views/projects/milestones/show.html.haml index 5249d752585..8b62b156853 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -23,9 +23,9 @@ .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 diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/projects/notes/_comment_button.html.haml new file mode 100644 index 00000000000..6bb55f04b6e --- /dev/null +++ b/app/views/projects/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 + + %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/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml index e8e450742b5..8b4e5928e0d 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/projects/notes/_edit_form.html.haml @@ -9,6 +9,6 @@ .note-form-actions.clearfix .settings-message.note-edit-warning.js-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-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/projects/notes/_form.html.haml index b561052e721..0d835a9e949 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -4,12 +4,18 @@ = 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 @@ -22,7 +28,9 @@ .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: 'projects/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/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 18afa811bad..c12c05eeb73 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -5,8 +5,11 @@ %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' + - 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 %a.visible-xs{ href: user_path(note.author) } @@ -31,7 +34,7 @@ - if note.resolvable? - can_resolve = can?(current_user, :resolve_note, note) %resolve-btn{ "project-path" => project_path(note.project), - "discussion-id" => note.discussion_id, + "discussion-id" => note.discussion_id(@noteable), ":note-id" => note.id, ":resolved" => note.resolved?, ":can-resolve" => can_resolve, diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/projects/notes/_notes.html.haml index 022578bd6db..2b2bab09c74 100644 --- a/app/views/projects/notes/_notes.html.haml +++ b/app/views/projects/notes/_notes.html.haml @@ -1,7 +1,7 @@ -- if @discussions.present? +- if defined?(@discussions) - @discussions.each do |discussion| - - if discussion.for_target?(@noteable) - = render partial: "projects/notes/note", object: discussion.first_note, as: :note + - if discussion.individual_note? + = render partial: "projects/notes/note", collection: discussion.notes, as: :note - else = render 'discussions/discussion', discussion: discussion - else diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 4be9a1371ec..ab6baaf35b6 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 @@ -46,4 +46,4 @@ \... %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") + = 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..d7cefb8613e 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -36,7 +36,6 @@ %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 diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index 132f6372e40..a3f84476dea 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -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/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml index 4d8169815b3..f8cfe5e4b11 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 + = @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..6e187b54a59 --- /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 + = 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..74851519077 --- /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', + filter: true, dropdown_class: "dropdown-menu-selectable", 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..97e5cd6f9d2 --- /dev/null +++ b/app/views/projects/protected_tags/_matching_tag.html.haml @@ -0,0 +1,9 @@ +%tr + %td + = link_to matching_tag.name, namespace_project_tree_path(@project.namespace, @project, matching_tag.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_short_id') + = 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..26bd3a1f5ed --- /dev/null +++ b/app/views/projects/protected_tags/_protected_tag.html.haml @@ -0,0 +1,21 @@ +%tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } } + %td + = 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_short_id') + = 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..62823bee46e --- /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 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..63743f28b3c --- /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 + = @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/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/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 4c02302e161..5402320cb66 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -3,3 +3,4 @@ = 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 edfe6da1816..de1229d58aa 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -13,7 +13,7 @@ = render "home_panel" - if current_user && can?(current_user, :download_code, @project) - %nav.project-stats.limit-container-width{ class: container_class } + %nav.project-stats{ class: container_class } %ul.nav %li = link_to project_files_path(@project) do @@ -74,11 +74,11 @@ Set up auto deploy - if @repository.commit - .limit-container-width{ class: container_class } + %div{ class: container_class } .project-last-commit = render 'projects/last_commit', commit: @repository.commit, ref: current_ref, project: @project -.limit-container-width{ class: container_class } +%div{ class: container_class } - if @project.archived? .text-warning.center.prepend-top-20 %p 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..451e011a4b8 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -6,6 +6,11 @@ %span.item-title = icon('tag') = tag.name + + - if protected_tag?(@project, tag) + %span.label.label-success + protected + - if tag.message.present? = strip_gpg_signature(tag.message) @@ -30,5 +35,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/show.html.haml b/app/views/projects/tags/show.html.haml index fad3c5c2173..1c4135c8a54 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -7,6 +7,9 @@ .nav-text .title %span.item-title= @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 +27,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? diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 6855c463c6d..2497a2d91b1 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -10,7 +10,7 @@ %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") + = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard") = time_ago_with_tooltip(@commit.committed_date) \- = @commit.full_title diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index 5f708b3a2ed..8582bcbb8cc 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -8,4 +8,26 @@ .form-group = f.label :key, "Description", class: "label-light" = f.text_field :description, class: "form-control", required: true, title: 'Trigger description is required.', placeholder: "Trigger description" + - if @trigger.persisted? + %hr + = f.fields_for :trigger_schedule do |schedule_fields| + = schedule_fields.hidden_field :id + .form-group + .checkbox + = schedule_fields.label :active do + = schedule_fields.check_box :active + %strong Schedule trigger (experimental) + .help-block + If checked, this trigger will be executed periodically according to cron and timezone. + = link_to icon('question-circle'), help_page_path('ci/triggers', anchor: 'schedule') + .form-group + = schedule_fields.label :cron, "Cron", class: "label-light" + = schedule_fields.text_field :cron, class: "form-control", title: 'Cron specification is required.', placeholder: "0 1 * * *" + .form-group + = schedule_fields.label :cron, "Timezone", class: "label-light" + = schedule_fields.text_field :cron_timezone, class: "form-control", title: 'Timezone is required.', placeholder: "UTC" + .form-group + = schedule_fields.label :ref, "Branch or tag", class: "label-light" + = schedule_fields.text_field :ref, class: "form-control", title: 'Branch or tag is required.', placeholder: "master" + .help-block Existing branch name, tag = f.submit btn_text, class: "btn btn-save" diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index cc74e50a5e3..84e945ee0df 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -22,6 +22,8 @@ %th %strong Last used %th + %strong Next run at + %th = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger - else %p.settings-message.text-center.append-bottom-default diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index ed68e0ed56d..ebd91a8e2af 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 @@ -29,6 +29,12 @@ - else Never + %td + - if trigger.trigger_schedule&.active? + = trigger.trigger_schedule.real_next_run + - else + Never + %td.text-right.trigger-actions - take_ownership_confirmation = "By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?" - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?" diff --git a/app/views/projects/wikis/_main_links.html.haml b/app/views/projects/wikis/_main_links.html.haml index 5211ade1a5f..86178257af8 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 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/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/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 03684389742..34a4d7398bc 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -19,7 +19,7 @@ = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true .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/_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/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index af4cc90f4a7..e8062848fc3 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'); 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/icons/_icon_arrow_circle_o_right.svg b/app/views/shared/icons/_icon_arrow_circle_o_right.svg new file mode 100644 index 00000000000..db28b5e2d7a --- /dev/null +++ b/app/views/shared/icons/_icon_arrow_circle_o_right.svg @@ -0,0 +1 @@ +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1280 896q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192h-352q-13 0-22.5-9.5t-9.5-22.5v-192q0-13 9.5-22.5t22.5-9.5h352v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23zm160 0q0-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_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_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_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_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_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_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_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_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/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 847a86e2e68..c72268473ca 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -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 diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 330fa8a5b10..b447996a8ab 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -10,85 +10,93 @@ .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 + = dropdown_tag(content_tag(:i, '', class: 'fa fa-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 + .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) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 92d2d93a732..2e0d6a129fb 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -160,13 +160,13 @@ - 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)}'); 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/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 2810f1377b2..ccc808ff43e 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -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/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 37e2a377a69..ee3be3c789a 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -89,7 +89,7 @@ = f.label :enable_ssl_verification do = f.check_box :enable_ssl_verification %strong Enable SSL verification - = f.submit "Add Webhook", class: "btn btn-create" + = f.submit "Add webhook", class: "btn btn-create" %hr %h5.prepend-top-default Webhooks (#{hooks.count}) 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/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/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb new file mode 100644 index 00000000000..9c1baf7e6c5 --- /dev/null +++ b/app/workers/trigger_schedule_worker.rb @@ -0,0 +1,18 @@ +class TriggerScheduleWorker + include Sidekiq::Worker + include CronjobQueue + + def perform + Ci::TriggerSchedule.active.where("next_run_at < ?", Time.now).find_each do |trigger_schedule| + begin + Ci::CreateTriggerRequestService.new.execute(trigger_schedule.project, + trigger_schedule.trigger, + trigger_schedule.ref) + rescue => e + Rails.logger.error "#{trigger_schedule.id}: Failed to trigger_schedule job: #{e.message}" + ensure + trigger_schedule.schedule_next_run! + end + end + end +end |